Skip to content

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.

ServiceDockerfileBaseRuns as
apiapps/api/Dockerfilenode:22-alpineexpressjs (1001)
esuapps/esu/Dockerfilenode:22-alpinenon-root 1001
ogunapps/ogun/Dockerfilenode:22-alpinenon-root 1001
anansiapps/anansi/Dockerfilenode:22-alpinenon-root 1001
igdb-heartbeatapps/igdb-heartbeat/Dockerfilenode:22-alpinenon-root 1001
webapps/web/Dockerfilenode:22-alpinenextjs (1001)

The four stages

Using apps/api/Dockerfile as the reference:

1. base

dockerfile
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-init

COREPACK_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

dockerfile
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-lockfile

Only 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

dockerfile
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 tsc

Note 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:

dockerfile
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

dockerfile
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:

bash
./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-heartbeat

Each script does:

bash
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/amd64 is explicit, because DOKS nodes are amd64 and Apple Silicon would otherwise produce arm64 images that fail to start with a confusing exec format error.

Registry: registry.digitalocean.com/playpalzproduction.

bash
doctl registry login          # before the first push

Local 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:

bash
./start-dev.sh

Those 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

  • .dockerignore exists at the root and per app. Keep node_modules, .git, and dist in 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>.

Internal documentation — PlayPalz platform