Skip to content

Custom Domain — complete guide

A custom domain lets users open their tunnel at their own address (e.g. myshop.com) instead of the default slug.dix.su.


Architecture

Browser → https://myshop.com/
    → nginx (SNI: myshop.com)
      ssl_certificate     /etc/nginx/custom-certs/myshop.com/fullchain.pem
      ssl_certificate_key /etc/nginx/custom-certs/myshop.com/privkey.pem
      (loaded per-handshake via $ssl_server_name)
    → proxy_pass http://dixu_proxy:4000
      Host: admin-dixsu.dix.su          # slug is determined here
      X-Custom-Domain: myshop.com       # real domain preserved here
    → dixu_proxy InterstitialPlug
      parse_slug(conn.host)             # → "admin-dixsu"
      get_req_header(conn,"x-custom-domain") → "myshop.com"
    → device_client (WebSocket → TCP relay → localhost:PORT on device)

Important: nginx loads the cert/key per TLS-handshake via $ssl_server_name. This allows adding new domains without reloading nginx.


DB table: custom_domains

Column Description
user_id owner
slug user's slug (for building the check-URL)
domain custom domain
active TRUE after certificate is issued
dns_verified TXT record verified
verify_txt_value one-time token dixsu-verify=<token>
cert_status pending / issued / failed
cert_expires_at Let's Encrypt certificate expiry
attribution_verified dix.su footer link confirmed
domain_changed_at date of last domain change (for 14-day cooldown)
domain_changes_this_month change counter for current month (limit 2)

Verification flow (steps 1–5)

Step 1: User enters the domain

POST /api/domain/setCustomDomainService.setDomain(): - Checks eligibility (plan pro or higher) - Checks cooldown: if domain_changed_at < now - 14 days — error - Checks monthly limit: if domain_changes_this_month >= 2 — error - Creates/updates the record: active=FALSE, dns_verified=FALSE, generates verify_txt_value (20-char UUID)

Step 2: User adds DNS records at their registrar

The user is shown:

CNAME (required for SSL):

Host:   @  (or empty, or myshop.com — depends on the registrar panel)
Type:   CNAME
Value:  admin-dixsu.dix.su

TXT (for ownership verification):

Host:   _dixsu  (RELATIVE, without the domain — BUT some registrar panels
                 require FQDN: _dixsu.myshop.com)
Type:   TXT
Value:  dixsu-verify=<token>

TXT host nuance: registrars differ: - Most (Namecheap, GoDaddy, reg.ru): enter _dixsu → creates the record _dixsu.myshop.com - Some (Cloudflare, some hosting panels): enter _dixsu.myshop.com (FQDN) → also creates _dixsu.myshop.com - If you enter _dixsu.myshop.com as the host at a registrar that appends the domain automatically, you get _dixsu.myshop.com.myshop.com — which is wrong. That's why the UI shows both variants as a hint.

The backend (checkTxtRecord) always queries _dixsu.<rootDomain>, where rootDomain is the last two domain labels. For example: for sub.myshop.com → checks _dixsu.myshop.com.

Step 3: DNS verification (TXT)

POST /api/domain/verify-txtcheckTxtRecord(): - Extracts rootDomain (last two labels) from domain - Queries TXT records for _dixsu.<rootDomain> via dnsjava - Looks for a record matching dixsu-verify=<token> - On success: dns_verified = TRUE

DNS propagation delay: minutes to 48 hours. Typical: 5–30 minutes.

POST /api/domain/check-attributioncheckAttributionForUser(): - Finds the record with dns_verified=TRUE - Builds check-URL: https://<slug>.dix.su/ (via the standard tunnel, not the custom domain — because the SSL cert hasn't been issued yet) - Makes HTTP GET with 10s timeout - Looks for href="https://dix.su" or href="https://www.dix.su" in the response body - On success: attribution_verified = TRUE

SSL chicken-and-egg note: attribution is checked via https://<slug>.dix.su/, not https://myshop.com/, because the certificate for the custom domain hasn't been issued yet.

Step 5: SSL certificate issuance (certbot-agent)

