Custom Domain Management (F3.1)
Feature: F3.1 — Custom Domain Management
Status: 🟢 COMPLETED
Package:@vexlyx/api,@vexlyx/dashboard,@vexlyx/shared
Prisma Models:Domain,DnsRecord,Project
1. Overview
Custom Domain Management allows users to attach custom fully-qualified domain names (FQDNs) to their deployed projects, verify domain ownership through DNS TXT record challenges, and automatically configure Traefik v3 HTTP routing using zero-downtime dynamic file provider definitions.
When a domain is verified, Vexlyx generates a dynamic router configuration in docker/traefik/dynamic/domain-{domainId}.yml routing all incoming web traffic matching Host(...) to the project's container on traefik-net. When a domain is deleted, the dynamic configuration file is removed immediately with zero downtime or container restarts.
2. Architecture & Data Flow
┌─────────────────────────────────────────────────────────────┐
│ Next.js Dashboard UI │
│ - /domains (Global domain overview & project assignment) │
│ - /projects/[id] (Project detail DomainPanel) │
└──────────────────────────────┬──────────────────────────────┘
│ HTTP / JSON
▼
┌─────────────────────────────────────────────────────────────┐
│ Fastify API Server │
│ - POST /api/domains (Register domain) │
│ - GET /api/domains (List user domains) │
│ - GET /api/domains/:id (Get domain details) │
│ - POST /api/domains/:id/verify (Resolve DNS & verify) │
│ - DELETE /api/domains/:id (Unlink & cleanup) │
└───────────────┬──────────────────────────────┬──────────────┘
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────┐
│ PostgreSQL (Prisma) │ │ Traefik v3 Dynamic File │
│ - model Domain │ │ docker/traefik/dynamic/ │
│ * hostname (unique) │ │ domain-{domainId}.yml │
│ * status (PENDING/ACTIVE) │ └─────────────┬─────────────┘
│ * verificationToken │ │ Hot-reloaded
│ * projectId (relation) │ ▼
│ - model DnsRecord (TXT) │ ┌───────────────────────────┐
└───────────────────────────────┘ │ Traefik v3 Reverse Proxy │
│ routes Host(domain) to │
│ {service_name}@docker │
└───────────────────────────┘Key Workflow:
Domain Registration (
POST /api/domains):- Hostname validated against RFC 1123 FQDN syntax (lowercase, no protocols, valid labels).
- Generates a cryptographic verification token:
vexlyx-verify-<32-hex-chars>. - Stores
Domainin database with statusPENDINGand inserts a matchingDnsRecordTXT challenge. - Returns DNS instructions:
- Type:
TXT - Host:
_vexlyx-challenge.<hostname> - Value:
vexlyx-verification=<token> - Plus a
routingRecord(anArecord:<hostname>→ the server's public IP, from F5.9'sPUBLIC_IP) — TXT verification only proves ownership, it doesn't route traffic. Found live: a domain that verified successfully still showed nothing when visited, because nothing told the admin an A record was also needed.DomainPanel.tsx's DNS Instructions modal now shows both.
- Type:
DNS Ownership Verification (
POST /api/domains/:id/verify):- Queries
_vexlyx-challenge.<hostname>and<hostname>directly against a few public resolvers (Cloudflare/Google/Quad9 —new dns.Resolver()+setServers(), not the server's own configured resolver) — found live that a premature "Verify" click could get the server's local/cloud-provider resolver to negative-cache a lookup for up to the zone's SOA TTL (observed: 1 hour on an AWS VPC resolver), making a genuinely-published record look missing long after it actually propagated. Any one public resolver seeing the record is enough. - If matching token is detected:
- Domain status updated to
ACTIVE. - Invokes
syncTraefikRouter(domain).
- Domain status updated to
- If token is missing/mismatched:
- Domain status set to
ERROR. - Returns detected records and actionable troubleshooting guidance.
- Domain status set to
- Queries
Traefik Dynamic Routing (
syncTraefikRouter):- Traefik v3 in
docker-compose.ymlmounts./docker/traefik/dynamic/. - Writes
docker/traefik/dynamic/domain-{domainId}.ymlwith direct service loadbalancer routing acrosstraefik-net:yamlhttp: routers: domain-{domainId}: rule: Host(`{domain.hostname}`) entryPoints: - web service: service-{domainId} services: service-{domainId}: loadBalancer: servers: - url: "http://vexlyx-{serviceName}-app-1:{containerPort}" - Seamlessly proxies HTTP traffic to the target container with zero downtime.
- Traefik v3 in
Domain Deletion (
DELETE /api/domains/:id):- Unlinks
docker/traefik/dynamic/domain-{domainId}.yml. - Traefik instantly removes the HTTP router.
- Deletes domain row from PostgreSQL (cascade deletes associated
dns_records).
- Unlinks
3. API Reference
POST /api/domains
- Auth: Required (Session cookie)
- Body:json
{ "hostname": "app.example.com", "projectId": "cm1234567890abcdef" // optional } - Response (201 Created):json
{ "id": "dom_abc123", "hostname": "app.example.com", "status": "PENDING", "sslEnabled": false, "verificationToken": "vexlyx-verify-a1b2c3d4...", "projectId": "cm1234567890abcdef", "verificationInstructions": { "recordType": "TXT", "recordName": "_vexlyx-challenge.app.example.com", "recordValue": "vexlyx-verification=vexlyx-verify-a1b2c3d4...", "routingRecord": { "recordType": "A", "recordName": "app.example.com", "publicIp": "203.0.113.10" } } }
GET /api/domains
- Auth: Required
- Query Parameters:
projectId,status,search,page,limit - Response (200 OK): Array of
DomainResponseobjects.
POST /api/domains/:id/verify
- Auth: Required
- Query Parameters (Testing only):
?mockRecord=... - Response (200 OK):json
{ "verified": true, "status": "ACTIVE", "message": "Domain verified successfully. Traefik traffic routing is active.", "expectedRecord": { ... }, "detectedRecords": [ ... ] }
DELETE /api/domains/:id
- Auth: Required
- Response:
204 No Content
4. Testing
Automated Test Suite
Run the Python custom domain test suite:
python tests/test_custom_domains.pyCovered test cases:
test_valid_hostnames: Validates compliant RFC 1123 domain names and subdomains.test_invalid_hostnames: Rejects protocols, ports, trailing dots, spaces, invalid chars.test_traefik_yaml_generation: Verifies Traefik router configuration format, entryPoints, and Docker service names.test_verification_match: Tests token resolution matching and failure rejection.test_endpoint_unauthorized: Verifies 401 unauthorized protection on/api/domains.
Monorepo Validation
pnpm typecheck # Validates TypeScript across all 3 workspaces
pnpm lint # Validates ESLint rules
pnpm build # Validates Next.js and API production buildsLive Verification
Confirmed end-to-end on panel.mindgera.site: attached html.mindgera.site to a deployed project, verified via TXT (public-resolver check), added the now-surfaced A record, and confirmed it serves trusted HTTPS alongside the project's own default subdomain (F5.10) — both routes work simultaneously without interfering with each other.
5. UI Integration
- Project Settings (
DomainPanel): Located at/projects/[id]. Allows attaching and verifying domains directly in the context of a project. - Global Overview (
/domains): Located at/domains. Lists all domains across all projects with filters, search, and a project-selector creation dialog.
6. Important Decisions
- Dedicated Verification Token: Added
verificationToken String? @map("verification_token")directly toDomainmodel via Prisma migration20260902184554_add_domain_verification_tokento guarantee cryptographic unicity and fast indexing. - Traefik Dynamic File Provider: Utilized Traefik's dynamic file provider (
docker/traefik/dynamic/) rather than editing container labels. This enables zero-downtime instant routing attachment and detachment without restarting or redeploying the application container. - No External DNS Dependencies: Implemented DNS resolution using Node's built-in
dnsmodule and crypto generation usingnode:crypto. - Query public resolvers directly, not the server's own (added after live-server testing on
panel.mindgera.site): originally used the defaultdns.promises.resolveTxt, which trusts whatever resolver the host has configured. A cloud provider's local resolver can negative-cache an early failed lookup (e.g. a "Verify" click before DNS propagated) for a long time — observed up to an hour on AWS — even after the record is genuinely live everywhere else. Switched to explicitly querying Cloudflare/Google/Quad9, matching the pattern F3.3'sdns-service.ts checkPropagation()already used. - Surface the required A record, not just TXT (same live-testing pass): TXT verification proves ownership but was the only thing shown to the admin — a verified domain with no A record just renders nothing, with no indication why.
verificationInstructionsnow always includes aroutingRecord(A record host + the server's public IP from F5.9), and the dashboard's DNS Instructions modal shows it alongside the TXT record.