LangENKO

Docker

Emit a multi-stage `Dockerfile` + `.dockerignore` tuned for Bun + Mandu. Artifact-only — users run `docker build` / `docker push` themselves.

since v0.24
On this page

Docker

mandu deploy --target=docker emits a multi-stage Dockerfile and a tuned .dockerignore. It is artifact-only — Mandu never invokes docker build for you.

# Emit artifacts only (idempotent)
mandu deploy --target=docker

# Preview what would be written
mandu deploy --target=docker --dry-run

Emitted files

.
├── Dockerfile
└── .dockerignore

Both are idempotent — re-running mandu deploy --target=docker produces byte-identical output given the same mandu.config.ts.

The Dockerfile

The emitted Dockerfile is multi-stage — dependency install, build, runtime — with aggressive layer caching:

# syntax=docker/dockerfile:1.6
ARG BUN_VERSION=1.3.12

# --- Stage 1: deps ---
FROM oven/bun:${BUN_VERSION}-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production=false

# --- Stage 2: build ---
FROM deps AS build
COPY . .
RUN bun run build

# --- Stage 3: runtime ---
FROM oven/bun:${BUN_VERSION}-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3333

# Copy only runtime deps + build output
COPY --from=build /app/package.json /app/bun.lock ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/.mandu ./.mandu
COPY --from=build /app/public ./public
COPY --from=build /app/dist ./dist

USER bun
EXPOSE 3333
CMD ["bun", "run", "start"]

The .dockerignore

node_modules
.mandu
coverage
.mandu/coverage
dist
.git
.github
tests
**/*.test.ts
**/*.test.tsx
**/__snapshots__/**
.env*
!.env.example

The goal is to keep the build context small — without this ignore, docker build sends every node_modules/ file over the wire to the daemon.

Building and pushing

# Build
docker build -t myapp:latest .

# Run locally
docker run --rm -p 3333:3333 -e NODE_ENV=production myapp:latest

# Tag + push to GHCR
docker tag myapp:latest ghcr.io/acme/myapp:v1.2.3
docker push ghcr.io/acme/myapp:v1.2.3

Runtime environment

Variable Default Purpose
PORT 3333 Listener port
NODE_ENV production Cookie secure flag, etc.
DATABASE_URL Primary DB (consumed by mandu db)
SESSION_SECRET Session middleware secret
JWT_SECRET Auth middleware secret

Pass them through -e or --env-file .env.production:

docker run --rm -p 3333:3333 \
  --env-file .env.production \
  myapp:latest

Image size

The runtime image is approximately:

Layer Size
oven/bun:1.3.12-alpine base ~90 MB
node_modules (production) ~20–80 MB (app-dependent)
.mandu build output ~5–20 MB
public + dist app-dependent

Typical total: 120–200 MB. If you need to drop further, post-process the runtime stage with docker-slim — the Dockerfile is already minimal on the Bun side.

Health check

Add a Mandu-friendly health check to the Dockerfile if your orchestrator doesn't supply one:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD bunx --bun wget --spider -q "http://127.0.0.1:${PORT}/healthz" || exit 1

Mandu exposes /healthz by default — it returns 200 when the server is live and the manifest is loaded.

Security notes

  1. Non-root runtime — the emitted Dockerfile pins USER bun. If you need root for a one-off install, add it to the deps stage, not runtime.
  2. No secrets in the image--set-secret stores values in the OS keychain; the adapter refuses to write them into the Dockerfile or any COPY source (CLI_E210).
  3. .dockerignore covers .env* — the ignore file excludes every .env* except .env.example. Do not move secrets into a committed file.

Common errors

EACCES when running as USER bun — a file in /app was created as root and then not chown'd. The emitted Dockerfile avoids this; if you hand-edit, make sure COPY --chown=bun:bun is used for files written in the runtime stage.

Image runs but no response on 3333 — check docker logs <id>. Usually a missing DATABASE_URL or SESSION_SECRET at runtime.

🤖 Agent Prompt

🤖 Agent Prompt — Docker
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/deploy/docker to my project.

Summary of the page:
`mandu deploy --target=docker` emits a Dockerfile using `oven/bun` as the build and runtime base. Multi-stage: deps → build → runtime. Runtime stage uses `USER bun` (non-root) and exposes port 3333. `.dockerignore` excludes `.mandu/`, `node_modules/`, `coverage/`.

Required invariants — must hold after your changes:
- `mandu deploy --target=docker` NEVER invokes `docker build` — users run it themselves
- Runtime stage runs as `USER bun` (non-root) — avoid running as root
- Default port is 3333 — override with `PORT` env at runtime
- Build cache is optimized via layered COPY: `package.json` + `bun.lock` first, source last

Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
   in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.

For Agents

{
  "schema": "mandu.deploy.docker/v0.24",
  "command": "mandu deploy --target=docker",
  "artifacts": ["Dockerfile", ".dockerignore"],
  "execute": false,
  "base_image": "oven/bun:${BUN_VERSION}-alpine",
  "runtime_user": "bun",
  "default_port": 3333,
  "rules": [
    "Runtime stage always runs as non-root (`USER bun`)",
    "`.env*` is always in `.dockerignore` — never bake secrets into the image",
    "`docker build` is NEVER invoked by `mandu deploy` — users run it"
  ]
}

For Agents

AI hint

`mandu deploy --target=docker` emits a Dockerfile using `oven/bun` as the build and runtime base. Multi-stage: deps → build → runtime. Runtime stage uses `USER bun` (non-root) and exposes port 3333. `.dockerignore` excludes `.mandu/`, `node_modules/`, `coverage/`.

Invariants
  • `mandu deploy --target=docker` NEVER invokes `docker build` — users run it themselves
  • Runtime stage runs as `USER bun` (non-root) — avoid running as root
  • Default port is 3333 — override with `PORT` env at runtime
  • Build cache is optimized via layered COPY: `package.json` + `bun.lock` first, source last
Guard scope
deploy