Skip to content

Deploy: Docker

This page describes the production image used by a Kumiko server app. It assumes that you already have a container registry and a deployment target. For a single-VM setup, continue with Deploy: Solo. For the CDGS cluster, use Deploy: K3s.

Reference implementation: kumiko new app writes deploy/Dockerfile from the framework template Dockerfile.template.

  • Run the build from the app repository root.
  • Keep deploy/Dockerfile, kumiko/migrations/, and package.json in the build context.
  • Keep production environment values outside Git.
  • Use an immutable image tag such as sha-<short-commit> for a release and keep the previous tag for rollback.
  • If the app has a seeds/ directory, copy it into the runtime image. Seed migrations load those files at runtime.

The scaffolded deploy template uses two Bun stages. The runtime image contains the generated client bundle, server bundle, production dependencies, and checked-in SQL migrations.

1.7
ARG BUN_VERSION=1.4.0
ARG NPM_AUTH_TOKEN=
ARG BUILD_VERSION=dev
ARG BUILD_TIME=unknown
FROM oven/bun:${BUN_VERSION}-alpine AS build
WORKDIR /app
ARG NPM_AUTH_TOKEN
ENV GITHUB_TOKEN=${NPM_AUTH_TOKEN}
# Some apps resolve local package stubs during install, so keep the full
# repository in the build context.
COPY . .
RUN bun install --frozen-lockfile
RUN bun run build
FROM oven/bun:${BUN_VERSION}-alpine AS runtime
WORKDIR /app
ARG BUILD_VERSION=dev
ARG BUILD_TIME=unknown
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist-server ./
RUN bun install --production
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/kumiko/migrations ./kumiko/migrations
# Add this COPY only when the app has a seeds/ directory.
# COPY --from=build --chown=app:app /app/seeds ./seeds
USER app
ENV NODE_ENV=production
ENV PORT=3000
ENV KUMIKO_REPO_ROOT=/app
ENV INIT_CWD=/app
ENV BUILD_VERSION=$BUILD_VERSION
ENV BUILD_TIME=$BUILD_TIME
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --spider "http://127.0.0.1:${PORT}/health" || exit 1
CMD ["sh", "-c", "exec bun run server.js"]

The scaffold template adds private-package setup only when it detects a private package dependency. The exact token configuration belongs in the CI workflow. Do not place a token in the Dockerfile or commit it to Git. Pass it through the CI build configuration and make sure it is not printed in build logs.

The build context is the app repository root. The generated server bundle has a minimal package.json, so bun install --production installs only runtime externals such as native database, Redis, and hashing packages.

bun run build calls kumiko-build. An app with one client entry at src/client.tsx needs no configuration. An app with several browser entries declares them in package.json under kumiko.clientEntries; see kumiko build.

Tag the image with both the immutable release identifier and the moving tag used by your deployment system:

Terminal window
docker build \
--build-arg BUILD_VERSION="$(git describe --tags --always)" \
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t ghcr.io/your-org/your-app:${SHA} \
-t ghcr.io/your-org/your-app:latest \
-f deploy/Dockerfile .

Start the image only after the database migration step has succeeded. The example uses placeholders. Replace them with environment injection from your deployment system; do not put real credentials in this command or in Git.

Terminal window
docker run --rm \
--env-file .env.production \
-p 3000:3000 \
ghcr.io/your-org/your-app:sha-<short-commit>

Set KUMIKO_TRUSTED_PROXY_HOPS to the number of proxies in front of the app that append to X-Forwarded-For. One ingress or load balancer means 1. The app takes the client IP from that position in the header and ignores anything a client put in front of it. The default 0 trusts no proxy header and uses the socket address.

If you leave it at 0 behind a proxy, every request seems to come from the proxy, and all clients share one rate-limit bucket. Boot prints this warning when the value is not set:

[runProdApp] trustedProxyHops / KUMIKO_TRUSTED_PROXY_HOPS is not set; client IP falls back to the socket address. Behind a reverse proxy all clients share one rate-limit bucket.

An explicit trustedProxyHops option passed to runProdApp takes precedence over the variable. Do not set a higher value than the real number of proxies, or clients can choose their own IP through the header.

Apps that share one Redis instance each get their own ACL user and set KUMIKO_REDIS_PREFIX on every process of the app (API and worker). Without the variable the key layout stays as it was.

VariableEffect
KUMIKO_REDIS_PREFIX1 to 63 characters matching ^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$, so no : and no glob characters; an invalid value aborts the boot. Every Redis key (locks, rate limit, idempotency, event dedup, cache), the BullMQ prefix (<P>:bull) and every Pub/Sub channel then start with <P>:.
KUMIKO_REDIS_CHANNEL_PREFIXPrefixes Pub/Sub channels only, for apps without KUMIKO_REDIS_PREFIX, such as parallel e2e stacks. When both are set they must be equal, otherwise the boot aborts.
KUMIKO_SHUTDOWN_DRAIN_TIMEOUT_MSPositive integer. How long runProdApp and runWorkerApp wait for running work after SIGTERM. An explicit signals.drainTimeoutMs wins; the default is 25000. Set it to the pod’s terminationGracePeriodSeconds minus a margin, for example about 290000 for 300 seconds, so running jobs can finish.

The ACL user for prefix <P> needs the colon in its patterns. Without it, app-x* would also match the keys of app-x2::

ACL SETUSER <user> on ><password> resetkeys resetchannels ~<P>:* &<P>:* +@all -@dangerous +info +client|setname

The runtime image contains the bundled kumiko.js CLI. Run it as an ephemeral container against the same database before starting the app:

Terminal window
docker run --rm \
--env-file .env.production \
ghcr.io/your-org/your-app:sha-<short-commit> \
bun /app/kumiko.js schema apply

The command is idempotent. It applies pending SQL migrations and retries queued projection rebuilds. The process exits non-zero when a migration or rebuild fails. The app boot gate also refuses to start when the database schema does not match the migration journal.

If the app uses lookupable encrypted PII, the process running schema apply must receive the same KUMIKO_BLIND_INDEX_KEY as the app. Otherwise a rebuild must stop before it can replace a live projection with missing blind indexes.

For an arm64 deployment, build and push an arm64 image:

Terminal window
docker buildx build --platform linux/arm64 --push \
-t ghcr.io/your-org/your-app:sha-<short-commit> \
-f deploy/Dockerfile .

In GitHub Actions, use docker/setup-qemu-action@v3 when the runner is not already arm64 and set the matching platform on docker/build-push-action. Keep the image-build workflow in your app or deployment repository, and make its registry credentials available only through the CI secret store.

A browser tab that was opened before a deploy can still hold the old client bundle. bun run build writes a build ID to dist/build-info.json and embeds the same value in index.html as <meta name="kumiko-build">. Read the loaded value with readLoadedBuild().

Renderer-web mounts an UpdateChecker automatically. When the tab regains focus, it fetches build-info.json without using a cached response. If the server ID differs from the loaded ID, the app shows a reload banner. A missing build ID or a failed fetch does not produce a false positive.