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/set → CustomDomainService.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-txt → checkTxtRecord():
- 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.
Step 4: Attribution verification (dix.su footer link)¶
POST /api/domain/check-attribution → checkAttributionForUser():
- 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:
GET /internal/custom-domain/pending-certs→ list of domains withdns_verified=TRUE AND attribution_verified=TRUE AND active=FALSE- For each domain runs:
- Copies files from
/etc/letsencrypt/live/myshop.com/to/etc/nginx/custom-certs/myshop.com/ - Important:
chmod 644 privkey.pem— certbot writes the key as 600 (root-only), but the nginx worker runs asuid=nginx POST /internal/custom-domain/cert-issued→active = TRUE- 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:
nginx activation¶
After regen_map(), the file custom-domain-map.conf is updated:
nginx reloads automatically every 5 minutes (background loop in docker-entrypoint.sh).
Force 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¶
-
Map.copyOf()+ nullable columns: R2DBC returns null for nullable DB columns.Map.copyOf()(Java 10+) throws NPE on null values. Fix: useLinkedHashMapwith null filtering. -
TXT record for subdomains: for
sub.myshop.comthe TXT host is checked at_dixsu.myshop.com(root two labels), not_dixsu.sub.myshop.com. -
SSL chicken-and-egg: don't check attribution through the custom domain before the certificate is issued — use
slug.dix.suinstead. -
privkey.pem 600 → 644: certbot creates the key with 600 permissions.
chmodis needed after every copy (incertbot-agentissue_cert + renew_all, and at nginx container startup via entrypoint). -
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. -
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.