Deploy with Docker
Run the whole stack anywhere with the multi-stage Dockerfiles and Compose.
Package web/next and api/hono as two container images from their multi-stage Dockerfiles, then bring the whole stack up with one command:
docker compose upThat builds and starts both services, web on localhost:3000 and api on localhost:4000, reading config from a root .env you provide (see Environment Variables). Compose lives in docker-compose.yml at the repo root.
How the containers talk: INTERNAL_API_URL
Inside the Compose network the web container can't reach the api at localhost: that address is its own container. INTERNAL_API_URL=http://api:4000 (set on both services in docker-compose.yml, and baked into the web image) is the one Docker-specific knob, and it does three jobs:
- web, build time: baked into the Next.js rewrite in
next.config.ts, so/api/*proxies tohttp://api:4000. - web, runtime: server-side API calls (SSR, server actions) in
src/lib/config.tshit the api container directly. - api, runtime: its presence flips
POSTGRES_URLfromlocalhosttohost.docker.internal(packages/env/src/db.ts), so the container reaches a database running on your host.
External clients still use the mapped ports (localhost:3000, localhost:4000). You don't set INTERNAL_API_URL by hand for Compose: it's already wired.
The images
Both Dockerfiles share one shape, on oven/bun:1.3.14-alpine:
base → prepare → builder → runnerprepare runs turbo prune <workspace> --docker to carve a workspace-only build context, so each image installs and builds just its own dependencies. The final runner stage runs as the non-root USER bun.
- The web image uses Next.js
output: "standalone": the builder copies.next/staticandpublicinto the standalone bundle, and the runner startsbun server.js.next.config.tssets it for every build except Vercel's, which packages its own output through a Next adapter and writes no trace files for the standalone step to copy. - The api image copies its
bundle/output and runsbun bundle/index.mjs.
Why the images stay small and self-contained
The api runner ships only its bundle: no node_modules, so it resolves every import from the bundle and starts with no network. The web build prunes the native binaries it will never run: next.config.ts detects the image's libc (alpine is musl) and drops the other-libc sharp and @takumi-rs binaries from output tracing, keeping only the takumi core /og needs. That keeps a single libc stack in the standalone output instead of both.
The .env build secret
Both builds validate the full env, so they need .env at build time, but never bake it into a layer. It's passed as a BuildKit secret, mounted only for the build step:
--mount=type=secret,id=dotenv,target=/app/.env,required=trueCompose wires this from secrets: dotenv (sourced from ./.env); a plain docker build passes --secret id=dotenv,src=.env. The secret is read from your host and never enters the build context; .dockerignore also excludes every real .env* file, letting only .env.example in. A missing .env fails the build immediately.
Rebuild with --no-cache after editing .env
Secret contents aren't part of BuildKit's cache key. Edit .env and a plain rebuild can reuse the cached build step, silently shipping stale values, including web's NEXT_PUBLIC_* and the auth session-cookie scope derived from HONO_APP_URL, both baked into the bundle at build time. After any env change, run docker compose build --no-cache.
Building images individually
docker build --secret id=dotenv,src=.env -f api/hono/Dockerfile -t zerostarter-api .
docker run -p 4000:4000 --env-file .env zerostarter-api
docker build --secret id=dotenv,src=.env -f web/next/Dockerfile -t zerostarter-web .
docker run -p 3000:3000 --env-file .env zerostarter-web.env does double duty here: --secret supplies it at build, --env-file supplies it at runtime.
Migrations: you run them
Docker does not migrate your database
Unlike the Vercel build, the Docker images never run migrations. Apply them yourself with bun run db:migrate before or alongside a rollout. Only Vercel migrates on deploy, via .github/scripts/migrate-on-deploy.ts.
The api needs a reachable POSTGRES_URL; without one, DB-backed routes like /api/v1/user return 500 while /api/health still passes. For production use a managed Postgres (Neon, Supabase, Railway). This project requires PostgreSQL, so MySQL-compatible providers won't work without substantial rework.
Troubleshooting
- Stale values after an env change: rebuild with
docker compose build --no-cache(see the callout above). docker run --env-filekeeps inline comments:HONO_RATE_LIMIT=60 # notearrives as"60 # note"and fails validation. Compose'senv_filestrips comments; for a directdocker run, sanitize first:sed 's/ #.*//' .env > .env.docker.- Port already in use:
3000/4000collide with a runningbun run devstack. Stop it, or remap the compose ports (e.g.14000:4000) in a scratch checkout.
Next
- Deploy to Vercel: the serverless path, where the build migrates for you.
- Environment Variables: every variable, and which app validates it.
- Database: the schema and how migrations work.