Deployment
humuus runs on a single Hetzner server managed by Coolify,
with Traefik as the reverse proxy. Every environment is one Coolify application
built from the same docker-compose.yaml.
For day-to-day work you rarely need this page. It matters when you add an environment, change domains, or a preview deployment misbehaves.
Topology
| Environment | Branch | Frontend | Nakama API | Nakama console |
|---|---|---|---|---|
| staging | dev | dev.humuus.de | nakama.dev.humuus.de | console.dev.humuus.de |
| production | main | humuus.de | nakama.humuus.de | console.humuus.de |
Pull requests against dev get an automatic preview at <pr>.dev.humuus.de,
with its own database, its own Nakama and its own console. Coolify destroys the
whole stack when the PR closes.
The stack is defined once, in docker-compose.yaml:
web Next.js frontend :3000
nakama Game server, API :7350
nakama-console socat forwarder to Nakama's console :8080 → nakama:7351
postgres Nakama's database :5432
docs This documentation site :3001
All ports use expose:, never ports: — Traefik reaches the containers over the
Coolify network, nothing is published on the host.
Adding an environment
- New resource in the Coolify project, source
techagogics/humuus, build pack Docker Compose, compose file/docker-compose.yaml. - Pick the branch (
devfor staging,mainfor production). - Leave custom start and build commands empty. Anything other than a plain
docker compose upbypasses the Traefik labels Coolify generates, which is how the old setup ended up needing hand-written routers. - Give every service exactly one domain — see the warning below.
- Set the environment variables, then deploy.
Domains
One hostname per compose service, and the port belongs in Coolify's field, not in the URL:
web https://dev.humuus.de
nakama https://nakama.dev.humuus.de:7350
nakama-console https://console.dev.humuus.de:8080
docs https://docs.dev.humuus.de
The :7350 tells Traefik which container port to forward to. From the outside
only 443 is reachable (plus a custom nakama entrypoint on 7350 that predates
this setup and is only used by the old production stack).
Coolify prefixes the whole comma-separated domain string when it builds preview hostnames, instead of each entry on its own. The result is a syntactically valid but unmatchable router:
Host(`442.dev.humuus.de,https`) && PathPrefix(`//442.www.dev.humuus.de`)
Every hostname of that preview then answers 404 with Traefik's default
certificate. Traefik logs nothing — it counts the router, it just never matches.
Tracked as coollabsio/coolify#7915, fix pending in #11181.
This is why the Nakama console is a separate nakama-console service rather than
a second domain on nakama, and why Coolify's auto-added www. variants have to
be deleted after typing a domain.
Environment variables
NEXT_PUBLIC_RESOURCE_LOCATION Base URL for all workshop media, trailing slash
POSTGRES_PASSWORD Only applied when the volume is first created
NEXT_LLM_SHARED_SECRET Shared secret for the LLM proxy routes
NEXT_LLM_URL_GENERATE where Nakama calls back into the frontend
NEXT_LLM_URL_CLUSTER
OPENAI_API_KEY
N8N_DOMAIN
CREATE_TEST_USERS "true" seeds the two host accounts on boot
Nakama's own secrets are passed as CLI flags from the compose entrypoint and each needs a value — leave one empty and Nakama falls back to a well-known default and warns at startup:
NAKAMA_HTTP_KEY
NAKAMA_CONSOLE_USERNAME
NAKAMA_CONSOLE_PASSWORD
NAKAMA_CONSOLE_SIGNING_KEY
NAKAMA_SESSION_ENCRYPTION_KEY
NAKAMA_SESSION_REFRESH_ENCRYPTION_KEY
Generate each separately with openssl rand -hex 32. Changing either session key
invalidates all existing logins.
NEXT_PUBLIC_NAKAMA_SERVER_KEY has to match on both sidesNakama's socket server key is the one value the server and the browser must agree
on. Nakama sends it as the HTTP Basic username on every client call, so it ends
up in the frontend bundle and is readable by anyone who opens the site. It is
therefore not a secret in the way the keys above are — but Heroic Labs still list
it among the values that must be changed before a production deployment, because
it defeats opportunistic scans that simply try defaultkey.
Set it once, as NEXT_PUBLIC_NAKAMA_SERVER_KEY. The compose files pass the
same value to Nakama as NAKAMA_SERVER_KEY, the same way MINIO_ADDRESS derives
from NEXT_PUBLIC_RESOURCE_LOCATION. Both fallbacks are defaultkey, so an
unset variable leaves the two sides agreeing rather than broken.
If they ever disagree, every login fails with 401 Unauthorized on
POST /v2/account/authenticate/device — measured on staging on 2026-08-19, when
the server got a generated key and the frontend still hard-coded defaultkey.
Because NEXT_PUBLIC_* is compiled into the bundle, changing it takes a
redeploy of web, not a restart, and a browser holding the previous bundle
keeps failing until it reloads.
The frontend loads images and video from NEXT_PUBLIC_RESOURCE_LOCATION; Nakama
prefixes the same base onto sound paths coming out of workshop JSON, but
expects it under the name MINIO_ADDRESS. The compose files derive the second
from the first, so set the value once — with a trailing slash, which the
frontend and next.config.mjs both require.
Leaving it empty does not raise an error anywhere: Nakama passes sound paths
through unchanged and the frontend falls back to /, so media quietly 404s.
nkconfig.yml changes need a rebuild, not a redeployapps/nakama/nkconfig.yml is copied into the Nakama image at build time.
Editing it and redeploying keeps the old file; the image has to be rebuilt.
Compose changes, by contrast, apply on a plain redeploy.
NEXT_PUBLIC_NAKAMA_SERVER_HOSTCoolify appends a bare --build-arg <NAME> for every variable it knows, and a
CLI build-arg overrides the value from docker-compose.yaml. Setting it would
point every preview at the same backend.
The compose file derives the value from SERVICE_FQDN_NAKAMA, which Coolify
maintains per deployment — including a separate value for each preview.
NAKAMA_DOMAIN remains only as a fallback for running docker compose up
outside Coolify.
The frontend bakes this value into its JavaScript bundle at build time, so changing it requires a redeploy, not a restart.
Testing a preview
A preview starts with an empty database. Nakama seeds two host-capable accounts
on boot (apps/nakama/src/createDefaultUsers.ts):
| Password | |
|---|---|
alice@example.com | AliceTestPass123! |
bob@example.com | BobTestPass123! |
Sign in as either, then open /StoragePush to load the workshop definitions from
apps/web/workshops/ into Nakama storage. Only then can you start a workshop.
Every service is reachable under the PR number, including the Nakama console:
444.dev.humuus.de, 444.nakama.dev.humuus.de, 444.console.dev.humuus.de,
444.docs.dev.humuus.de.
How previews work
Coolify does three things per preview, and each of them has bitten us:
- It renames the compose services, appending the PR number:
postgresbecomespostgres-pr-444. Hostnames hardcoded in an entrypoint stop resolving. - It puts the containers on a network of their own, separate from staging.
- It sets
SERVICE_FQDN_*andSERVICE_URL_*to preview values for that deployment.
A preview builds the application code from the pull request, but the stack
definition — docker-compose.yaml — is read from the branch the Coolify
application points at, which is dev.
Changes to docker-compose.yaml cannot be tested in a preview. They have to
land on dev first, where they take effect on staging immediately, and only the
next preview shows how they behave in a PR. Several correct fixes looked broken
for hours because they only existed on the PR branch.
Recovering the PR number
Because the services are renamed, nakama needs to address postgres-pr-444
and the console needs nakama-pr-444 — but Coolify exposes no
COOLIFY_PULL_REQUEST_ID, neither in the container environment nor on the
docker compose command line. The number is therefore derived from
SERVICE_FQDN_NAKAMA, which Coolify does set per deployment:
# 444.nakama.dev.humuus.de -> "444" | nakama.dev.humuus.de -> ""
PR=$(echo "$NAKAMA_FQDN" | sed -n 's/^\([0-9][0-9]*\)\..*/\1/p')
PGHOST=postgres; [ -n "$PR" ] && PGHOST="postgres-pr-$PR"
Both nakama and nakama-console use this in their entrypoints. On staging the
expression yields the plain names, so the same file works everywhere.
networks: default: to a serviceIt looks like the obvious way to pin a stable alias, and it is the opposite.
Coolify runs previews under the same compose project name as the parent
environment, so default resolves to one shared network: staging and every
preview land on it together. postgres then resolves to two containers at once
and Docker round-robins between them — a preview can end up writing into
staging's database. Measured on PR 443.
entrypoint:, not command:A command: written as a string is shell-split by compose and silently
truncated at the first ;. Use a block scalar inside entrypoint: instead.
After changing the compose file
- Push to
dev. - In Coolify, Reload Compose File — the diff view then lists exactly what will change.
- Deploy.
- Verify against the running container, not the symptom:
docker inspect <container> --format '{{json .Config.Entrypoint}}'
docker exec <nakama-container> sh -c 'getent hosts postgres postgres-pr-<nr>'
docker logs <console-container> 2>&1 | grep "console forwarding"
Step 4 is the one that matters. A deploy can finish successfully while still running an older definition, and every measurement taken from the outside will then describe the wrong thing.
Debugging
Which Nakama does the frontend talk to? The host is compiled into the bundle, so read it back from the deployed site:
curl -s https://dev.humuus.de/ -o page.html
for c in $(grep -oE '/_next/static/[^"]+\.js' page.html | sort -u); do
curl -s "https://dev.humuus.de$c"
done | grep -oE '\("[^"]*","[^"]*","[0-9]+"'
Does Traefik know the route? A 404 on plain HTTP means no router matched; a
TRAEFIK DEFAULT CERT on HTTPS means the same thing seen from the TLS side.
docker exec coolify-proxy wget -qO- http://127.0.0.1:8080/api/http/routers \
| python3 -c "
import json,sys
for r in json.load(sys.stdin):
print(r.get('status'), r.get('rule'), r.get('name'))
"
Is a container healthy? Traefik silently skips unhealthy containers — no log entry, the routers simply never appear.
docker ps --format '{{.Names}}\t{{.Status}}' | grep <service>
Can services reach each other? Coolify renames compose services in previews
(postgres → postgres-pr-442), which breaks hostnames hardcoded in entrypoints.
The entrypoints therefore rebuild the name from the PR number rather than pinning
a network alias — pinning one is what caused the cross-environment leak described
above. Resolve the name from inside the container that needs it:
docker exec <nakama-container> sh -c 'getent hosts postgres postgres-pr-<nr>'
Exactly one of the two should answer. If both do, the containers share a network they must not share.
Traefik dynamic configuration
/data/coolify/proxy/dynamic/ holds hand-written router files from the previous
setup next to three that Coolify manages itself. New environments need none of
the hand-written ones — Coolify generates the labels — but several are still
load-bearing.
| File | Hosts | Status |
|---|---|---|
coolify.yaml | coolify.humuus.de | generated — never edit |
default_redirect_503.yaml | catch-all, priority -1000 | generated — every "no router matched" 503 comes from here |
middlewares.yaml | — | shared middlewares, see below |
nakamadev2.yaml | nkdev2.humuus.de | the Data Decorator's Nakama — dd.humuus.de breaks without it |
nkpreview.yaml | pr-preview-*.humuus.de | the humuus-one-stack stack — its only working route, and where the n8n holding the live LLM workflows runs |
Caddyfile | — | managed by Coolify, cannot be deleted; Traefik ignores it |
nakamadev.yaml used to live here and is gone; its routers pointed at hosts that
no longer existed.
It is tempting to read a stale-looking hostname as a leftover. All of them are load-bearing, each for a different reason, and none of the reasons is visible from the filename:
- No domain at all.
nkdev2's Nakama (gggg80s) hasfqdn: nullin Coolify. Nothing generates a router for it. - A custom start command.
nkdev3's application runsdocker compose --project-name nkdev3 up -d, and anything other than a plaindocker compose upskips the labels Coolify would generate. - Two domains on one service.
humuus-one-stackhaspr-preview-nakama.humuus.de:7350,pr-preview-console.humuus.de:7351on itsnakamaservice. Coolify does emit a router, but #7915 makes the rule unmatchable — so the file is what actually serves it. Deletingnkpreview.yamltook that stack offline, and the Coolify UI showed a perfectly healthy application throughout.
Before deleting any of them, check the application it serves for all three conditions. A hostname that looks obsolete is not evidence.
Traefik's file provider merges every file in the directory into a single
configuration, so a middleware defined anywhere is referenced by name from
everywhere. default-headers and https-redirectscheme are defined once, in
middlewares.yaml, and used by nakamadev2, nakamadev3, nkcolin and
nkdennis.
They used to sit inside nakamadev.yaml, a file whose own routers had been dead
for months and which this page previously called safe to delete. Deleting it
would have taken four working routers with it — including the one production
depends on. middlewares.yaml exists for no other reason than to hold them.
When removing any of these files, create the replacement first, confirm it, and only then delete:
docker exec coolify-proxy wget -qO- http://127.0.0.1:8080/api/http/middlewares \
| python3 -c "import json,sys;[print(m['status'],m['name']) for m in json.load(sys.stdin)]"
Every hand-written file points at a container IP rather than a service name,
which is why they had to be edited after each restart. The worse consequence is
silent: nakamadev.yaml ended up routing nk.humuus.de at staging's Postgres
container, because 172.27.0.4 had been handed out again. Nothing was exposed
— Traefik speaks HTTP to port 7350, Postgres listens on 5432 — but a router
quietly pointing at an unrelated container is what this style produces over time.
Resolve what an IP actually is before trusting a file:
docker ps -a --format '{{.Names}}' | while read c; do
ip=$(docker inspect "$c" -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}')
case "$ip" in *172.27.0.4*) echo "$ip -> $c";; esac
done
If you ever need a file-based router again, address the compose service by name.
Why the Data Decorator stopped working
The node is out of service, and the reasons are worth keeping because they cost an afternoon and every fix attempted from this side failed. The full account lives with the node itself; in short, its Unity build compiles in Nakama's port and server key and reads neither from the iframe URL.
Three approaches were tried here and none of them work:
- Passing
&port=443in the iframe URL. The client ignores it — the stringsportandschemein the build belong to the Nakama SDK'sClientclass, not to its query-string parsing. - Traefik labels on the
nakamaservice. Coolify escapes${...}inside labels when it writes the final compose file, so the container ends up carrying the literal string and Traefik asks Let's Encrypt for a certificate for${NAKAMA_7350_HOST:-…}every few minutes, against rate limits the real domains share.docker compose configinterpolates it locally, so this only shows on the server — checkdocker inspect … .Config.Labels, not the local render. - Addressing the backend as
nakamafrom the proxy. That name resolves to whichever environment Docker picks: measured returning production's address while staging was wanted. The same class of leak that made us drop network aliases.
A generated Traefik router on port 7350 did work — a cron job rewriting a file-provider config from the running containers — but it was removed along with the node, because a workaround nobody needs is a workaround nobody maintains.
Automated Docker cleanup
Coolify runs its cleanup job daily at 00:00 UTC. force_docker_cleanup is on, so
it runs regardless of the 80% disk threshold. It executes roughly
(CleanupDocker.php):
docker container prune -f --filter label=coolify.managed=true \
--filter label!=coolify.proxy=true --filter label!=coolify.type=database \
--filter label!=coolify.type=application --filter label!=coolify.type=service
docker image prune # application and infrastructure images excluded
docker builder prune -af
The last line wipes the build cache every night, so the first build of any day is a cold one. Two server settings extend this job, and only one of them belongs on.
It adds docker volume prune -af. The -a covers named volumes, and unused
means "referenced by no container" — a Coolify resource that is merely stopped
has had its containers removed, so its volumes qualify.
Measured on 2026-08-19 with the option briefly on:
gixnx4rrh211uw2v7dfnl9oq_postgres-data and …_n8n-data — staging's database
and its n8n workflows, which exist nowhere in git — were both queued for
deletion, along with roughly 35 other volumes.
Prune by hand instead. This lists exactly what prune -a would remove:
docker volume ls -qf dangling=true
"Delete Unused Networks" is safe to enable. It runs docker network prune -f,
which only removes networks with no attached container, and Coolify recreates
them on the next deploy. It frees no disk worth mentioning, but every preview
consumes subnets from Docker's address pool; an exhausted pool fails deployments
with "could not find an available, non-overlapping IPv4 address pool".
"Application Image Retention" stays enabled. The image prune already spares application images, and retention is what makes a rollback to a previous image possible at all.
Known gaps
- n8n is deliberately not part of the stack. It runs as its own Coolify service
at
n8n.humuus.de, shared by every environment throughLLM_N8N_ENDPOINT, and its workflows live only in that instance's database — not versioned, not reviewable, not reproducible. The plan is to replace it with code rather than to version it. - The
nakama-consoleservice exists only because Coolify mangles Traefik rules when one service carries two domains. Once #11181 is merged, Nakama can hold both the API and the console domain and the socat forwarder can go. - Staging, production and all previews share one server, so a preview build competes with production for CPU.