Skip to main content

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.

tip

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​

EnvironmentBranchFrontendNakama APINakama console
stagingdevdev.humuus.denakama.dev.humuus.deconsole.dev.humuus.de
productionmainhumuus.denakama.humuus.deconsole.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​

  1. New resource in the Coolify project, source techagogics/humuus, build pack Docker Compose, compose file /docker-compose.yaml.
  2. Pick the branch (dev for staging, main for production).
  3. Leave custom start and build commands empty. Anything other than a plain docker compose up bypasses the Traefik labels Coolify generates, which is how the old setup ended up needing hand-written routers.
  4. Give every service exactly one domain — see the warning below.
  5. 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).

Never give a service two domains

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 sides

Nakama'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.

One media URL, two consumers

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 redeploy

apps/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.

Do not set NEXT_PUBLIC_NAKAMA_SERVER_HOST

Coolify 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):

EmailPassword
alice@example.comAliceTestPass123!
bob@example.comBobTestPass123!

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:

  1. It renames the compose services, appending the PR number: postgres becomes postgres-pr-444. Hostnames hardcoded in an entrypoint stop resolving.
  2. It puts the containers on a network of their own, separate from staging.
  3. It sets SERVICE_FQDN_* and SERVICE_URL_* to preview values for that deployment.
The compose file always comes from the application's branch

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.

Never add networks: default: to a service

It 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.

Put scripts in 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​

  1. Push to dev.
  2. In Coolify, Reload Compose File — the diff view then lists exactly what will change.
  3. Deploy.
  4. 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.

FileHostsStatus
coolify.yamlcoolify.humuus.degenerated — never edit
default_redirect_503.yamlcatch-all, priority -1000generated — every "no router matched" 503 comes from here
middlewares.yaml—shared middlewares, see below
nakamadev2.yamlnkdev2.humuus.dethe Data Decorator's Nakama — dd.humuus.de breaks without it
nkpreview.yamlpr-preview-*.humuus.dethe 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.

Every one of these files is the only route its application has

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) has fqdn: null in Coolify. Nothing generates a router for it.
  • A custom start command. nkdev3's application runs docker compose --project-name nkdev3 up -d, and anything other than a plain docker compose up skips the labels Coolify would generate.
  • Two domains on one service. humuus-one-stack has pr-preview-nakama.humuus.de:7350,pr-preview-console.humuus.de:7351 on its nakama service. Coolify does emit a router, but #7915 makes the rule unmatchable — so the file is what actually serves it. Deleting nkpreview.yaml took 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.

The middlewares live in one file and are shared by all of them

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)]"
These routers address containers by fixed IP, and Docker recycles IPs

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=443 in the iframe URL. The client ignores it — the strings port and scheme in the build belong to the Nakama SDK's Client class, not to its query-string parsing.
  • Traefik labels on the nakama service. 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 config interpolates it locally, so this only shows on the server — check docker inspect … .Config.Labels, not the local render.
  • Addressing the backend as nakama from 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.

Leave "Delete Unused Volumes" off

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 through LLM_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-console service 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.