Docker Images
Six services ship as containers. All follow the same multi-stage pattern, which exists to solve one problem: building a workspace package in a monorepo without shipping the whole monorepo.
| Service | Dockerfile | Base | Runs as |
|---|---|---|---|
| api | apps/api/Dockerfile | node:22-alpine | expressjs (1001) |
| esu | apps/esu/Dockerfile | node:22-alpine | non-root 1001 |
| ogun | apps/ogun/Dockerfile | node:22-alpine | non-root 1001 |
| anansi | apps/anansi/Dockerfile | node:22-alpine | non-root 1001 |
| igdb-heartbeat | apps/igdb-heartbeat/Dockerfile | node:22-alpine | non-root 1001 |
| web | apps/web/Dockerfile | node:22-alpine | nextjs (1001) |
The four stages
Using apps/api/Dockerfile as the reference:
1. base
FROM node:22-alpine AS base
ENV COREPACK_HOME=/opt/corepack
RUN mkdir -p /opt/corepack && chmod 777 /opt/corepack && \
corepack enable && corepack prepare pnpm@9.0.0 --activate
WORKDIR /app
RUN apk add --no-cache dumb-initCOREPACK_HOME is set to a world-readable path so the non-root runtime user shares the same pnpm cache as the build user — without it, corepack fails at runtime with a permission error.
dumb-init becomes PID 1 so SIGTERM reaches Node and Kubernetes rollouts terminate gracefully rather than being killed after the grace period.
2. deps — manifests only
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api/package.json ./apps/api/
COPY packages/database/package.json ./packages/database/
COPY packages/tsconfigs/package.json ./packages/tsconfigs/
COPY packages/types/package.json ./packages/types/
COPY packages/queue/package.json ./packages/queue/
RUN pnpm install --frozen-lockfileOnly package.json files are copied before the install. That layer is then cached and reused for every build where dependencies have not changed — which is most builds. Copying source first would invalidate it on every edit.
--frozen-lockfile fails rather than silently resolving a different tree.
3. builder — build in dependency order
WORKDIR /app/packages/types && RUN pnpm build
WORKDIR /app/packages/queue && RUN pnpm build
WORKDIR /app/packages/database
RUN DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy pnpm db:generate
RUN pnpm build
WORKDIR /app/apps/api
RUN pnpm exec tscNote the dummy DATABASE_URL. The comment in the file explains it: prisma.config.ts reads the variable at import time, but code generation never connects, so a placeholder satisfies the check without needing a live database at build time.
4. runner — minimal runtime
Copies only build outputs, then installs production dependencies:
COPY --from=builder /app/packages/database/dist ./packages/database/dist
COPY --from=builder /app/packages/database/generated ./packages/database/generated
COPY --from=builder /app/packages/types/dist ./packages/types/dist
COPY --from=builder /app/packages/queue/dist ./packages/queue/dist
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/apps/api/dist ./apps/api/dist
USER expressjs
ENTRYPOINT ["dumb-init", "--"]
CMD ["./docker-entrypoint.sh"]No source, no dev dependencies, no build tooling. Final size is roughly 300–400 MB — mostly Node, Prisma's query engine, and Sharp's native binaries.
Health checks
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"The start-period matters: without it, a slow first start counts as a failure and the container is killed before it ever becomes ready.
These Docker-level checks are informational in Kubernetes, which uses the deployment's own livenessProbe and readinessProbe. See Kubernetes.
The API health check port is hard-coded
It probes localhost:3000, but the deployment sets PORT=4000. The Docker health check therefore reports unhealthy in Kubernetes. Nothing depends on it — the Kubernetes probes target 4000 correctly — but it is misleading in docker ps. Same class of issue as the web image's check pointing at a nonexistent /api/health route.
Building and pushing
One script per service, plus one that does all of them:
./scripts/build-api.sh # latest
./scripts/build-api.sh v1.2.3 # a specific tag
./scripts/build-all.sh v1.2.3 # api, web, esu, ogun, anansi, igdb-heartbeatEach script does:
docker build --platform linux/amd64 -f "apps/$APP/Dockerfile" -t "$IMAGE" .
docker push "$IMAGE"Two things to note:
- Build context is the repo root, not the app directory — the Dockerfile needs the workspace manifests and the shared packages.
--platform linux/amd64is explicit, because DOKS nodes are amd64 and Apple Silicon would otherwise produce arm64 images that fail to start with a confusingexec format error.
Registry: registry.digitalocean.com/playpalzproduction.
doctl registry login # before the first pushLocal Compose
docker-compose.yaml provides infrastructure only — Postgres, Redis, LiveKit — not the services. docker-compose.dev.yaml overlays live-reload containers for api, esu, and ogun:
./start-dev.shThose dev containers mount the repo and run pnpm install && pnpm dev, with named volumes for each package's node_modules so the host and container trees do not collide.
Improving these images
.dockerignoreexists at the root and per app. Keepnode_modules,.git, anddistin it — a bloated context slows every build.- Layer order — always manifests, then install, then source. A
COPY . .early in the file destroys caching. - Pin the base image by digest if reproducibility matters more than automatic patching.
- Scan before a release:
docker scout cves <image>.
