Docker#
A Janux app in a container is Bun + your source + dist/client. There is no server bundle to produce, so there is nothing to copy between stages: one stage installs, builds the client assets and runs janux start.
FROM oven/bun:1.3-slim
WORKDIR /app
# Deps first: this layer is cached until package.json or the lockfile changes.
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
ENV PORT=3000
EXPOSE 3000
USER bun
CMD ["bun", "run", "start"]docker build -t my-app .
docker run -p 3000:3000 my-app.dockerignore is part of the recipe#
COPY . . happens after bun install, so without this file you copy your host's node_modules on top of the container's:
node_modules
dist
.git
*.logIt still builds — Bun keeps the platform binary it installed — but the image carries your machine's dead weight. Measured on the starter app: 425 MB with the file, 523 MB without it (98 MB of macOS binaries that can never run there).
What ends up in the image#
| What | Detail |
|---|---|
| Size (starter app) | ~425 MB — mostly the Bun runtime and node_modules |
| Build toolchain | stays: Vite, Rollup and esbuild are dependencies of @janux/cli, and janux start is that CLI |
curl / wget |
absent from oven/bun:*-slim — see the healthcheck below |
| Your TSX | shipped as source: janux start runs it on Bun, there is no server build step |
bun install --production works and is safe — janux, @janux/server and @janux/cli are runtime dependencies in the scaffolded app, not dev ones. It just doesn't save much, and neither does a multi-stage build: a builder stage plus a --production runtime stage measured 421 MB against the single stage's 425 MB. The toolchain isn't build-only here, so there is nothing to leave behind. Skip the complexity.
Healthcheck without curl#
The slim image has no HTTP client on PATH, so a CMD curl -f healthcheck fails 100% of the time. Bun is the HTTP client:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD bun -e 'const r = await fetch(`http://localhost:${process.env.PORT ?? 3000}/`); process.exit(r.ok ? 0 : 1)'Ports#
ENV PORT=3000 sets the default; the server reads PORT at boot, so the runtime wins:
docker run -e PORT=4000 -p 8080:4000 my-app # server listens on 4000Environment#
| Variable | Effect if absent |
|---|---|
JANUX_MODEL or a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY) |
The app works and /_janux/agent answers 200 with a setup card — only the copilot's model is missing |
PORT |
Defaults to 3000 |
| Your own secrets | Read them in ctxFor or in api() modules, as usual |
Verify the container, not just the build#
curl -s localhost:3000/ # SSR HTML with <janux-island> markers
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/client.js # 200: the build landed
curl -s 'localhost:3000/_janux/manifest?path=/' # the agent surface is up
curl -s -X POST localhost:3000/_janux/agent \
-H 'content-type: application/json' -d '{"messages":[]}' # 200, even with no model keyA 200 on / with a 404 on /client.js means bun run build didn't run (or dist/client was ignored after the build): the page renders, the islands never wake up.
Compose#
services:
web:
build: .
ports: ['3000:3000']
environment:
PORT: 3000
JANUX_MODEL: ${JANUX_MODEL}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
healthcheck:
test: ['CMD', 'bun', '-e', 'process.exit((await fetch("http://localhost:3000/")).ok ? 0 : 1)']
interval: 30sStatic apps don't need any of this#
With output: 'static' there is no server at request time: janux build prerenders the pages and you upload dist/client. See deploying.
Related: Deploying · Custom server · CLI