certbot-agent.py (in container dixsu-certbot-1) runs in a loop every 60 seconds:

  1. GET /internal/custom-domain/pending-certs → list of domains with dns_verified=TRUE AND attribution_verified=TRUE AND active=FALSE
  2. For each domain runs:
    certbot certonly --webroot -w /var/www/certbot -d myshop.com
    
  3. Copies files from /etc/letsencrypt/live/myshop.com/ to /etc/nginx/custom-certs/myshop.com/
  4. Important: chmod 644 privkey.pem — certbot writes the key as 600 (root-only), but the nginx worker runs as uid=nginx
  5. POST /internal/custom-domain/cert-issuedactive = TRUE
  6. Regenerates /etc/nginx/custom-certs/custom-domain-map.conf

After nginx rebuild: docker-entrypoint.sh automatically does chmod 644 on all privkey.pem files at every container start. For manual recovery:

docker exec dixsu-certbot-1 chmod 644 /etc/nginx/custom-certs/myshop.com/privkey.pem

nginx activation

After regen_map(), the file custom-domain-map.conf is updated:

map $host $custom_domain_slug {
    myshop.com  admin-dixsu;
    default     "";
}

nginx reloads automatically every 5 minutes (background loop in docker-entrypoint.sh). Force reload:

docker exec dixsu-nginx-1 nginx -s reload


Limits and cooldowns

Restriction Value Applies to
Changes per month 2 Changing to a new domain
Cooldown between changes 14 days Changing to a new domain
Disable/enable the same domain unlimited Deactivation only

Certificate renewal

certbot-agent.py runs certbot renew --quiet once a day, which renews certificates 30 days before expiry. After renewal: - Files are copied to /etc/nginx/custom-certs/<domain>/ - chmod 644 privkey.pem is applied - regen_map() recreates the map file - nginx reloads automatically within ≤5 minutes


Diagnostics

# Check domain status in DB
docker exec dixsu-postgres-1 psql -U dixsu -c \
  "SELECT domain, active, dns_verified, attribution_verified, cert_status,
          cert_expires_at, domain_changed_at
     FROM custom_domains WHERE domain='myshop.com';"

# certbot-agent logs
docker logs dixsu-certbot-1 --tail=50

# Check key permissions
docker exec dixsu-certbot-1 ls -la /etc/nginx/custom-certs/myshop.com/

# Fix permissions (if nginx can't open the key)
docker exec dixsu-certbot-1 chmod 644 /etc/nginx/custom-certs/myshop.com/privkey.pem
docker exec dixsu-nginx-1 nginx -s reload

# Verify TXT DNS record
dig TXT _dixsu.myshop.com +short

# Verify CNAME
dig CNAME myshop.com +short

# Verify SSL certificate
echo | openssl s_client -connect myshop.com:443 -servername myshop.com 2>/dev/null \
  | openssl x509 -noout -dates -subject

Known pitfalls

  1. Map.copyOf() + nullable columns: R2DBC returns null for nullable DB columns. Map.copyOf() (Java 10+) throws NPE on null values. Fix: use LinkedHashMap with null filtering.

  2. TXT record for subdomains: for sub.myshop.com the TXT host is checked at _dixsu.myshop.com (root two labels), not _dixsu.sub.myshop.com.

  3. SSL chicken-and-egg: don't check attribution through the custom domain before the certificate is issued — use slug.dix.su instead.

  4. privkey.pem 600 → 644: certbot creates the key with 600 permissions. chmod is needed after every copy (in certbot-agent issue_cert + renew_all, and at nginx container startup via entrypoint).

  5. nginx per-handshake cert loading: nginx loads cert/key at each TLS handshake via ssl_certificate $cert_path. This allows adding domains without reload, but means permission errors are only visible on an incoming connection, not at startup.

  6. Webroot challenge and nginx: certbot places challenge files in /var/www/certbot/.well-known/acme-challenge/. nginx must serve /.well-known/acme-challenge/ for the custom domain over HTTP (port 80) — the CNAME must already point to our server.