Containerize service + unauthenticated /api/health (#9) #35
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!35
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "9-containerize-service"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #9.
Summary
Dockerfile(deps → builder → runtime), runs as the non-rootnodeuser. Migrations apply on startup via the existinginstrumentation.tshook./api/health— unauthenticated, returns{status, version, db}JSON. 200 when DB is responsive within 2s, 503 +db_errorwhen it isn't.versionis read frompackage.jsonvia a JSON import so it bakes into the bundle at build time.output: "standalone"innext.config.mjs— Next.js emits a self-contained server bundle so the runtime image only carries the deps it needs.compose.sqlite.yaml(self-hoster default, one container + one volume) andcompose.postgres.yaml(with apg_isreadyhealthcheck +depends_ongating). Either is onedocker compose up --buildaway.Stack notes worth flagging
node:22-slimovernode:22-alpine.@libsqlships a prebuiltlinux-x64-muslbinding that fails to load against alpine's musl withfcntl64: 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.DATABASE_URL=sqlite:./data/carol.dbbaked into the image — zero-config self-host. Overridable at runtime; the SQLite parent directory is auto-created on first connect.public/.gitkeepadded because the Next.js standalone Dockerfile pattern copies/app/publicand 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 ~150mscurl -4 http://localhost:3000/api/health→ 200,{"status":"ok","version":"0.0.0","db":"ok"}/app/data/carol.dbcreated on first request, owned bynode:nodepodman exec ... node -e 'fetch(...)'); the-4is just to dodge a podman rootless IPv6 forwarding quirk on the hostOut 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 greenpodman buildfrom clean checkout — succeedspodman runagainst default SQLite —/api/healthreturns 200 with the expected bodynode(uid 1000), not rootdocker compose -f compose.postgres.yaml up --buildsmoke test (not run locally yet — pattern matches the SQLite path, same image + Postgres service)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>