Custom Domains (DNS)

Host sites on subdomains and custom domains with DNS verification and TLS certificate management. Users create sites with subdomains, add custom hostnames, verify domain ownership via TXT records, and get automatic TLS certificates through Fly.io.

v1.8.3
Level: Advanced
27
2205
1

Caddyfile

The Caddyfile configures Caddy as a local reverse proxy for testing. It routes three types of requests to the Phoenix server: the main app domain ( my-app.test ), wildcard subdomains ( *.my-app.test ), and a test custom domain ( my-hostname.test ). All use plain HTTP since Caddy handles TLS termination in production but not needed locally.

Dev configuration

Development uses my-app.test as the endpoint host (instead of localhost ) so the ServeHostedSite plug can distinguish between the main app and subdomain/hostname requests. The DNS resolver and certificate adapter are set to their local variants that auto-verify and auto-succeed.

Production configuration

Production configures the Fly.io certificate adapter with the app ID and API token, the live DNS resolver, and a CNAME target derived from the Fly app name. check_origin: :conn is set on the endpoint to allow WebSocket connections from any origin, since hosted sites will connect from their custom domains.

Hosting context

The Hosting context manages sites and hostnames with scoped access (users can only manage their own resources) and PubSub broadcasting for real-time updates.

Sites — Standard CRUD operations scoped to the current user. get_site_by_subdomain/1 and get_site_by_hostname/1 are unscoped lookups used by the routing plug to resolve incoming requests to sites.

Hostnames — CRUD operations scoped to a site. Creating a hostname auto-generates the TXT verification secret. Deleting a hostname also cleans up any associated certificate with the certificate provider.

DNS verification verify_hostname/2 delegates to the DNS resolver to check for the expected TXT record. On success, it timestamps verified_at on the hostname. verification_instructions/1 and cname_instructions/1 return the DNS records the user needs to configure.

Certificate management issue_certificate/2 requires a verified domain and delegates to the certificate adapter. It tracks both certificate_requested_at (immediately) and certificate_issued_at (when the adapter confirms issuance, which may be async). refresh_certificate_status/2 polls the adapter for pending certificates. delete_certificate/2 removes the certificate and clears the timestamp fields.

Certificates module

The Certificates module provides a behaviour-based interface for TLS certificate management. It defines three callbacks: add_certificate/2 , check_certificate/2 , and delete_certificate/2 , each taking a hostname string and adapter-specific options.

The public API functions ( issue_certificate/1 , check_certificate/1 , delete_certificate/1 ) accept a Hostname struct, extract the domain, and delegate to the configured adapter with its options. This keeps the hosting context decoupled from any specific certificate provider.

Fly.io certificate adapter

The production adapter uses Fly.io's GraphQL API to manage TLS certificates. Fly automatically provisions Let's Encrypt certificates for custom domains added to an app.

add_certificate/2 calls the addCertificate mutation to register a domain. check_certificate/2 queries the certificate status including issued certificate details (type, expiration). delete_certificate/2 removes the certificate.

The adapter parses Fly's response into a normalized certificate_result map with status ( :pending or :issued ), validation details, and expiration. Certificate status is determined by whether any issued certificate nodes exist in the response.

Local certificate adapter

The development adapter logs certificate operations and immediately returns success. Since local development uses Caddy (which handles its own TLS or runs over HTTP), there's no real certificate to provision. All operations return :issued status instantly so the full flow can be tested locally.

DNS verification module

The DNS module handles domain ownership verification through TXT records. Users prove they own a domain by adding a TXT record at _myapp-verify.<domain> containing myapp-verification=<secret> .

The module defines a @callback lookup_txt/1 behaviour so the resolver is swappable between environments. verify_domain/1 looks up TXT records at the verification hostname and checks if any match the expected value.

Helper functions verification_host/0 , verification_hostname/1 , and verification_record_value/1 build the DNS record details shown to users. cname_target/0 returns the hostname users should CNAME their domain to (configurable, defaults to proxy.fly.dev ).

Live DNS resolver

