Containerize service + unauthenticated /api/health (#9) #35

Merged
james merged 1 commit from 9-containerize-service into main 2026-06-13 13:44:17 +00:00
Owner

Closes #9.

Summary

  • Multi-stage Dockerfile (deps → builder → runtime), runs as the non-root node user. Migrations apply on startup via the existing instrumentation.ts hook.
  • /api/health — unauthenticated, returns {status, version, db} JSON. 200 when DB is responsive within 2s, 503 + db_error when it isn't. version is read from package.json via a JSON import so it bakes into the bundle at build time.
  • output: "standalone" in next.config.mjs — Next.js emits a self-contained server bundle so the runtime image only carries the deps it needs.
  • Two compose filescompose.sqlite.yaml (self-hoster default, one container + one volume) and compose.postgres.yaml (with a pg_isready healthcheck + depends_on gating). Either is one docker compose up --build away.

Stack notes worth flagging

  • node:22-slim over node:22-alpine. @libsql ships a prebuilt linux-x64-musl binding that fails to load against alpine's musl with fcntl64: symbol not found. Slim is ~30MB heavier but matches the glibc the prebuilt was built against. Reversible if libsql ever ships a matching musl prebuilt, or if we replace libsql with a pure-JS SQLite.
  • Default DATABASE_URL=sqlite:./data/carol.db baked into the image — zero-config self-host. Overridable at runtime; the SQLite parent directory is auto-created on first connect.
  • Empty public/.gitkeep added because the Next.js standalone Dockerfile pattern copies /app/public and the scaffold didn't create the directory.

Local verification (podman, rootless)

  • podman build -t carol . ✓ (clean build on Debian-slim)
  • podman run --rm -p 3000:3000 carol ✓ boots in ~150ms
  • curl -4 http://localhost:3000/api/health200, {"status":"ok","version":"0.0.0","db":"ok"}
  • /app/data/carol.db created on first request, owned by node:node
  • Container responds correctly from inside (podman exec ... node -e 'fetch(...)'); the -4 is just to dodge a podman rootless IPv6 forwarding quirk on the host

Out of scope (intentionally deferred)

The ticket also asks that any non-health endpoint return 401 unauthenticated. That gate is #10's responsibility (auth middleware). Today the placeholder / still returns 200. When #10 lands, the Dockerfile and compose files don't change — only the middleware does.

Test plan

  • npm run lint / npm run typecheck / npm run build / npm test — all green
  • podman build from clean checkout — succeeds
  • podman run against default SQLite — /api/health returns 200 with the expected body
  • Container process runs as node (uid 1000), not root
  • docker compose -f compose.postgres.yaml up --build smoke test (not run locally yet — pattern matches the SQLite path, same image + Postgres service)
Closes #9. ## Summary - **Multi-stage `Dockerfile`** (deps → builder → runtime), runs as the non-root `node` user. Migrations apply on startup via the existing `instrumentation.ts` hook. - **`/api/health`** — unauthenticated, returns `{status, version, db}` JSON. 200 when DB is responsive within 2s, 503 + `db_error` when it isn't. `version` is read from `package.json` via a JSON import so it bakes into the bundle at build time. - **`output: "standalone"`** in `next.config.mjs` — Next.js emits a self-contained server bundle so the runtime image only carries the deps it needs. - **Two compose files** — `compose.sqlite.yaml` (self-hoster default, one container + one volume) and `compose.postgres.yaml` (with a `pg_isready` healthcheck + `depends_on` gating). Either is one `docker compose up --build` away. ## Stack notes worth flagging - **`node:22-slim` over `node:22-alpine`.** `@libsql` ships a prebuilt `linux-x64-musl` binding that fails to load against alpine's musl with `fcntl64: symbol not found`. Slim is ~30MB heavier but matches the glibc the prebuilt was built against. Reversible if libsql ever ships a matching musl prebuilt, or if we replace libsql with a pure-JS SQLite. - **Default `DATABASE_URL=sqlite:./data/carol.db`** baked into the image — zero-config self-host. Overridable at runtime; the SQLite parent directory is auto-created on first connect. - **Empty `public/.gitkeep`** added because the Next.js standalone Dockerfile pattern copies `/app/public` and the scaffold didn't create the directory. ## Local verification (podman, rootless) - `podman build -t carol .` ✓ (clean build on Debian-slim) - `podman run --rm -p 3000:3000 carol` ✓ boots in ~150ms - `curl -4 http://localhost:3000/api/health` → **200**, `{"status":"ok","version":"0.0.0","db":"ok"}` - `/app/data/carol.db` created on first request, owned by `node:node` - Container responds correctly from inside (`podman exec ... node -e 'fetch(...)'`); the `-4` is just to dodge a podman rootless IPv6 forwarding quirk on the host ## Out of scope (intentionally deferred) The ticket also asks that any **non-health** endpoint return 401 unauthenticated. That gate is **#10's** responsibility (auth middleware). Today the placeholder `/` still returns 200. When #10 lands, the Dockerfile and compose files don't change — only the middleware does. ## Test plan - [x] `npm run lint` / `npm run typecheck` / `npm run build` / `npm test` — all green - [x] `podman build` from clean checkout — succeeds - [x] `podman run` against default SQLite — `/api/health` returns 200 with the expected body - [x] Container process runs as `node` (uid 1000), not root - [ ] `docker compose -f compose.postgres.yaml up --build` smoke test (not run locally yet — pattern matches the SQLite path, same image + Postgres service)
Containerize service + unauthenticated /api/health (#9)
All checks were successful
PR / Lint (pull_request) Successful in 21s
PR / Typecheck (pull_request) Successful in 23s
PR / Build (pull_request) Successful in 58s
PR / Test (sqlite) (pull_request) Successful in 44s
PR / Test (postgres) (pull_request) Successful in 47s
ee925c9502
Multi-stage Dockerfile produces a self-contained Next.js standalone
image, running as the non-root `node` user. Migrations apply on
startup via the existing instrumentation.ts hook — for SQLite the
data directory and DB file are created on first launch under the
volume mount.

Stack choices that pulled the design:

- node:22-slim over node:22-alpine. @libsql ships a prebuilt
  linux-x64-musl binding that fails to load against alpine's musl
  with "fcntl64: symbol not found". Slim is ~30MB larger but matches
  the glibc the prebuilt was built against. Reversible: switch back
  to alpine if libsql ever ships a musl prebuilt matching alpine's
  ABI, or if we replace libsql with a pure-JS SQLite.
- output: "standalone" in next.config.mjs. Emits a minimal server
  bundle so the runtime image carries only the deps it actually
  needs at request time (still pulls in @libsql/pg via
  serverExternalPackages, but skips dev/build-only deps).
- DATABASE_URL=sqlite:./data/carol.db baked into the image as the
  default. Zero-config self-host: `docker run -p 3000:3000 -v
  carol-data:/app/data carol` brings the service up against a fresh
  SQLite file. Easily overridden at runtime.
- Two compose files (compose.sqlite.yaml, compose.postgres.yaml) so
  either deployment shape is one command. Postgres compose uses
  pg_isready healthcheck + depends_on condition so the service waits
  for the DB.

/api/health returns {status, version, db} JSON. Status is 200 when
the DB is responsive (SELECT 1 within 2s), 503 with status: degraded
+ db_error when it isn't. Version is taken from package.json via a
JSON import so it bakes into the bundle at build time without an
extra ENV passthrough.

Verified locally with podman: the image builds, boots, applies
migrations, creates /app/data/carol.db as the `node` user, and
returns 200 from /api/health.

Note on the spec: the ticket also asks that any non-health endpoint
return 401 unauthenticated. That gate is #10's responsibility (auth
middleware); without it, the placeholder `/` still returns 200 today.
The compose examples and Dockerfile are unaffected when #10 lands —
only the route guard changes.

Closes #9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 27daa98ef8 into main 2026-06-13 13:44:17 +00:00
Sign in to join this conversation.
No description provided.