Adding custom domains to a Node.js / Express app is three pieces: route by the Host header, get the real client IP right behind a proxy, and terminate TLS with a certificate per hostname. The routing is a few lines of Express; the TLS is the part you should not hand-roll. Here is the DIY path and the shortcut.
// tenant.js — resolve the customer domain to a tenant, fail closed
export async function tenantByHost(req, res, next) {
const host = req.hostname; // requires app.set('trust proxy', true)
if (host === 'yourapp.com' || host.endsWith('.yourapp.com')) return next();
const tenant = await getTenantByDomain(host);
if (!tenant) return res.status(404).send('Unknown domain');
req.tenant = tenant;
next();
}Step 1 — Point DNS at your app
The customer aims DNS at infrastructure you control. Prefer a CNAME for subdomains so you can renumber later without touching their zone:
app.customer.com. CNAME proxy.yourapp.com.Bare domains run into the CNAME-at-apex problem. Confirm resolution with the CNAME checker.
Step 2 — Route by Host
Register the middleware and every downstream route can rely on req.tenant:
import express from 'express';
const app = express();
app.set('trust proxy', true); // see step 3 — do this before reading req.hostname/req.ip
app.use(tenantByHost);
app.get('/', (req, res) => {
res.send(`Hello from ${req.tenant.name}`);
});Step 3 — The trust proxy gotcha
This is the one that bites everyone. The moment Express runs behind a TLS-terminating proxy, req.ip and req.hostname reflect the proxy, not the visitor — unless you opt in:
app.set('trust proxy', true);With it on, Express reads X-Forwarded-For / X-Forwarded-Host. But only enable it when you genuinely sit behind a proxy you control — otherwise a client can forge X-Forwarded-For and defeat your rate limiter. Scope it tighter with a subnet or hop count in production (for example app.set('trust proxy', 1) to trust exactly one hop).
Step 4 — TLS: use a proxy, not your Node process
Do not run an ACME client inside your app to mint certs per customer domain. Put a reverse proxy in front:
- Caddy with on-demand TLS issues a certificate the first time an allowed hostname is seen. You must give it an allow-list endpoint so it does not issue for arbitrary hosts. Trade-offs in cnames.dev vs self-hosting Caddy on-demand TLS.
- Nginx works but needs a companion ACME workflow and reload-on-new-cert plumbing.
- A custom-domains service handles issuance, renewal, and forwarding, and hands your Express app clean requests.
The one-API-call way
Front your app with cnames.dev and register each domain:
curl -X POST https://api.cnames.dev/v1/domains \
-H "Authorization: Bearer sk_live_..." \
-d '{"hostname":"app.customer.com","origin":"your-express-app.internal:3000"}'SSL, renewals, and the real client IP header are handled at the edge; your Express app keeps its trust proxy setup and reads req.tenant as before. The operational reality of doing this at scale is in how we run SSL for 5,000+ domains, and the full picture in the complete guide.