Domains and SSL

Point DNS at the server, issue a Let's Encrypt certificate, keep it renewing, and make the platform's own URL settings agree with the domain people actually type.

10 min readUpdated 3 August 2026dns, ssl, certbot, nginx, domain

Everything the platform serves lives on one hostname. nginx terminates TLS and forwards page requests to the Next.js frontend on 127.0.0.1:3000 and everything under /api to the backend on 127.0.0.1:4000. There is no separate api. subdomain to create, and you should not invent one — the pages, the REST API and all 28 WebSocket endpoints are designed to share a single origin on port 443.

With NODE_ENV=production the login response sets accessToken and sessionId with Secure and SameSite=None. Browsers discard Secure cookies delivered over HTTP, and SameSite=None is invalid without Secure. The login request returns 200, the session is created server-side, and the very next request arrives anonymous — with nothing in the logs that looks like a failure. No setting relaxes this. The certificate is part of the install, not optional hardening.

DNS records

Point both the apex and www at the server. Add the AAAA records only if the box actually has a routable IPv6 address — a published AAAA that nothing listens on makes the site intermittently unreachable for IPv6 clients, and it makes certificate validation fail on the address that answers last.

Type Name Value Why
A @ server IPv4 the apex, example.com
A www server IPv4 the www host
AAAA @ server IPv6 only if the server has one
AAAA www server IPv6 only if the server has one

Wait for both names to resolve before asking for a certificate. Let's Encrypt validates each name from the public internet, and one certificate covering two names is one order — a typo in the www record fails the whole request, apex included.

dig +short example.com
dig +short www.example.com

Only publish hostnames you intend to serve the platform on. In production the backend's CORS allowlist is derived from a single setting, NEXT_PUBLIC_SITE_URL, expanded to the http/https and www/non-www variants of that one URL. An extra brand domain, a staging alias, or the raw IP address is not on the list, so a browser that lands there has every API call rejected: the page paints, the tables stay empty, and the server log shows nothing wrong.

Open 80 and 443, close 3000 and 4000

The installer's firewall step opens ssh, http, https and port 3000. Port 3000 does not need to be reachable from outside once nginx proxies it, and port 4000 must never be: in production the backend binds every interface, speaks plain HTTP, and has no TLS of its own. Anything that can reach 4000 bypasses your certificate entirely and reaches the API unencrypted.

ufw allow 80/tcp
ufw allow 443/tcp
ufw delete allow 3000
ufw status
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --remove-port=3000/tcp
firewall-cmd --reload

The cron process listens on 4001 purely so it does not collide with the backend. Nothing is meant to connect to it — never open it and never point a load balancer at it.

Let the ACME challenge through before you ask for a certificate

This is the single most common reason issuance fails on a working site. The vhost proxies location / to the frontend, so a catch-all block swallows the /.well-known/acme-challenge/ request and Let's Encrypt gets a Next.js 404 instead of the token.

Add this above location /, in the server block that listens on :80. The ^~ prefix is what makes it win over the catch-all.

location ^~ /.well-known/acme-challenge/ {
    default_type "text/plain";
    root /var/www/html;
    try_files $uri =404;
}

A server-level return 301 https://$host$request_uri; runs before any location block, so it rewrites the challenge request too and validation fails. Scope the redirect as location / { return 301 https://$host$request_uri; } instead, so the ACME block above can still win. Set root to the same document root the challenge files are written to — on a panel-managed box that is the site's real docroot, not /var/www/html.

