6. Shipping it
bun run buildTwo things happen: a route manifest is written with a static import per route file, so production
never walks the filesystem at boot; then Bun.build bundles everything into a single
dist/index.js.
bun dist/index.jsThat runs with no node_modules at all — dependencies are in the bundle. Which is what makes
the container image below as small as it is.
The Dockerfile
Section titled “The Dockerfile”FROM oven/bun:1 AS buildWORKDIR /app
COPY package.json bun.lock ./RUN bun install --frozen-lockfile
COPY . .RUN bunx oven build
FROM oven/bun:1-slimWORKDIR /app
COPY --from=build /app/dist ./distCOPY --from=build /app/drizzle ./drizzle
USER bunEXPOSE 3000CMD ["bun", "dist/index.js"]oven/bun is Bun’s own image — the name collision with this framework is a coincidence, since
oven-sh is Bun’s GitHub organisation.
The drizzle/ folder comes along because migrations are how a fresh database gets its schema.
Run them as a release step, not from every replica at boot:
bunx oven db migrateFour things that will refuse to start
Section titled “Four things that will refuse to start”This is the part worth reading before you deploy rather than after.
| Refused in production | Because | |
|---|---|---|
mail |
the console driver |
reset links printed to a log look healthy while nobody can reset their password |
storage |
the disk driver |
uploads on a container filesystem vanish on the next deploy |
queue |
the memory driver |
a deploy silently drops every queued job |
cache |
the memory driver |
each instance keeps its own copy, so a refresh gives a different answer |
Your todo app registers none of the last three, so mail is the only one you have to deal with.
It uses the console driver, so it will not boot with NODE_ENV=production until you configure a
real one:
import { resendMail } from '@theoven/mail'
export const driver = resendMail({ apiKey: config.resendApiKey,})Configuration
Section titled “Configuration”NODE_ENV=productionPORT=3000DATABASE_URL=/data/todo.dbAUTH_SECRET=<from your secret store, not the image>NODE_ENV=production is what turns on the refusals above, and what stops internal error messages
and stacks reaching clients. AUTH_SECRET has no default and never should.
A SQLite file needs a mounted volume — a database inside the image resets on every deploy. When
you outgrow it, switching to Postgres is one import and one line in
db.ts; every query you wrote in chapters 3 and 4 is unchanged.
Health checks
Section titled “Health checks”import { checkHealth } from '@theoven/db'import { route } from '../route'
export default route({ summary: 'Liveness' }, 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
Section titled “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.
Before you call it done
Section titled “Before you call it done”bun run doctorpassesNODE_ENV=productionis set — several safety checks key on itAUTH_SECRETcomes from a secret store, not the image- Migrations run as a release step, not from every replica at boot
- A volume is mounted for the SQLite file
- The health check queries something real
- The shutdown grace period exceeds your slowest request
What you built
Section titled “What you built”Four route files, a schema, and a test — on top of a scaffold you did not modify. For that you have authentication with revocable sessions and password reset, per-user data isolation proven by tests, validation that documents itself, RFC 9457 errors with request ids, an OpenAPI document and a browsable reference, structured logging, and graceful shutdown.
None of which you wired.
Where to go next
Section titled “Where to go next”- Add file uploads — attachments on a todo, streamed straight to S3
- Add background jobs — a nightly digest of what is overdue
- Send real email — with typed templates
- Write your own brick — about fifty lines
- Deployment guide — Fly, Railway, and the full checklist