Node.js Todo App - Docker + GHCR
Published Oct 15, 2025
⋅
3 minutes read
This project is a simple but production‑minded Todo application that demonstrates how I containerize a full‑stack app and publish images to GitHub Container Registry (GHCR) with automated CI/CD. The goal is to practice containerization, release flow, and day‑2 operations on a compact, approachable codebase.
- Backend API: Node.js (Express 5),
better-sqlite3,helmet,cors,pino - Data: SQLite file DB (WAL), persisted to a volume
- Frontend: Next.js (App Router), Tailwind + shadcn/ui
- Containers: Separate Dockerfiles for API and Web (standalone Next.js)
- CI/CD: GitHub Actions → build and push images to GHCR with multiple tags

Project goals
- Fast local development with deterministic environments.
- Small, minimal container images, non‑root user, health checks.
- Automated build and publish to GHCR with reliable tagging.
- Clear, minimal code where DevOps patterns are easy to see.
Architecture overview
-
Backend API (Express)
- CRUD on the
todosresource helmet+cors+ JSON body parsingpinoandpino-httpfor structured logsGET /healthzfor health checks- SQLite via
better-sqlite3in WAL mode
- CRUD on the
-
Data layer
- SQLite file at
data/todos.sqlite - Schema ensured at startup;
createdAt/updatedAtas ISO strings - WAL mode for better write/reads and reliability
- SQLite file at
-
Frontend (Next.js)
- App Router, modern UI (Tailwind, shadcn/ui)
- UI calls the API via
NEXT_PUBLIC_API_URL - Standalone build copied into the runtime image
-
Containerization
- Separate Dockerfiles for API and Web
- Multi‑stage with cached npm install
- Non‑root user;
HEALTHCHECKin API image - DB volume at
/app/data
-
CI/CD
- GitHub Actions workflow builds on push and publishes to GHCR
- Tagging:
latest,sha-<SHORT_SHA>,vX.Y.Z(on release tags)

Data model (essentials)
todos(id INTEGER PK AUTOINCREMENT, title TEXT, completed INTEGER, createdAt TEXT, updatedAt TEXT)
Indexes:
- Implicit PK index on
id; queries useORDER BY id DESCfor recency
API surface (concise)
GET /healthz→{ status: "ok" }GET /todos→ listGET /todos/:id→ single itemPOST /todos→{ title, completed }→ 201PUT /todos/:id→{ title?, completed? }→ 200DELETE /todos/:id→ 204
Request lifecycle
- Frontend calls the API using
NEXT_PUBLIC_API_URL(e.g.,http://localhost:3000). - API validates and reads/writes the SQLite file via
better-sqlite3. - API returns JSON; Frontend updates local state.
Operational concerns
- Local dev: fast startup via
npm run devfor both components. - Containers: small images, non‑root runtime, API
HEALTHCHECK. - Persistence: SQLite stored on a host volume (
/app/data). - Config: Frontend points to the API via environment variable.

Observability
- Healthcheck:
GET /healthz(also used by DockerHEALTHCHECK). - Logs: JSON logs via
pino, tunable withLOG_LEVEL. - Error contract: consistent 4xx/5xx JSON responses.
Security and reliability
helmet: baseline security headers.cors: explicit enablement for development.- Non‑root: image runs as a non‑root user.
- Readiness/health:
HEALTHCHECKsurfaces faulty instances quickly.
Performance notes
- SQLite WAL mode for fast and safe writes.
better-sqlite3sync API → simple and fast on this scale.- Simple id‑based ordering; UI computes stats locally.
Trade‑offs and decisions
- SQLite → zero infra dependency and simple setup; single‑process writes.
- Two separate images (API, Web) → clearer deploy ownership boundaries.
- Standalone Next.js runner → smaller runtime image and faster start.
- Explicit
HEALTHCHECK→ better operability and diagnostics.
Running the project
-
Backend (local)
npm install npm run dev # or: npm start curl -s http://localhost:3000/healthz | jq . -
Frontend (local)
cd web npm install PORT=3001 npm run dev # Open http://localhost:3001 -
Docker build
# API docker build -t ghcr.io/<owner>/<repo>-api:local . # Web cd web docker build -t ghcr.io/<owner>/<repo>-web:local . -
Docker run
# API on 3000 (persist DB to ./data) docker run -d --name todo-api \ -p 3000:3000 \ -v "${PWD}/data":/app/data \ ghcr.io/<owner>/<repo>-api:local # Web on 3001 (point UI to API) docker run -d --name todo-web \ -p 3001:3001 \ -e NEXT_PUBLIC_API_URL="http://host.docker.internal:3000" \ ghcr.io/<owner>/<repo>-web:local -
Healthcheck
curl -sI http://localhost:3000/healthz curl -sI http://localhost:3001
CI/CD and releases
- Workflow:
.github/workflows/ci.yml - Triggers:
pushtomain→ build + push GHCR- tags
v*→ version tags (e.g.,v1.2.3) pull_request→ build only
- Published images:
ghcr.io/<owner>/<repo>-api:{latest,sha-<SHORT_SHA>,vX.Y.Z}ghcr.io/<owner>/<repo>-web:{latest,sha-<SHORT_SHA>,vX.Y.Z}
- Auth: uses GitHub‑provided
GITHUB_TOKEN
Edge cases
- Port conflicts (3000/3001) → free the ports:
lsof -ti:3000,3001 | xargs -r kill -9 - Next dev port →
PORT=3001is required for Web dev. - Docker daemon unavailable → start Docker Desktop.
Future work
- E2E tests in CI (e.g., Playwright for UI + Supertest for API)
- Docker Compose for joint dev startup
- Production log forwarding (e.g., Loki/ELK)
- Simple rate‑limiting and request id tracing in pino
Notes
The repository is intentionally small, but the patterns (containerization, health checks, logging, CI/CD, env configuration) mirror how I approach larger systems. If you want, I can extend this page with screenshots, a live demo link, or deeper cost/perf details.