Docker
Building and running kreogen applications as containers.
Each app builds into a standalone image from a single Dockerfile, parameterised by which app you want.
docker build --build-arg APP=app -t my-project/app .
docker build --build-arg APP=web -t my-project/web .
docker build --build-arg APP=api -t my-project/api .Or all three in parallel with a shared cache:
bun run docker:build # docker buildx bakeHow the build is staged
pruner — turbo prune --docker splits the workspace into manifests
(out/json) and sources (out/full). Installing from the manifests alone
means the dependency layer is only invalidated by a package.json or lockfile
change, not by every source edit. That is the difference between a
thirty-second rebuild and a five-minute one.
deps — bun install --frozen-lockfile, with a BuildKit cache mount so the
package cache survives between builds.
builder — turbo run build --filter=$APP, then an assertion that the
standalone output actually exists. Failing here is much easier to diagnose
than a container that starts and immediately exits.
runner — node:22-slim, non-root, with tini as PID 1 so SIGTERM
reaches Next and shutdown is graceful.
Standalone output
@kreogen/next-config sets both output: "standalone" and
outputFileTracingRoot.
The second is not optional in a monorepo. Without it Next infers the tracing root from the nearest lockfile, and if that resolves to the app rather than the workspace root, the standalone bundle lands at a different path than the Dockerfile expects.
It also sets outputFileTracingIncludes for the generated Prisma client, which
lives at a custom output path that Next's tracing does not follow on its own.
Public environment variables
Next inlines NEXT_PUBLIC_* into the client bundle at build time. Left alone,
that makes every image environment-specific and breaks promoting the exact
artifact that passed CI.
kreogen bakes in placeholders and substitutes them when the container starts, so one image serves staging and production:
docker run \
-e NEXT_PUBLIC_APP_URL=https://app.example.com \
-e NEXT_PUBLIC_WEB_URL=https://example.com \
-e DATABASE_URL=... \
-e BETTER_AUTH_SECRET=... \
-p 3000:3000 \
my-project/appThe URL-valued placeholders are themselves valid URLs on the reserved
.invalid TLD, because static generation parses them — sitemap.ts and
robots.ts construct a URL at module scope, and a bare token fails the
build there.
Adding a new public variable means adding it to docker/public-env.json and
running bun run generate:public-env. The Dockerfile's ENV block and
turbo.json's build env list are generated from that manifest, and the
entrypoint reads it directly at container start. The three used to be
maintained by hand, each with a comment asking whoever edited one to remember
the others; the drift was silent and only observable in something already
running — a variable baked in but not substituted serves a .invalid hostname
to users, and one missing from turbo.json is stripped by envMode: "strict"
and reads undefined in the browser only.
Running the full stack locally
docker compose -f docker-compose.yml -f docker-compose.apps.yml up --buildEvery container listens on 3000 internally; the host port encodes the app, so
the URLs match bun run dev.
Migrations run as a one-shot:
docker compose --profile tools run --rm migrateHealth checks
Every app serves /health, and the image declares a HEALTHCHECK against it.
It is a liveness probe — it answers "is the process serving?" and deliberately
does not touch the database, so a slow query cannot cause a restart loop.
/health is exempt from the auth middleware and from locale rewriting. Both
exemptions are load-bearing: without them the check would be redirected to
sign-in, or rewritten into a locale segment and 404.
Ports
Containers always listen on 3000. Map whatever you need at the host or
ingress; PORT is honoured if you must change it.