Deployment
oven buildTwo things happen, in this order:
-
A route manifest is written to
.oven/routes.tswith a static import per route file, so production neither walks the filesystem at boot nor imports anything dynamically. -
Bun.buildbundles the app, targeting Bun. Output is a singledist/index.js— dependencies included.
oven start # runs dist/index.jsbun dist/index.js # the same thing, no CLI needed at runtimeThat 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.
Dockerfile
Section titled “Dockerfile”FROM oven/bun:1 AS buildWORKDIR /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-slimWORKDIR /app
# Only the bundle. No node_modules, no sources, no CLI.COPY --from=build /app/dist ./dist
# Never run as root.USER bunEXPOSE 3000CMD ["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.
Configuration
Section titled “Configuration”Read everything through env, in one module, at boot:
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.
Production refuses development defaults
Section titled “Production refuses development defaults”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 }).
Health checks and shutdown
Section titled “Health checks and shutdown”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.
Workers
Section titled “Workers”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.
Platforms
Section titled “Platforms”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.
Before you call it done
Section titled “Before you call it done”oven doctorpassesNODE_ENV=productionis set — several safety checks are keyed on itAUTH_SECRETcomes 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