ZeroStarter

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 up

That 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 to http://api:4000.
  • web, runtime: server-side API calls (SSR, server actions) in src/lib/config.ts hit the api container directly.
  • api, runtime: its presence flips POSTGRES_URL from localhost to host.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 → runner

prepare 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/static and public into the standalone bundle, and the runner starts bun server.js. next.config.ts sets 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 runs bun 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=true

Compose 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-file keeps inline comments: HONO_RATE_LIMIT=60 # note arrives as "60 # note" and fails validation. Compose's env_file strips comments; for a direct docker run, sanitize first: sed 's/ #.*//' .env > .env.docker.
  • Port already in use: 3000/4000 collide with a running bun run dev stack. Stop it, or remap the compose ports (e.g. 14000:4000) in a scratch checkout.

Next