1
0
Fork 0

chore(deploy): mount a persistent tiles volume and refresh deploy docs

Create /app/public/tiles in the runner image, bind-mount it in compose, and document the Coolify volume plus upload steps; restructure .gitignore (test artifacts, /public/tiles, backups).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-22 17:58:31 -04:00
parent 8135c9850b
commit 8cb6590ce6
5 changed files with 119 additions and 108 deletions

110
.gitignore vendored
View file

@ -1,74 +1,58 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/.archived/playwright-report/
/.archived/test-results/
/backups/
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
/blob-report/
/build
/coverage
# debug
# dependencies # dependencies
/node_modules .DS_Store
/.pnp .env
.pnp.js .env.*
.yarn/install-state.gz .env.development
.env*.local
.env.prd
.env.stg
# generated version info (regenerated by predev/prebuild/postversion)
/.idea/* /.idea/*
!/.idea/runConfigurations !/.idea/runConfigurations
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# generated version info (regenerated by predev/prebuild/postversion)
/src/generated/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
/logs/
# local env files # local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
.env.stg
.env.prd
.env.development
.env.*
/media
/backups/
# Playwright
node_modules/
/test-results/
/playwright-report/
/.archived/test-results/
/.archived/playwright-report/
/blob-report/
/playwright/.cache/
# local tooling # local tooling
/.logs/ /.logs/
/logs/
/media
# misc
/.next/
next-env.d.ts
# next.js
/node_modules
node_modules/
npm-debug.log*
/.omo/ /.omo/
/.opencode/
/.openchamber/ /.openchamber/
/.playwright-mcp/ /.opencode/
/out/
*.pem
# personal shell alias notes # personal shell alias notes
# Playwright
/playwright/.cache/
/.playwright-mcp/
/playwright-report/
/.pnp
.pnp.js
# production
/public/tiles
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/src/generated/
# testing
/test-results/
*.tsbuildinfo
# typescript
# vercel
.vercel
yarn-debug.log*
yarn-error.log*
.yarn/install-state.gz
.zinc .zinc

View file

@ -44,8 +44,9 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
## Test details ## Test details
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires a live PostgreSQL database (connection from `.env`). Uses `dotenv/config` via `vitest.setup.ts`. - **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` (with `reuseExistingServer: true`). Currently minimal (homepage smoke test). - **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). Currently minimal (homepage smoke test). Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts` - Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts` - Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
@ -415,6 +416,8 @@ Whenever creating a new component or refactoring an existing component's styling
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used. `bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
**Volumes**: prod containers mount persistent volumes for `/app/media` (uploads) and `/app/public/tiles` (gitignored map tile pyramids, uploaded to the server by hand). Both dirs are created in the image; see DEPLOYMENT.md Sections 4 and 8.
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed. **Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
**Version bumps and release tags: `main` deploys only**: whenever a `/git` or `/git-master` workflow lands work on `main` (or a deploy is being cut), bump the version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` (never a bare version number). The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits, but never use a bare `git push --tags`, which sends only tags and not the branch. **Feature branches must never bump versions or push release tags**: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. Release tags exist solely to mark `main` deploys. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump on a `main` deploy (or pushing the deploy commit without its tag) makes releases indistinguishable and breaks version history. **Version bumps and release tags: `main` deploys only**: whenever a `/git` or `/git-master` workflow lands work on `main` (or a deploy is being cut), bump the version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` (never a bare version number). The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits, but never use a bare `git push --tags`, which sends only tags and not the branch. **Feature branches must never bump versions or push release tags**: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. Release tags exist solely to mark `main` deploys. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump on a `main` deploy (or pushing the deploy commit without its tag) makes releases indistinguishable and breaks version history.

View file

@ -17,7 +17,7 @@ Local full-stack dev with the same image shape is described at the end (`docker
│ │ (Docker- │ │ svc │ per-env) │ │ │ (Docker- │ │ svc │ per-env) │
│ │ file) │ │ + volume │ │ │ │ file) │ │ + volume │ │
│ │ + media │ └─────────────┘ │ │ │ + media │ └─────────────┘ │
│ │ volume │ │ │ │ volume │ │
│ └─────┬──────┘ │ │ └─────┬──────┘ │
│ │ scheduled jobs (docker exec → node payload bin) │ │ │ scheduled jobs (docker exec → node payload bin) │
└────────┼──────────────────────────────────────────────────┘ └────────┼──────────────────────────────────────────────────┘
@ -26,12 +26,12 @@ Local full-stack dev with the same image shape is described at the end (`docker
``` ```
- **One image**, parameterized by env vars per environment. No image bake per env required. - **One image**, parameterized by env vars per environment. No image bake per env required.
- **PostgreSQL** is provisioned by Coolify as a *separate service* in each environment (`dev`, `stg`, `prd`). The app talks to it via `DATABASE_URI` injected by Coolify. - **PostgreSQL** is provisioned by Coolify as a _separate service_ in each environment (`dev`, `stg`, `prd`). The app talks to it via `DATABASE_URI` injected by Coolify.
- **Payload uploads** (`media/` collection) are written to a Coolify **Persistent Storage Volume** mounted at `/app/media` in the app container, so uploads survive container restarts, rollbacks, and rebuilds. - **Payload uploads** (`media/` collection) are written to a Coolify **Persistent Storage Volume** mounted at `/app/media` in the app container, so uploads survive container restarts, rollbacks, and rebuilds.
- **Scheduled one-shot jobs** (game-tick, market-tick) run via `docker exec` Coolify Scheduled Tasks against the running app container — same image, same env, no second service required. - **Scheduled one-shot jobs** (game-tick, market-tick) run via `docker exec` Coolify Scheduled Tasks against the running app container — same image, same env, no second service required.
- **The Discord bot ships inside the image** (`src/bot/`) and activates per-environment when `DISCORD_TOKEN` is set — prod only. It shares the same Postgres DB via the container env and inherits the single-instance constraint: **2 replicas = two bot instances** (duplicate command registration, double embeds/DMs). Notifications generated while the container is down are **not backfilled** (the Discord notification bridge polls with an in-memory cursor). - **The Discord bot ships inside the image** (`src/bot/`) and activates per-environment when `DISCORD_TOKEN` is set — prod only. It shares the same Postgres DB via the container env and inherits the single-instance constraint: **2 replicas = two bot instances** (duplicate command registration, double embeds/DMs). Notifications generated while the container is down are **not backfilled** (the Discord notification bridge polls with an in-memory cursor).
> ⚠️ **Single-instance only.** The realtime SSE pipeline (`gameTick → /api/game-tick/notify → in-process bus → SSE /api/realtime → client router.refresh()`) uses an **in-memory subscriber bus** (`src/lib/realtime/bus.ts`) that is not shared across processes. Coolify *must* run **exactly 1 replica** of the app container per environment for SSE to work. If you scale the app horizontally, SSE stops working (live shipment/event log updates silently fail). Everything else continues to work; you'll need a distributed bus before you can scale. See `src/lib/realtime/bus.ts` and `AGENTS.md` for context. > ⚠️ **Single-instance only.** The realtime SSE pipeline (`gameTick → /api/game-tick/notify → in-process bus → SSE /api/realtime → client router.refresh()`) uses an **in-memory subscriber bus** (`src/lib/realtime/bus.ts`) that is not shared across processes. Coolify _must_ run **exactly 1 replica** of the app container per environment for SSE to work. If you scale the app horizontally, SSE stops working (live shipment/event log updates silently fail). Everything else continues to work; you'll need a distributed bus before you can scale. See `src/lib/realtime/bus.ts` and `AGENTS.md` for context.
--- ---
@ -67,7 +67,8 @@ For each environment (`dev`, `stg`, `prd`):
- **Don't** use the `docker-compose.yml` for the production app service — that file is for local dev (it bundles its own Postgres). Coolify manages the Postgres service for you. - **Don't** use the `docker-compose.yml` for the production app service — that file is for local dev (it bundles its own Postgres). Coolify manages the Postgres service for you.
3. Set the environment variables (Section 5). 3. Set the environment variables (Section 5).
4. Attach a **Persistent Storage** entry mapping `/app/media` (read/write). This is where Payload stores uploaded media. The directory is created and chowned inside the image; just mount the volume on top. 4. Attach a **Persistent Storage** entry mapping `/app/media` (read/write). This is where Payload stores uploaded media. The directory is created and chowned inside the image; just mount the volume on top.
5. Deploy. First build will take a few minutes (Bun install + Next.js standalone build, `--max-old-space-size=8000` — make sure the build host has ≥ 4 GB free RAM, ideally 8). 5. Attach a second **Persistent Storage** entry mapping `/app/public/tiles` (read/write) if you serve map tile pyramids. The directory is created and chowned inside the image; upload tile sets into the volume (see Section 8).
6. Deploy. First build will take a few minutes (Bun install + Next.js standalone build, `--max-old-space-size=8000` — make sure the build host has ≥ 4 GB free RAM, ideally 8).
### Build args you may want to override ### Build args you may want to override
@ -77,25 +78,25 @@ The Dockerfile pins Bun 1.3.11 (**build-time only**) and Node 22 alpine (runtime
## 5. Environment variables (per environment) ## 5. Environment variables (per environment)
| Var | Required | Example / Notes | | Var | Required | Example / Notes |
|---|---|---| | ------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URI` | ✅ | Coolify auto-injects this from the per-env Postgres service if linked. Format: `postgres://<user>:<pwd>@<host>:5432/<db>` | | `DATABASE_URI` | ✅ | Coolify auto-injects this from the per-env Postgres service if linked. Format: `postgres://<user>:<pwd>@<host>:5432/<db>` |
| `PAYLOAD_SECRET` | ✅ | Random 32+ chars. `openssl rand -hex 16`. **Unique per environment.** | | `PAYLOAD_SECRET` | ✅ | Random 32+ chars. `openssl rand -hex 16`. **Unique per environment.** |
| `APP_URL` | ✅ | Public base URL for this environment, e.g. `https://dev.ptf.example.com` | | `APP_URL` | ✅ | Public base URL for this environment, e.g. `https://dev.ptf.example.com` |
| `GAME_TICK_NOTIFY_SECRET` | ✅ | Shared secret for `/api/game-tick/notify`. **Must match** the value used by your scheduled job (Sec. 6). | | `GAME_TICK_NOTIFY_SECRET` | ✅ | Shared secret for `/api/game-tick/notify`. **Must match** the value used by your scheduled job (Sec. 6). |
| `EMAIL_FROM_ADDRESS` | — | Outbound email "from" address (e.g. `arma@onyxsimple.com`) | | `EMAIL_FROM_ADDRESS` | — | Outbound email "from" address (e.g. `arma@onyxsimple.com`) |
| `EMAIL_FROM_NAME` | — | Display name (e.g. `Arma`) | | `EMAIL_FROM_NAME` | — | Display name (e.g. `Arma`) |
| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) | | `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) |
| `EMAIL_PORT` | — | `587` | | `EMAIL_PORT` | — | `587` |
| `EMAIL_USERNAME` | — | SMTP user | | `EMAIL_USERNAME` | — | SMTP user |
| `EMAIL_PASSWORD` | — | SMTP password / API key | | `EMAIL_PASSWORD` | — | SMTP password / API key |
| `DISCORD_TOKEN` | iff bot enabled in this env | Set in **prod only** — presence activates the bot (activation gate below). | | `DISCORD_TOKEN` | iff bot enabled in this env | Set in **prod only** — presence activates the bot (activation gate below). |
| `DISCORD_GUILD_ID` | iff `DISCORD_TOKEN` set | Config throws at import if the token is set and this is missing. | | `DISCORD_GUILD_ID` | iff `DISCORD_TOKEN` set | Config throws at import if the token is set and this is missing. |
| `DISCORD_OPS_CHANNEL_ID` | — | Ops channel for attendance RSVP embeds. | | `DISCORD_OPS_CHANNEL_ID` | — | Ops channel for attendance RSVP embeds. |
| `DISCORD_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. | | `DISCORD_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. |
| `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). | | `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). |
| `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. | | `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. |
| `DISCORD_NOTIFICATION_POLL_MS` | — | Notification bridge poll interval; default `20000`. | | `DISCORD_NOTIFICATION_POLL_MS` | — | Notification bridge poll interval; default `20000`. |
> Never copy the dev `PAYLOAD_SECRET` into prod. Never copy `GAME_TICK_NOTIFY_SECRET`. Generate fresh per environment. > Never copy the dev `PAYLOAD_SECRET` into prod. Never copy `GAME_TICK_NOTIFY_SECRET`. Generate fresh per environment.
@ -121,13 +122,13 @@ The `game-tick` and `market-tick` Payload bins are registered on `payload.config
### Option A — Coolify Scheduled Tasks (recommended) ### Option A — Coolify Scheduled Tasks (recommended)
Coolify has a **Scheduled Tasks** feature per service (`<service> → Scheduled Tasks → Add`). Each scheduled task runs a command inside the *running service container* — equivalent to `docker exec`. Add: Coolify has a **Scheduled Tasks** feature per service (`<service> → Scheduled Tasks → Add`). Each scheduled task runs a command inside the _running service container_ — equivalent to `docker exec`. Add:
| Job | Schedule (cron) | Command | | Job | Schedule (cron) | Command |
|---|---|---| | ----------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Process shipments | `*/5 * * * *` (every 5 min, tune to match `gameTickIntervalMinutes` in your Game Rules global) | `node node_modules/payload/bin.js game-tick` | | Process shipments | `*/5 * * * *` (every 5 min, tune to match `gameTickIntervalMinutes` in your Game Rules global) | `node node_modules/payload/bin.js game-tick` |
| Refresh market | `*/10 * * * *` (every 10 min — adjust as needed) | `node node_modules/payload/bin.js market-tick` | | Refresh market | `*/10 * * * *` (every 10 min — adjust as needed) | `node node_modules/payload/bin.js market-tick` |
| Server presence | `* * * * *` (every minute) | `node node_modules/payload/bin.js server-tick` | | Server presence | `* * * * *` (every minute) | `node node_modules/payload/bin.js server-tick` |
The commands run inside the app container with the app's env (incl. `DATABASE_URI`, `APP_URL`, `GAME_TICK_NOTIFY_SECRET`), so the bins talk to the same DB and post the notify callback correctly. The commands run inside the app container with the app's env (incl. `DATABASE_URI`, `APP_URL`, `GAME_TICK_NOTIFY_SECRET`), so the bins talk to the same DB and post the notify callback correctly.
@ -149,6 +150,7 @@ The container name comes from Coolify's per-service container name (visible on t
### Confirming a tick ran ### Confirming a tick ran
Each tick: Each tick:
- Updates `Shipments` rows in DB (status, fuel, arrival). - Updates `Shipments` rows in DB (status, fuel, arrival).
- `POST`s to `/api/game-tick/notify` (internal SSE bus push → clients `router.refresh()`). If no connected clients, this just 204s. - `POST`s to `/api/game-tick/notify` (internal SSE bus push → clients `router.refresh()`). If no connected clients, this just 204s.
- Emits `GameEventLog` entries you'll see in the Payload admin → Game Event Logs. - Emits `GameEventLog` entries you'll see in the Payload admin → Game Event Logs.
@ -174,12 +176,25 @@ For dev (where `push: false` in `payload.config.ts`), run `bun run payload migra
Payload's `Media` collection (`src/collections/Media.ts`) uses `upload: true` with no storage adapter, so files land on local disk under `<projectRoot>/media` (i.e. `/app/media` inside the container, since `WORKDIR /app` and `process.cwd() = /app`). Payload's `Media` collection (`src/collections/Media.ts`) uses `upload: true` with no storage adapter, so files land on local disk under `<projectRoot>/media` (i.e. `/app/media` inside the container, since `WORKDIR /app` and `process.cwd() = /app`).
In Coolify: In Coolify:
1. On the app service → **Persistent Storage → Add Path**. 1. On the app service → **Persistent Storage → Add Path**.
2. Host path or named volume → mapped to `/app/media` in the container. 2. Host path or named volume → mapped to `/app/media` in the container.
3. Coolify preserves this volume across deploys, **including rollbacks**. 3. Coolify preserves this volume across deploys, **including rollbacks**.
Without this, every redeploy wipes user uploads. Without this, every redeploy wipes user uploads.
### Map tiles volume (`/app/public/tiles`)
Map basemaps in `tiles` mode serve XYZ pyramids from `public/tiles/<map-slug>/{z}/{x}/{y}.jpg`.
The `public/tiles` directory is **gitignored** (like `media/`), so production images do not contain tiles; mount a
**Persistent Storage** volume at `/app/public/tiles` and get the tiles there by either:
- copying them into the volume's host path on the server (host-path mount: `scp -r public/tiles/antarctica/* <host-path>/`), or
- `docker cp` into the running app container before the volume is in place, or from the host into the volume.
Files added to the volume are served immediately; no restart needed. Keep the pyramid's top zoom level at the source
texture's native detail (256 x 2^z px >= source pixels) and set the map's `maxZoom` to that level.
> Note on the local-disk default: this works fine for a self-hosted single-instance deployment, but it does **not** scale beyond one replica (same caveat as the SSE bus). If you outgrow the single-instance architecture (more replicas, multi-host), switch to an S3-compatible storage adapter — add it to `payload.config.ts` under `plugins` (the project already has a `// storage-adapter-placeholder` hook for this) and drop the `/app/media` volume. > Note on the local-disk default: this works fine for a self-hosted single-instance deployment, but it does **not** scale beyond one replica (same caveat as the SSE bus). If you outgrow the single-instance architecture (more replicas, multi-host), switch to an S3-compatible storage adapter — add it to `payload.config.ts` under `plugins` (the project already has a `// storage-adapter-placeholder` hook for this) and drop the `/app/media` volume.
--- ---
@ -193,7 +208,13 @@ Without this, every redeploy wipes user uploads.
Response shape: Response shape:
```json ```json
{ "ok": true, "uptime": 1243, "version": "0.1.6", "commit": "a1b2c3d", "buildTime": "2026-08-12T20:30:00.000Z" } {
"ok": true,
"uptime": 1243,
"version": "0.1.6",
"commit": "a1b2c3d",
"buildTime": "2026-08-12T20:30:00.000Z"
}
``` ```
--- ---
@ -210,7 +231,7 @@ Recommended flow:
> **Tags only from `main` deploys.** Create and push the `vX.Y.Z` release tag only when deploying from `main`. Never bump versions or push release tags from feature branches: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. > **Tags only from `main` deploys.** Create and push the `vX.Y.Z` release tag only when deploying from `main`. Never bump versions or push release tags from feature branches: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped.
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only. > **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's _Advanced_ settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
> >
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay. > **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
> >
@ -229,7 +250,8 @@ Coolify supports two rollout modes:
1. **Automatic rollback on failed health**: if the new container never becomes healthy (3 failed `/api/health` probes within the `start_period`), Coolify keeps the old container running and doesn't route traffic to the new one. Brings you back to the previous working version with no action. 1. **Automatic rollback on failed health**: if the new container never becomes healthy (3 failed `/api/health` probes within the `start_period`), Coolify keeps the old container running and doesn't route traffic to the new one. Brings you back to the previous working version with no action.
2. **Manual rollback**: in the service dashboard,/files history → pick a previous image tag → "Rollback". This redeploys that exact image with the existing `/app/media` volume intact (because the volume is mounted, not tied to the image). 2. **Manual rollback**: in the service dashboard,/files history → pick a previous image tag → "Rollback". This redeploys that exact image with the existing `/app/media` volume intact (because the volume is mounted, not tied to the image).
Rollbacks do **not** roll the database schema. If a deploy includes a non-reversible migration that has already run, rolling back the *image* but keeping the DB can break the older image's assumptions. Mitigations: Rollbacks do **not** roll the database schema. If a deploy includes a non-reversible migration that has already run, rolling back the _image_ but keeping the DB can break the older image's assumptions. Mitigations:
- Only ship additive migrations (don't drop columns in the same deploy that removes their consumer code — drop them in a follow-up deploy instead). - Only ship additive migrations (don't drop columns in the same deploy that removes their consumer code — drop them in a follow-up deploy instead).
- For destructive changes, take a Coolify DB snapshot first. - For destructive changes, take a Coolify DB snapshot first.
@ -269,15 +291,15 @@ docker compose exec app sh # shell inside the app container
## 13. Quick troubleshooting ## 13. Quick troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Likely cause | Fix |
|---|---|---| | ------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Container restart loops, never healthy | DB connection unreachable | Verify `DATABASE_URI` resolves from inside the container (`docker compose exec app wget -qO- $DATABASE_URI:5432` or `nc -zv ...`). Coolify: confirm the Postgres and app services are in the same Project so Coolify's network reaches it. | | Container restart loops, never healthy | DB connection unreachable | Verify `DATABASE_URI` resolves from inside the container (`docker compose exec app wget -qO- $DATABASE_URI:5432` or `nc -zv ...`). Coolify: confirm the Postgres and app services are in the same Project so Coolify's network reaches it. |
| 401 loop after deploy | `PAYLOAD_SECRET` changed without rotating user sessions | Keep `PAYLOAD_SECRET` constant across deploys of the same env. If you must rotate, users will need to re-log in (cookies are JWT-signed against this secret). | | 401 loop after deploy | `PAYLOAD_SECRET` changed without rotating user sessions | Keep `PAYLOAD_SECRET` constant across deploys of the same env. If you must rotate, users will need to re-log in (cookies are JWT-signed against this secret). |
| Realtime updates stopped working | More than 1 replica running, OR scheduled tick stopped | Limit replicas to 1. Check the scheduled tasks in Coolify are firing and `GameEventLogs` show fresh `gameTick` events. | | Realtime updates stopped working | More than 1 replica running, OR scheduled tick stopped | Limit replicas to 1. Check the scheduled tasks in Coolify are firing and `GameEventLogs` show fresh `gameTick` events. |
| First build OOMs | Host has < 8 GB RAM during Next build | The `bun run build` script sets `--max-old-space-size=8000`. Give the Coolify build host at least 8 GB, or lower this in `package.json#scripts.build` (memory will become the limiter earlier). | | First build OOMs | Host has < 8 GB RAM during Next build | The `bun run build` script sets `--max-old-space-size=8000`. Give the Coolify build host at least 8 GB, or lower this in `package.json#scripts.build` (memory will become the limiter earlier). |
| `bun run db push` fails PostGIS error | This is expected (`must be owner of table spatial_ref_sys`) | Use `bun run payload migrate` only. See `AGENTS.md` Gotchas. | | `bun run db push` fails PostGIS error | This is expected (`must be owner of table spatial_ref_sys`) | Use `bun run payload migrate` only. See `AGENTS.md` Gotchas. |
| Token set but bot never logs in / silent bot death | Entrypoint gate, config throw, or revoked/invalid token | Check the container logs for the bot's fail-fast line. Confirm `DISCORD_GUILD_ID` is set (the config throws at import if missing) and the token is valid/not revoked. The web app is unaffected — the bot is an in-process subordinate. | | Token set but bot never logs in / silent bot death | Entrypoint gate, config throw, or revoked/invalid token | Check the container logs for the bot's fail-fast line. Confirm `DISCORD_GUILD_ID` is set (the config throws at import if missing) and the token is valid/not revoked. The web app is unaffected — the bot is an in-process subordinate. |
| Scheduled task / bot dies with `Illegal instruction (core dumped)` | Running Bun on the host CPU (no AVX/AVX2) | The runtime image is Node-only — never `docker exec ... bun ...`. Bins: `node node_modules/payload/bin.js <cmd>`; bot: `node --import tsx src/bot/index.ts`. | | Scheduled task / bot dies with `Illegal instruction (core dumped)` | Running Bun on the host CPU (no AVX/AVX2) | The runtime image is Node-only — never `docker exec ... bun ...`. Bins: `node node_modules/payload/bin.js <cmd>`; bot: `node --import tsx src/bot/index.ts`. |
--- ---

View file

@ -144,7 +144,7 @@ ENV NODE_ENV=production \
HOSTNAME=0.0.0.0 HOSTNAME=0.0.0.0
# The `node` user already exists in `node:*-alpine` images. # The `node` user already exists in `node:*-alpine` images.
RUN mkdir -p /app/media \ RUN mkdir -p /app/media /app/public/tiles \
&& chown -R node:node /app && chown -R node:node /app
# 1) Next.js standalone runtime (server.js + traced node_modules + .next). # 1) Next.js standalone runtime (server.js + traced node_modules + .next).

View file

@ -32,6 +32,8 @@ services:
volumes: volumes:
# Persist Payload uploads between rebuilds locally. # Persist Payload uploads between rebuilds locally.
- ./media:/app/media - ./media:/app/media
# Map tile pyramids (gitignored; generated locally, uploaded in prod).
- ./public/tiles:/app/public/tiles
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -63,4 +65,4 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pgdata: pgdata: