Deploying Game Studio
Target: Anvi's Hetzner server running Coolify, with a Postgres resource. The app has no login, so the proxy must gate access before the URL is shared: icon generation and the Improve button call OpenAI with your keys, and anyone with the URL could run up the bill.
What the repo provides
Dockerfile: multi-stage build using Next.jsoutput: "standalone". The runtime image has only the traced server files,public/(including the icon style references inpublic/icon-styles/),.next/static/, thedrizzle/migrations anddocs/(for the Docs pages), and runs as a non-root user on port 3000. Uploads and generated icons go to/app/.data/assets(ASSETS_DIR), declared as a volume.src/instrumentation.ts: applies pending migrations when the server starts in production (MIGRATE_ON_STARTdefaults to1there). A deploy is therefore just "start the new image"; no separate migrate step.GET /api/health: returns{ ok: true }after aselect 1, or 503. The Dockerfile'sHEALTHCHECKuses it; point Coolify's health check at it too.
Environment variables
| Variable | Value |
|---|---|
DATABASE_URL |
The Postgres resource's internal URL from Coolify, e.g. postgres://studio:…@<postgres-service>:5432/game_studio |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
A stable base64 32-byte key, generated once: openssl rand -base64 32. Set it as a build-time variable as well as runtime; Next embeds it at build. Without it, every deploy invalidates in-flight Server Actions |
OPENAI_API_KEY |
Shared OpenAI key for icon generation and Improve. Runtime only, never build-time |
OPENAI_ICONS_API_KEY, OPENAI_TEXT_API_KEY |
Optional per-feature keys, so icon and text spend show separately in OpenAI's usage. Each falls back to OPENAI_API_KEY. Without any key, icons use the free placeholder generator and Improve shows a "no key" message |
OPENAI_IMAGE_MODEL, OPENAI_IMAGE_QUALITY |
Optional. Defaults gpt-image-2.6-flare, medium (about 5¢ per icon) |
OPENAI_TEXT_MODEL |
Optional. Default gpt-6.1-terra |
GENERATION_DAILY_CAP |
Optional. Generated images per day across everyone, default 50 |
ASSETS_DIR |
Set in the image to /app/.data/assets; only change it together with the volume |
MIGRATE_ON_START |
Optional. 0 to skip migrations at start |
Coolify steps
- Postgres: Project → New Resource → Database → PostgreSQL 17. Name it
game-studio-db, databasegame_studio, userstudio. Keep it internal (no public port). Copy the internal connection URL. - App: New Resource → Application → from the Git repository, build pack Dockerfile. Port 3000. Add the environment variables above (tick "available at build time" for
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYonly; keep the OpenAI keys runtime-only). - Storage: in the app's Persistent Storage, add a volume mounted at
/app/.data/assets. Without it, uploaded portraits and generated icons disappear on every deploy. - Health check: path
/api/health, port 3000. - Domain: set the FQDN; Coolify's Traefik issues the certificate.
- Deploy, then open
/api/healthand/classes. The first start creates the tables and the default resources (Mana, Energy, Rage, Focus, Souls, Combo Points). There are no classes yet: create one with New Class. - Optional: the example Plague Knight. It's added automatically only to the local PGlite database. To add it to production, run the seed from your machine against the database through an SSH tunnel (the image has no
tsx):ssh -L 5433:<postgres-service-ip>:5432 user@hetzner
Delete it from the Base tab if you don't want it.DATABASE_URL=postgres://studio:…@localhost:5433/game_studio npm run db:seed
Access protection (do this before sharing the URL)
Pick one:
- Basic auth at Traefik. Generate a user line with
htpasswd -nB anvi(oropenssl passwd -apr1). In the app's Coolify settings → Custom Labels, add (escape$as$$):
The router name is visible in the labels Coolify generated for the app; addtraefik.http.middlewares.studio-auth.basicauth.users=anvi:$$2y$$05$$… traefik.http.routers.<router-name>.middlewares=studio-authstudio-authto itsmiddlewareslist rather than replacing existing ones. - Tailscale. Don't expose a public domain at all; bind the app to the server's tailnet address (or run Tailscale on the host and use a
ts.nethostname via Tailscale Serve). Simpler and keyless, but every device that needs the studio must be on the tailnet.
Basic auth is the quicker of the two; Tailscale is the safer one. Either way, keep the Postgres resource internal.
Backups
- In Coolify, open the Postgres resource → Backups → add a schedule (e.g. daily at 03:00, keep 14). Point it at an S3-compatible bucket (Hetzner Object Storage works) so backups survive the server.
- Test a restore once: download a dump,
pg_restoreit into a scratch database, open the app against it. - Uploaded portraits and generated icons live in the
/app/.data/assetsvolume, not in Postgres. Back that volume up too (Coolify's volume backup, or a nightlytarof the volume directory to the same bucket). The database only stores their IDs.
Updating
Push to the deploy branch; Coolify rebuilds the image and swaps containers. Migrations run at start. If a migration must not run automatically, set MIGRATE_ON_START=0 for that deploy and run it by hand through the tunnel with npm run db:migrate.
Local check of the production image
Docker isn't installed on the dev machine, so the image itself is unverified. What can be checked locally is the standalone server the image runs, started from its own folder like the image does:
npm run build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ && cp -r drizzle docs .next/standalone/
cd .next/standalone && NODE_ENV=production PORT=3100 DATABASE_URL=pglite://$PWD/../../.data/pglite-standalone ASSETS_DIR=$PWD/../../.data/assets-standalone OPENAI_API_KEY= node server.js
Then open http://localhost:3100/api/health. Blanking OPENAI_API_KEY keeps icon generation on the free placeholder generator (the build copies .env into .next/standalone/; the Docker image doesn't, because .dockerignore excludes it). Last run 2026-11-15: all pages, the icon search and glyph routes, the style references and placeholder generation into ASSETS_DIR worked.