Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard
Deployment

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 bake

How the build is staged

prunerturbo 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.

depsbun install --frozen-lockfile, with a BuildKit cache mount so the package cache survives between builds.

builderturbo 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.

runnernode: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/app

The 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 --build

Every 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 migrate

Health 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.