Issue the certificate

  1. Install certbot — from the distribution packages.

    apt install certbot python3-certbot-nginx
  2. Prove the vhost is valid — a config error here reads as a certbot failure later.

    nginx -t && systemctl reload nginx
  3. Request the certificate — both names in one command, so one certificate covers both.

    certbot --nginx -d example.com -d www.example.com

    --nginx edits the vhost for you: it adds the listen 443 ssl block, the ssl_certificate lines and an HTTP-to-HTTPS redirect. On a hand-written vhost with the proxy blocks already in place, prefer certonly so certbot writes nothing:

    certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com

    With certonly you add the listen 443 ssl;, ssl_certificate and ssl_certificate_key lines yourself, pointing at /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem.

  4. Check TLS is really serving the app/api/settings is unauthenticated and is the same readiness probe the updater uses, so a JSON body here proves the certificate, the proxy and the backend are all working together.

    curl -sI https://example.com | head -1
    curl -sS https://example.com/api/settings | head -c 120

Renewal

Let's Encrypt certificates last 90 days and certbot's timer starts trying at 30 days remaining. Two things quietly break renewals months after install.

The ACME location has to stay. Renewal is another HTTP-01 challenge on port 80. A vhost tidy-up that deletes the /.well-known/acme-challenge/ block, or a later edit that moves it below location /, works fine until the certificate expires and the site goes dark on a weekend.

nginx has to be told. A renewed certificate on disk is not a renewed certificate in memory; nginx keeps serving the old one until it reloads. Certbot's nginx installer adds a reload for you, but certonly does not. Drop a deploy hook so it happens either way:

#!/bin/sh
systemctl reload nginx
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl list-timers | grep certbot
certbot renew --dry-run

Run the dry run once now and once after any vhost change. It exercises the real challenge path without spending a rate limit.

API responses carry Strict-Transport-Security: max-age=31536000; includeSubDomains; preload. Once a browser has seen that over HTTPS it refuses plain HTTP to the domain and every subdomain for a year, and clearing it is a per-visitor action you cannot perform. So: do not go live on HTTPS while a subdomain you also serve is still HTTP-only, and treat "we will move back to HTTP for a bit" as impossible for anyone who has already visited.

Tell the application its own address

All of these live in the root .env — the frontend reads ../.env first, so there is one file, not two.

The canonical public origin. Feeds the CORS allowlist, every link in outbound e-mail, affiliate referral links, and the next/image host allowlist.
Base URL that payment gateways build their return and webhook URLs from. Declared empty in .env.example.
Older base URL, still used by PayU, Authorize.Net and NFT metadata. Not present in .env.example — add the line yourself.
Must be "true" behind nginx, or the backend reads every visitor as the proxy's own address. Not present in .env.example.
Only needed if you sell binary options. Without it the binary order socket connects to port 4000 by hostname, which is neither proxied nor open.
NEXT_PUBLIC_SITE_URL="https://example.com"
APP_PUBLIC_URL="https://example.com"
FRONTEND_URL="https://example.com"
NEXT_PUBLIC_WS_URL="wss://example.com"

No trailing slash on any of them. The gateway helper strips one, but the mail templates concatenate the value raw — https://example.com/ produces https://example.com//img/logo/logo-text.webp and a broken logo in every e-mail you send.

TRUST_PROXY is not in that list, deliberately. nginx on this machine talks to the backend over loopback, and a forwarding header from a loopback connection is honoured with no configuration. What still matters is that nginx sends one — without it every visitor collapses into one rate-limit bucket and the geo-restriction engine evaluates the whole world as 127.0.0.1.

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;

This page previously showed $proxy_add_x_forwarded_for on that line, which appends to whatever the client sent rather than replacing it, producing <attacker's choice>, <real client> at the backend. The backend reads that list from the right, so the forgery is ignored — but $remote_addr removes the ambiguity at the source and costs nothing.

$remote_addr overwrites the header with the address nginx actually accepted the connection from, which is the only value a visitor cannot forge. nginx configuration has always shown it this way; the two pages disagreed, and an operator following the SSL step landed on the vulnerable combination while being told it was correct.

Editing .env and restarting does nothing for NEXT_PUBLIC_SITE_URL or NEXT_PUBLIC_WS_URL. Both are inlined into the browser bundle when the frontend is built, and the hostname is written into the next/image allowlist at the same moment. Until you rebuild, browsers keep calling the old origin and next/image refuses every image served from the new one.

