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.
Before you build
Section titled “Before you build”- Run the build from the app repository root.
- Keep
deploy/Dockerfile,kumiko/migrations/, andpackage.jsonin 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.
Dockerfile
Section titled “Dockerfile”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.
ARG BUN_VERSION=1.4.0ARG NPM_AUTH_TOKEN=ARG BUILD_VERSION=devARG BUILD_TIME=unknown
FROM oven/bun:${BUN_VERSION}-alpine AS buildWORKDIR /appARG NPM_AUTH_TOKENENV 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-lockfileRUN bun run build
FROM oven/bun:${BUN_VERSION}-alpine AS runtimeWORKDIR /appARG BUILD_VERSION=devARG BUILD_TIME=unknownRUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist-server ./RUN bun install --productionCOPY --from=build --chown=app:app /app/dist ./distCOPY --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 appENV NODE_ENV=productionENV PORT=3000ENV KUMIKO_REPO_ROOT=/appENV INIT_CWD=/appENV BUILD_VERSION=$BUILD_VERSIONENV BUILD_TIME=$BUILD_TIMEEXPOSE 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.
Build and run
Section titled “Build and run”Tag the image with both the immutable release identifier and the moving tag used by your deployment system:
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.
docker run --rm \ --env-file .env.production \ -p 3000:3000 \ ghcr.io/your-org/your-app:sha-<short-commit>Behind a reverse proxy
Section titled “Behind a reverse proxy”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.
Shared Redis and shutdown
Section titled “Shared Redis and shutdown”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.
| Variable | Effect |
|---|---|
KUMIKO_REDIS_PREFIX | 1 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_PREFIX | Prefixes 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_MS | Positive 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|setnameApply the schema before boot
Section titled “Apply the schema before boot”The runtime image contains the bundled kumiko.js CLI. Run it as an ephemeral
container against the same database before starting the app:
docker run --rm \ --env-file .env.production \ ghcr.io/your-org/your-app:sha-<short-commit> \ bun /app/kumiko.js schema applyThe 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.
Multi-architecture images
Section titled “Multi-architecture images”For an arm64 deployment, build and push an arm64 image:
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.
Browser update awareness
Section titled “Browser update awareness”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.