Skip to content

Deployment

Terminal window
oven build

Two things happen, in this order:

  1. A route manifest is written to .oven/routes.ts with a static import per route file, so production neither walks the filesystem at boot nor imports anything dynamically.

  2. Bun.build bundles the app, targeting Bun. Output is a single dist/index.js — dependencies included.

Terminal window
oven start # runs dist/index.js
bun dist/index.js # the same thing, no CLI needed at runtime

That second line is the useful one for a container: the built output has no dependency on @theoven/cli, so a production image does not need it.

FROM oven/bun:1 AS build
WORKDIR /app
# Dependencies first, so a code change does not re-download them.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bunx oven build
FROM oven/bun:1-slim
WORKDIR /app
# Only the bundle. No node_modules, no sources, no CLI.
COPY --from=build /app/dist ./dist
# Never run as root.
USER bun
EXPOSE 3000
CMD ["bun", "dist/index.js"]

If your app uses bun:sqlite with a file, mount a volume for it. A database inside the image is a database that resets on every deploy.

Read everything through env, in one module, at boot:

src/env.ts
export const config = {
port: env.port('PORT', 3000),
databaseUrl: env.string('DATABASE_URL'),
authSecret: env.string('AUTH_SECRET'),
}

A missing variable then fails at startup, with a message naming it — instead of at 3am as undefined somewhere unrelated. That is the whole reason the readers throw rather than returning a default.

oven doctor checks the same things before you deploy, and says what to do about each.

Four bricks deliberately fail to boot outside development rather than working badly:

Brick Refused Escape hatch Because
mail the console driver allowConsoleInProduction reset links printed to a log look healthy while nobody can reset their password
storage the disk driver allowDiskInProduction uploads on a container filesystem vanish on the next deploy
queue the memory driver allowMemoryInProduction a deploy silently drops every queued email
cache the memory driver allowMemoryInProduction each instance keeps its own copy, so a user refreshes and sees a different answer

The common shape: each one works perfectly on a laptop and fails only under conditions you cannot reproduce there — more than one instance, or a restart. That is the bug class worth a boot error, because no amount of local testing finds it.

Each failure names itself and says what to configure. The escape hatches are real, not discouragement: a per-instance cache for a hot lookup table is a legitimate choice, and allowMemoryInProduction is how you say so deliberately rather than by accident.

Oven is in production mode when NODE_ENV=production, or when you pass createApp({ development: false }).

src/routes/health.get.ts
export default () => ({ status: 'healthy' })

For a check that means something, ask the database:

import { checkHealth } from '@theoven/db'
export default async (ctx) => {
const database = await checkHealth(ctx.db)
ctx.status = database ? 200 : 503
return { status: database ? 'healthy' : 'degraded', database }
}

checkHealth issues a real query. A check that reports whether a pool object exists cannot fail, and a health check that cannot fail is not a health check.

Shutdown is already handled. SIGTERM stops accepting connections, waits for in-flight requests, drains the queue worker, then closes every brick’s resources. Give your platform a grace period longer than your slowest request — 30 seconds is a reasonable default — or it will SIGKILL mid-drain and undo the point.

The queue worker runs in your app process in development and not in production, so scale them separately:

# Same image, different command.
CMD ["bunx", "oven", "worker", "--concurrency", "20"]

That one needs @theoven/cli in the image, since it imports your app module rather than the bundle. oven worker --once drains and exits, which is what a cron container wants.

Fly.io — fly launch detects the Dockerfile. Set secrets with fly secrets set, and put [[services]] health checks on /health.

Railway / Render — point them at the Dockerfile. Both send SIGTERM and wait, which is what graceful shutdown needs.

Anywhere with a container runtime works. There is no serverless adapter: Oven assumes a long-lived process with a real filesystem, brick lifecycles and a queue worker, and pretending otherwise would mean a second set of semantics for the same API.

  • oven doctor passes
  • NODE_ENV=production is set — several safety checks are keyed on it
  • AUTH_SECRET comes from a secret store, not the image
  • Migrations run as a release step, not at boot from every replica at once
  • Health check points at a route that actually queries something
  • Shutdown grace period exceeds your slowest request
  • A volume is mounted for any SQLite file