pnpm stop
pnpm build:frontend
pnpm start

pnpm stop raises the maintenance page and proves ports 3000 and 4000 are free before the build starts, so this is the safe order rather than building in place.

What breaks when the app URL and the real domain disagree

The site is on https://example.com while NEXT_PUBLIC_SITE_URL still says http://…. The bundle builds its API calls from that value, so an HTTPS page issues HTTP requests and the browser blocks them as mixed content before they leave. The console shows mixed-content errors; the server sees no traffic at all.

The production CORS allowlist is only the http/https and www/non-www variants of NEXT_PUBLIC_SITE_URL. Serve the same install on a second brand domain, an old domain you kept alive, or the bare IP, and those visitors get no Access-Control-Allow-Origin header — every request appears in the network tab and every response is discarded.

next/image optimises only allow-listed hosts. The list is a fixed set of third-party hosts (the IPFS gateways, Google and GitHub avatar hosts) plus localhost and the hostname parsed out of NEXT_PUBLIC_SITE_URL at build time. Your own domain is only on it by way of that last entry, so moving domain without pnpm build:frontend fails every uploaded avatar, logo and product image.

Mail templates build absolute links from NEXT_PUBLIC_SITE_URL, falling back to the literal https://yoursite.com when it is unset. Affiliate referral links are generated from the same value, so a stale setting means every link your users share sends their referrals to a domain you no longer serve — and those signups are gone, not delayed.

PayU and Authorize.Net interpolate FRONTEND_URL with no fallback. Unset, the customer is redirected to undefined/finance/deposit?status=success&ref=… after paying. The remaining gateways use APP_PUBLIC_URL, which falls back to http://localhost:3000 — same outcome from the customer's browser, and the deposit sits unconfirmed.

The webhook URL shown on each gateway's setup screen under Finance → Payment Systems → Gateways is built from APP_PUBLIC_URL. Several providers — Mollie, Paystack, PayFast, Paytm — are also handed that URL with every payment they create, so it is used live and not just at setup. Leave it pointing at the old host and the provider posts confirmations into the void: the customer has paid, the platform never learns of it, and nothing on the admin side reports the gap.

Market, ticker and order sockets connect to wss:// on whatever host is in the address bar, so they follow the domain automatically. The binary-options order socket does not: it hardcodes port 4000 unless NEXT_PUBLIC_WS_URL is set. Port 4000 is not behind the certificate and should not be open, so the connection fails, and the browser client gives up permanently after five retries — the tab must be reloaded to try again.

Moving an existing install to a new domain

The order matters, because the browser bundle and the vendor-side configuration are the two things that do not follow automatically.

  1. Add the DNS records for the new name and let them resolve, keeping the old ones live for now.

  2. Issue a certificate for the new name with the ACME location already in place on the new vhost.

  3. Update .envNEXT_PUBLIC_SITE_URL, APP_PUBLIC_URL, FRONTEND_URL and NEXT_PUBLIC_WS_URL together. Leaving one behind is what produces the half-broken states above.

  4. Rebuild and restart — nothing in the browser changes until this runs.

    pnpm stop
    pnpm build:frontend
    pnpm start
  5. Re-paste every webhook URL into each payment provider's dashboard. Open the gateway's page under Finance → Payment Systems → Gateways and copy the URL shown there — it is now built from the new APP_PUBLIC_URL. Providers keep posting to whatever you told them last.

  6. Update the authorised origins for Google sign-in in the Google Cloud console if NEXT_PUBLIC_GOOGLE_CLIENT_ID is configured. Google rejects the sign-in from an origin it does not know, and the button simply does nothing.

  7. Keep the old domain redirecting for as long as e-mails, referral links and bookmarks with the old host are still in circulation.