AUTH_COOKIE_SECURE must be true on any instance reachable over TLS, so the session cookie is never sent over plain HTTP.
Put asobeast behind TLS
asobeast does not terminate TLS itself. The shipped path is a Cloudflare Tunnel, which terminates TLS at Cloudflare’s edge and reaches the stack over an outbound connection. That choice is about the host, not about certificates. A tunnel needs no inbound port at all, so a machine behind CGNAT or an ISP that blocks port 80 works unchanged, there is no certificate to obtain or renew on the host, and nothing on the machine is reachable from the internet except through the tunnel. Create the tunnel in the Cloudflare dashboard, point its public hostname athttp://web:3000, and put its token in the root .env beside the domain.
cloudflared to the stack, stops publishing host port 3001 so nothing is left listening beside the tunnel, and sets the three settings that depend on the hop: TRUST_PROXY=1 for the web app, AUTH_COOKIE_SECURE=true, and WEB_PUBLIC_URL=https://$ASOBEAST_DOMAIN. The overlay pins all three literally, so a value you set in the root .env cannot leave the web app counting no hop while the tunnel is in front of it.
cloudflared is pinned to a tested version, so two hosts built on different days run the same ingress binary and a rollback goes back to something known. Cloudflare does deprecate old builds, and the container runs with --no-autoupdate, so the pin is bumped by hand in docker-compose.tunnel.yml the same way MINT_VERSION and VALE_VERSION are. Treat a new cloudflared release as maintenance rather than something the deployment picks up on its own.
CI composes the same overlay with the root .env value for TRUST_PROXY set to 0, and asserts that the resolved configuration publishes no port at all, that the web app still counts the hop, that the other two settings arrive with it, that cloudflared carries the same memory and log ceilings as every other service, that no service image floats on a tag like latest, and that the origin still answers where cloudflared reaches it. What CI cannot do is stand up a real tunnel, so verify the hop count on your own deployment once. See Verify the hop count.
Run cloudflared on the host
The overlay is a convenience, not a requirement. Whencloudflared already runs as a service on the machine, as a Windows service or a systemd unit, keep the base stack and let the host binary reach it over the published port.
Point the tunnel’s public hostname at http://localhost:3001 rather than at http://web:3000, because the host binary is outside the Compose network and reaches the web app the same way any other local client does.
The overlay is what normally pins the three settings that depend on the hop, so set them yourself. Two live in the root .env, which Compose passes to the web service:
WEB_PUBLIC_URL belongs in apps/api/.env, which the API service reads as an env file:
TRUST_PROXY is still 1 here. The chain is the same as with the overlay: Cloudflare appends the address it observed and cloudflared adds no entry, so exactly one hop you control sits in front of the web app. The web app warns at startup when it runs in production counting no hop, which is the check the overlay used to make unnecessary.
Host port 3001 is published on this path, so bind it to the loopback or firewall it. Nothing else about the stack changes.
Bring your own proxy
A tunnel is not the only way. Put any reverse proxy in front of host port 3001 instead, and set the same three variables yourself. The proxy must forward theHost header and the request path unchanged, because the web app serves the admin surfaces at identical paths.
Set TRUST_PROXY correctly
TRUST_PROXY is a hop count, and both apps read their own. The value is the number of proxies you control between the internet and that app. true still means 1 and false still means 0, so an existing setting keeps working.
The API reads that many entries back from the right of X-Forwarded-For. Every proxy you control appends after the entries a client wrote, so the entry that many hops from the right is the first one the client could not have written.
Set the web value in the root
.env, which Compose passes to the web service. You do not set the API value on this stack, because the topology decides it.
0 is safe but blunt: the API sees the web container as the client, so every browser client shares one bucket. That is fine for a single operator instance and wrong for anything with real users. Both apps say so at startup, warning once when they run in production counting no hop.
Verify the hop count
The hop count is the one setting that is wrong silently. Too high and any caller spoofsX-Forwarded-For to spread login attempts across fabricated addresses; too low and every browser shares one bucket. Nothing in the stack can tell the difference on its own, because both look like a working site.
Verify it once, on the real deployment, with two devices on different networks. A phone on mobile data and a laptop on the office connection are enough.
1
Spend the login allowance from one device
Present a wrong password to the sign in form until it is refused with a rate limit message.
2
Try the second device immediately
From the other network, present a wrong password once.
X-Forwarded-For on every request.
429. If every one is 401, the count is too high and the forged header is buying an unlimited supply of addresses. Lower it until they are not.
What the web app forwards
The web proxy never passes a caller suppliedX-Forwarded-For or X-Real-IP through to the API. A caller can write either header, and Next.js keeps an incoming X-Forwarded-For rather than replacing it, so the proxy cannot tell a forged value from an observed one.
Instead it sends exactly one address, the one its own TRUST_PROXY says it can trust. At 0 it sends none, and the API falls back to the connection. See Web app configuration.
Set WEB_PUBLIC_URL
WEB_PUBLIC_URL builds absolute deep links inside alert payloads, and it is also the single origin the API allows through CORS. Set it to the origin a person would type. Only http and https are accepted, and the API refuses to boot on anything else, because a scheme the browser gives no origin to would otherwise become the allowed origin.
Unset, the link field is null rather than a localhost guess that would not open for anyone, and the API sends no CORS headers at all. See Send alerts.
Sending nothing is the right default here. The browser only ever talks to the web origin, and a same origin request needs no CORS headers, so the shipped topology is unaffected either way. What it does break is a custom frontend on another origin calling the API directly. That breaks loudly, in the browser console, with a message naming the cause, which is a better outcome than the wildcard the API used to send to every origin on earth.
What does Compose expose?
The base stack publishes only host port 3001, mapped to port 3000 in the web container. The API, PostgreSQL and Redis stay on Docker networks, and the backend network is markedinternal.
Composing docker-compose.tunnel.yml removes even that, so the machine publishes nothing at all and cloudflared reaches the web container over the frontend network. A reverse proxy on the host needs port 3001; a tunnel does not. See Self host with Docker Compose.
Checklist before going public
1
Compose the tunnel overlay, or terminate TLS yourself
Confirm the site loads over HTTPS with a valid certificate, and that nothing answers on host port 3001.
2
Set AUTH_COOKIE_SECURE=true
The tunnel overlay sets it. Set it yourself if you brought your own proxy, then restart the API and sign in again.
3
Verify TRUST_PROXY against the real chain
Reading it is not verifying it. Run the two device check in Verify the hop count.
4
Generate AUTH_SECRET properly
openssl rand -hex 32. Never a memorable string, never reused from development.5
Leave registration closed
AUTH_ALLOW_REGISTRATION=false once the owner account exists. If you do open it, leave AUTH_REGISTRATION_WORKSPACE=own so a stranger who signs up lands in an empty workspace of their own. See Registration.6
Schedule and test backups
A backup that has never been restored is not a backup. See Back up PostgreSQL.
What the API refuses to start with
Some settings are individually valid and jointly unsafe. The API checks those combinations at startup and stops rather than logging a warning that nobody reads, because both failures below are silent and expensive.
The Docker image sets
NODE_ENV=production, so the first check is live on every self hosted deployment. The Compose stack therefore defaults AUTH_COOKIE_SECURE to true. Browsers treat http://localhost as a secure context, so evaluating locally over plain HTTP still works.
The error names the variable, the required value and the reason. Fix the variable and start again.
What the API warns about
These cannot be decided from configuration alone, so they log a warning at startup and let you proceed. The mail warning is the one to read twice. Every individual mail guard is satisfied when SMTP is absent, because each of them is conditioned on SMTP being present, so a metered instance with no relay starts perfectly clean and cannot email a paying customer. It warns rather than refuses because bringing a hosted instance up before the relay credentials exist, with registration closed, is a legitimate step. Half a configuration warns as loudly as none, becauseSMTP_HOST without SMTP_FROM is the likelier mistake.
Related
Authentication and accounts
Sessions, registration and the guards.
The queue dashboard and the OpenAPI surface
Why those routes answer 404.