···17171818WORKDIR /app
19192020-COPY --from=builder /app/build ./
2020+# Keep output under ./build (not flattened) so the `db:*` scripts resolve
2121+# `build/index.js` both here and locally after `npm run build`.
2222+COPY --from=builder /app/build ./build
2323+COPY --from=builder /app/package.json ./package.json
2124COPY --from=builder /app/node_modules ./node_modules
22252326# I create a separate stage for these steps because they are not needed for
2427# development and chown can take a long time to run
2528FROM base
26292727-RUN groupadd -r coop && useradd -r -g coop -u 1001 coop
3030+# -m creates a home dir for `coop` so `npm run` has a writable HOME for its
3131+# cache/logs (the container runs `npm run db:*` as this non-root user).
3232+RUN groupadd -r coop && useradd -r -g coop -u 1001 -m coop
28332934RUN chown -R coop:coop /app
30353136# kubernetes requires us to use a numeric id for the user
3237USER 1001
3838+3939+# One-shot migrations task; override the command at runtime, e.g.
4040+# npm run db:update -- --db api-server-pg --env prod
4141+CMD ["node", "build/index.js"]
···33Pre-built images are published to the GitHub Container Registry on every push to `main`:
4455```
66-ghcr.io/roostorg/coop-server # API server
77-ghcr.io/roostorg/coop-worker # Background worker
88-ghcr.io/roostorg/coop-client # Frontend (nginx)
66+ghcr.io/roostorg/coop-server # API server
77+ghcr.io/roostorg/coop-worker # Background worker
88+ghcr.io/roostorg/coop-client # Frontend (nginx)
99+ghcr.io/roostorg/coop-migrations # One-shot database migrations runner
910```
10111111-Images are tagged with `latest`, the git SHA, and semver tags on release.
1212+Images are tagged with `latest`, the git SHA, and semver tags on release. The
1313+`coop-migrations` image is only rebuilt when something under `db/` changes (e.g.
1414+a new migration script) or on a release, so its tags track the schema rather
1515+than every server/client change.
12161317## Quick start
1418···75797680## Image details
77817878-| Image | Dockerfile | Build target | Base |
7979-| ------------- | ------------------- | --------------------- | --------------------------------- |
8080-| `coop-server` | `Dockerfile` | `build_server` | node:24-bullseye-slim + dumb-init |
8181-| `coop-worker` | `Dockerfile` | `build_worker_runner` | node:24-bullseye-slim + dumb-init |
8282-| `coop-client` | `client/Dockerfile` | `serve` | nginx:1.27-bookworm |
8282+| Image | Dockerfile | Build target | Base |
8383+| ----------------- | ------------------- | --------------------- | --------------------------------- |
8484+| `coop-server` | `Dockerfile` | `build_server` | node:24-bullseye-slim + dumb-init |
8585+| `coop-worker` | `Dockerfile` | `build_worker_runner` | node:24-bullseye-slim + dumb-init |
8686+| `coop-client` | `client/Dockerfile` | `serve` | nginx:1.27-bookworm |
8787+| `coop-migrations` | `db/Dockerfile` | _(final stage)_ | node:24-bullseye-slim |
83888489The client image serves the Vite-built SPA via nginx and proxies `/api/` requests (including `/api/v1/graphql`) to a backend service named `server` on port 8080.
9090+9191+## Running migrations
9292+9393+The `coop-migrations` image bundles the migration scripts under `db/` together
9494+with the migrator engine, so it can run as a one-shot task (an ECS `RunTask`, a
9595+Kubernetes `Job`, or the `migrations` service in `docker-compose.images.yaml`).
9696+It exposes the same `npm run db:*` commands used in local development, so the
9797+invocation matches what you'd run from the repo root:
9898+9999+```bash
100100+# Apply all pending migrations to an existing prod database
101101+docker run --rm --env-file .env.docker ghcr.io/roostorg/coop-migrations:latest \
102102+ npm run db:update -- --db api-server-pg --env prod
103103+```
104104+105105+Supported `--db` values are `api-server-pg`, `scylla`, and `clickhouse`.
106106+Connection settings come from environment variables (see `db/.env.example`),
107107+which must be present for any command since the database configs are read at
108108+startup.
109109+110110+### What `--env` controls
111111+112112+`--env` (`staging` or `prod`) only affects **seed scripts**, not migrations:
113113+114114+- Migration scripts run in every environment.
115115+- A seed named `*.seed.<env>.sql` runs **only** when `--env` matches it. Seed
116116+ files are timestamp-prefixed; the repo currently ships
117117+ `db/src/scripts/api-server-pg/2025.12.01T00.00.01.initial-test-data.seed.staging.sql`,
118118+ which creates a sample org with default-password users — so **use `--env prod`
119119+ in production** to skip it. Running `--env staging` against a prod database
120120+ would seed that test data.
121121+- `db:create` ignores `--env` functionally and is allowed in prod, so you can
122122+ provision schemas for self-hosted Scylla/ClickHouse (which have no managed
123123+ "create the database" step).
124124+- `db:clean` and `db:drop` are destructive and **reject `--env prod`** as a
125125+ safety guard.