The production resolver uses Erlang's :inet_res module for real DNS lookups. It handles the quirks of TXT records: they come back as lists of charlists (since TXT records can be split into 255-byte chunks) that need to be joined and converted to strings. An empty result from :inet_res.lookup/4 is ambiguous (could be no records or NXDOMAIN), so it falls back to :inet_res.resolve/4 to distinguish between the two.

Local DNS resolver

The development resolver auto-verifies all domains by looking up the hostname's txt_record_secret directly from the database and returning it as a TXT record. This means clicking "Verify" in development always succeeds without needing actual DNS configuration. It parses the _myapp-verify. prefix from the lookup hostname to find the actual domain, then queries the Hostname record.

Hostname schema

The Hostname schema stores custom domains pointed at a site. Each hostname has a domain , a txt_record_secret for DNS ownership verification, and timestamp fields tracking the verification and certificate lifecycle: verified_at , certificate_requested_at , and certificate_issued_at .

When a hostname is first created, maybe_generate_secret/1 generates a cryptographically random 32-byte token (base64-encoded) that becomes the TXT record value the user must set. Domain validation uses a regex that enforces valid DNS domain format and a unique constraint ensures no two hostnames share the same domain globally.

Separate changesets handle different stages of the lifecycle: verification_changeset/2 sets the verified timestamp, and certificate_changeset/2 updates certificate fields.

Site schema

The Site schema represents a hosted site belonging to a user. Each site has a name and a unique subdomain used for subdomain-based routing (e.g., my-site.my-app.test ).

The changeset/4 function validates that subdomains are lowercase alphanumeric with hyphens, enforces a max length of 63 characters, and ensures uniqueness. A cast_default_subdomain option auto-generates a subdomain from the site name by lowercasing and replacing non-alphanumeric characters with hyphens, providing a nice UX when creating sites.

Endpoint configuration

The ServeHostedSite plug is added to the endpoint just before MyAppWeb.Router . This placement is important: it needs access to the session (for CSRF in the hosted router) but must intercept requests before the main router handles them.

Hostname LiveView - Form

A simple form for adding custom domains to a site. After creating a hostname, it redirects to the hostname show page where the user can see the DNS verification instructions.

Hostname LiveView - Show

The hostname detail page shows the domain, current status (Pending verification / Verified / Certificate pending / Active), and DNS configuration instructions. The page guides users through the setup process:

Once the certificate is issued, the DNS configuration section hides and the status shows "Active".

Sites LiveView - Form

Handles both creating and editing sites with name and subdomain fields. Uses the standard Phoenix LiveView form pattern with validation on change and save on submit.

Sites LiveView - Index

A standard list page showing all sites for the current user in a table with name and subdomain columns. Supports creating, editing, and deleting sites with real-time updates via PubSub subscriptions.

Sites LiveView - Show

The site detail page displays the site info and a table of associated hostnames with their verification status. Users can add new hostnames, view hostname details, and delete hostnames. Subscribes to both site and hostname PubSub topics for real-time updates.

ServeHostedSite plug

This plug sits in the endpoint pipeline before the main router and intercepts requests destined for hosted sites. It compares the request host against the endpoint's configured host to determine routing:

When a site is found, the plug assigns current_site to the conn and dispatches to the HostedRouter . If no site matches, it returns a 404. The plug calls halt() after dispatching to the hosted router, preventing the request from continuing to the main app router.

Router

Site and hostname routes are added under the authenticated scope. Sites get full CRUD routes, while hostnames are nested under sites with routes for creating and viewing.

Hosted router and controller

The HostedRouter is a separate router for hosted site requests. It has its own browser pipeline (session, CSRF, etc.) and a catch-all route that sends all paths to HostedController.show/2 . The controller simply renders a success page with the site name — in a real app, you'd replace this with your hosted site rendering logic.

Migrations

Two migrations create the sites and hostnames tables. Sites have a unique index on subdomain and a foreign key to users . Hostnames have a unique index on domain (globally unique across all sites) and a foreign key to sites with cascade delete.