diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..57b8170 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,248 @@ +# Deployment — Coolify + +This document covers deploying the Polaris Task Force to **Coolify** (self-hosted PaaS). The app is built as a single Docker image (multi-stage, Bun + Node) and deployed once per environment, each backed by its own Coolify-managed PostgreSQL service. + +Local full-stack dev with the same image shape is described at the end (`docker compose up`). + +--- + +## 1. Architecture at a glance + +``` + Coolify cluster + ┌──────────────────────────────────────────────────────────┐ + │ │ + dev env │ ┌────────────┐ ┌─────────────┐ │ + (polaris-dev) │ │ App svc │ ← │ Postgres │ (Coolify-managed, │ + │ │ (Docker- │ │ svc │ per-env) │ + │ │ file) │ │ + volume │ │ + │ │ + media │ └─────────────┘ │ + │ │ volume │ │ + │ └─────┬──────┘ │ + │ │ scheduled jobs (docker exec → bun run payload) │ + └────────┼──────────────────────────────────────────────────┘ + │ + \________ builds pushed on git push (webhook) +``` + +- **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. +- **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. +- **The Discord bot is NOT deployed** today (`src/bot/` is planned but not started). The image still contains the source for future use; no bot service is wired in this guide. + +> ⚠️ **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. + +--- + +## 2. Prerequisites in Coolify + +1. A working **Coolify** instance (you said yours is at `https://coolify.onyxsimple.com`). +2. A **destination server** registered with Coolify (Docker engine reachable). +3. This repo reachable by Coolify (Git source — GitHub/GitLab/etc.) so it can build on push. A manual image-build flow is also fine if you prefer. +4. A Postgres-compatible **SMTP relay** if you want password-reset email (the app already supports Brevo; otherwise leave the `EMAIL_*` env vars blank). + +--- + +## 3. Provisioning a per-environment PostgreSQL service + +For each environment (`dev`, `stg`, `prd`): + +1. In Coolify, create (or open) the **Project** that represents the environment (e.g. `Polaris → dev`). +2. **New Resource → Database → PostgreSQL**. +3. Pick a sensible DB name, user, password. Coolify creates the database, exposes the connection string, and injects `POSTGRES_*` env vars into the same project automatically. +4. Note the **Connection String** Coolify generates (e.g. `postgres://ptf_app:@:5432/ptf-app-dev`). You'll wire this into the app service next. + +> Backups, version upgrades, and per-env resource limits are all handled by Coolify on this service — that's the whole point of hosting the DB individually. + +--- + +## 4. Creating the app service (per environment) + +1. Inside the same Coolify Project, **New Resource → Service / Application → From a Dockerfile** (or "From a Git repository" and point at the repo root — Coolify will detect `Dockerfile`). +2. Configure the build: + - **Port**: `3000` (already `EXPOSE`'d). + - **Health Check Path**: `/api/health` — Coolify will hit this to decide "healthy". The Docker `HEALTHCHECK` directive does the same thing inside the container. + - **Build Pack**: Dockerfile. + - **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). +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). + +### Build args you may want to override + +The Dockerfile pins `bun@1.3.11` and `node:22-alpine`. If you want to bump either without editing the Dockerfile, Coolify doesn't pass build args by default — just edit the Dockerfile lines (`RUN npm install -g bun@` and `FROM node:-alpine`). + +--- + +## 5. Environment variables (per environment) + +| Var | Required | Example / Notes | +|---|---|---| +| `DATABASE_URI` | ✅ | Coolify auto-injects this from the per-env Postgres service if linked. Format: `postgres://:@:5432/` | +| `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` | +| `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_NAME` | — | Display name (e.g. `Arma`) | +| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) | +| `EMAIL_PORT` | — | `587` | +| `EMAIL_USERNAME` | — | SMTP user | +| `EMAIL_PASSWORD` | — | SMTP password / API key | + +> Never copy the dev `PAYLOAD_SECRET` into prod. Never copy `GAME_TICK_NOTIFY_SECRET`. Generate fresh per environment. + +--- + +## 6. Scheduled jobs (game-tick, market-tick) + +The `game-tick` and `market-tick` Payload bins are registered on `payload.config.ts` and expected to run on a regular cadence (cron). In Coolify: + +### Option A — Coolify Scheduled Tasks (recommended) + +Coolify has a **Scheduled Tasks** feature per service (` → Scheduled Tasks → Add`). Each scheduled task runs a command inside the *running service container* — equivalent to `docker exec`. Add: + +| Job | Schedule (cron) | Command | +|---|---|---| +| Process shipments | `*/5 * * * *` (every 5 min, tune to match `gameTickIntervalMinutes` in your Game Rules global) | `bun run payload game-tick` | +| Refresh market | `*/10 * * * *` (every 10 min — adjust as needed) | `bun run payload market-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. + +### Option B — external cron calling `docker exec` over SSH + +If you prefer existing infra: + +```cron +*/5 * * * * ssh -i /path/to/key root@coolify-host \ + docker exec polaris-task-force-app- bun run payload game-tick +*/10 * * * * ssh -i /path/to/key root@coolify-host \ + docker exec polaris-task-force-app- bun run payload market-tick +``` + +The container name comes from Coolify's per-service container name (visible on the service dashboard). + +### Confirming a tick ran + +Each tick: +- 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. +- Emits `GameEventLog` entries you'll see in the Payload admin → Game Event Logs. + +--- + +## 7. Running migrations after a schema change + +If you add a new collection / field and create a new Payload migration under `src/migrations/`, run it once after deploying the new image: + +``` +docker exec bun run payload migrate +``` + +Coolify Scheduled Tasks support ad-hoc runs of the same command — fire it manually, watch the logs, then commit. **Never** run drizzle-kit (`bun run db migrate`) in prod; `bun run db push` fails on this DB because of the PostGIS `spatial_ref_sys` ownership issue (see `AGENTS.md` Gotchas). Use Payload's migration runner exclusively. + +For dev (where `push: true` in `payload.config.ts`), starting the dev server auto-applies schema changes — that's intentional and fine for dev databases. + +--- + +## 8. Persistent media volume — important for rollbacks + +Payload's `Media` collection (`src/collections/Media.ts`) uses `upload: true` with no storage adapter, so files land on local disk under `/media` (i.e. `/app/media` inside the container, since `WORKDIR /app` and `process.cwd() = /app`). + +In Coolify: +1. On the app service → **Persistent Storage → Add Path**. +2. Host path or named volume → mapped to `/app/media` in the container. +3. Coolify preserves this volume across deploys, **including rollbacks**. + +Without this, every redeploy wipes user uploads. + +> 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. + +--- + +## 9. Health checks + +- **Container** `HEALTHCHECK` (inside the Dockerfile): every 30 s, `wget http://127.0.0.1:3000/api/health`. 3 consecutive failures → unhealthy. +- **Coolify** itself should also probe `GET /api/health` (configure the service Health Check Path to `/api/health`). Coolify will route traffic only to "healthy" containers, so it doubles as your readiness gate after deployment. +- The health endpoint is deliberately DB-free — a transient database outage never marks the container unhealthy, so Coolify won't force a useless restart cycle while the Postgres service is bumping. + +Response shape: + +```json +{ "ok": true, "uptime": 1243, "version": "0.1.6", "commit": "a1b2c3d", "buildTime": "2026-08-12T20:30:00.000Z" } +``` + +--- + +## 10. Deploy flow (git push → live) + +Recommended flow: + +1. Bump version locally: `bun pm version patch` (or your own flow). Edit `package.json#version` and push the commit. +2. Push to your environment's branch (e.g. `main` → prod, `staging` → stg, `develop` → dev), or whatever branch Coolify watches. +3. Coolify's webhook fires, builds the image, and rolls the container. +4. Coolify waits for `/api/health` to come back 200, then routes traffic. +5. If a new migration shipped with that deploy, run it via `docker exec` or a Scheduled Task (Sec. 7). + +No SSH, no `rsync`, no `systemctl`. The legacy `build/deploy.sh` (gitignored, the old SSH+systemd flow to `jmf-usrv-2404`) is **no longer used** — exclude it from future deploys. `bun run deploy` (the npm script) will still try to run it (it calls `postversion → bash ./build/deploy.sh`); don't run it from your local machine anymore, or remove that script entry from `package.json` once you've moved onto Coolify for good. + +--- + +## 11. Rollbacks + +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. +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: +- 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. + +--- + +## 12. Local full-stack dev with `docker compose` + +For developers who want the production image shape locally (no Coolify required): + +```bash +cp .env.example .env +# Edit at minimum PAYLOAD_SECRET, GAME_TICK_NOTIFY_SECRET. +docker compose up --build +``` + +This boots: + +- `postgres` — Postgres 17 alpine, persisted to the `pgdata` named volume. +- `app` — the production Dockerfile image, talking to the bundled Postgres via `DATABASE_URI` rewritten by compose from `POSTGRES_USER/_PASSWORD/_DB`. + +App on `http://localhost:3000`, Postgres on `localhost:5432`. + +Useful commands: + +```bash +docker compose up --build # rebuild + boot +docker compose down # stop +docker compose down -v # stop + wipe the Postgres volume +docker compose exec app bun run payload game-tick # fire a tick manually +docker compose exec app bun run payload migrate # apply migrations +docker compose exec app sh # shell inside the app container +``` + +> `src/app/(frontend)/layout.tsx` gates on auth — first deploy is a clean DB; you'll need to seed an admin user. The existing seed scripts under `src/tools/seed/` run via bun, e.g. `docker compose exec app bun run src/tools/seed/.ts` (refer to those files for the intended entry point). + +--- + +## 13. Quick troubleshooting + +| 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. | +| 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. | +| 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. | + +--- + +Last updated: 2026-08-12. Verify against the live repo if anything looks stale. \ No newline at end of file diff --git a/README.md b/README.md index 2b2f856..7d36d3c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied). Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`. +For an Arma 3 unit deployment we self-host on **Coolify** with one container per environment (dev / stg / prod), a Coolify-managed PostgreSQL service per environment, a persistent volume for `/app/media`, and `docker exec` scheduled jobs for the game tick. The Dockerfile is Bun-based (multi-stage, Next.js standalone runtime + Payload CLI kept at runtime), and `docker-compose.yml` mirrors the same shape for local full-stack dev. + +For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**. + ## Project structure - `src/app/(frontend)/` — public-facing app pages (dashboard, logistics, banking, market)