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.
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.comOnly 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 statusfirewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --remove-port=3000/tcp
firewall-cmd --reloadThe 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
-
Install certbot — from the distribution packages.
apt install certbot python3-certbot-nginx -
Prove the vhost is valid — a config error here reads as a certbot failure later.
nginx -t && systemctl reload nginx -
Request the certificate — both names in one command, so one certificate covers both.
certbot --nginx -d example.com -d www.example.com--nginxedits the vhost for you: it adds thelisten 443 sslblock, thessl_certificatelines and an HTTP-to-HTTPS redirect. On a hand-written vhost with the proxy blocks already in place, prefercertonlyso certbot writes nothing:certbot certonly --webroot -w /var/www/html -d example.com -d www.example.comWith
certonlyyou add thelisten 443 ssl;,ssl_certificateandssl_certificate_keylines yourself, pointing at/etc/letsencrypt/live/example.com/fullchain.pemandprivkey.pem. -
Check TLS is really serving the app —
/api/settingsis 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 nginxchmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl list-timers | grep certbot
certbot renew --dry-runRun 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.
next/image host allowlist.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 startpnpm 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.
-
Add the DNS records for the new name and let them resolve, keeping the old ones live for now.
-
Issue a certificate for the new name with the ACME location already in place on the new vhost.
-
Update
.env—NEXT_PUBLIC_SITE_URL,APP_PUBLIC_URL,FRONTEND_URLandNEXT_PUBLIC_WS_URLtogether. Leaving one behind is what produces the half-broken states above. -
Rebuild and restart — nothing in the browser changes until this runs.
pnpm stop pnpm build:frontend pnpm start -
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. -
Update the authorised origins for Google sign-in in the Google Cloud console if
NEXT_PUBLIC_GOOGLE_CLIENT_IDis configured. Google rejects the sign-in from an origin it does not know, and the button simply does nothing. -
Keep the old domain redirecting for as long as e-mails, referral links and bookmarks with the old host are still in circulation.