A production-ready task management REST API containerised with Docker.
Stack: Node.js 20 · Express · PostgreSQL 16 · Redis 7 · Nginx.
Client → Nginx (:80) → API (:3000) → PostgreSQL (:5432)
→ Redis (:6379)
Four services orchestrated by Docker Compose, images built and scanned automatically by GitHub Actions, pushed to GHCR.
# 1. Clone
git clone https://github.com/<your-org>/taskflow-docker.git
cd taskflow-docker
# 2. Configure environment
cp .env.example .env # edit values if needed
# 3. Start all services
docker compose up --build -d
# 4. Check health
curl http://localhost/healthExpected response:
{"status":"healthy","timestamp":"...","version":"unknown","database":"connected","cache":"connected"}Any developer can clone the repo, run
docker compose up, and have the full stack running in under 30 seconds — no local Node.js, PostgreSQL, or Redis installation required.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check — returns DB + Redis status |
GET |
/api/tasks?limit=20&offset=0 |
List tasks — paginated, Redis-cached per page (TTL 60 s) |
GET |
/api/tasks/:id |
Get a single task |
POST |
/api/tasks |
Create a task |
PUT |
/api/tasks/:id |
Update a task |
DELETE |
/api/tasks/:id |
Delete a task |
{
"id": 1,
"title": "Write documentation",
"description": "Complete the README",
"status": "pending",
"created_at": "2026-04-16T12:00:00.000Z",
"updated_at": "2026-04-16T12:00:00.000Z"
}status accepted values: pending · in_progress · done
All routes validate inputs before touching the database:
| Rule | HTTP response |
|---|---|
:id is not a positive integer |
400 "id" must be a positive integer |
status is not one of the accepted values |
400 "status" must be one of: pending, in_progress, done |
title missing on POST |
400 "title" is required |
| Task not found | 404 Task not found |
{
"status": "healthy",
"timestamp": "2026-04-16T12:00:00.000Z",
"version": "1.0.0-blue",
"database": "connected",
"cache": "connected"
}HTTP 200 when healthy, 503 when any dependency is down.
version reflects the APP_VERSION environment variable — set per-container in docker-compose.prod.yml to identify which Blue/Green slot is serving the request. Defaults to "unknown" when the variable is not set (e.g. in the base docker-compose.yml).
| Service | Image | Role | Port |
|---|---|---|---|
api |
built from Dockerfile |
Node.js REST API | 3000 (internal) |
db |
postgres:16.8-alpine3.23 |
Persistent task storage | 5432 (internal) |
redis |
redis:7.4-alpine3.23 |
Task list cache (TTL 60 s) | 6379 (internal) |
nginx |
nginx:1.27-alpine3.23 |
Reverse proxy, single public entry point | 80 (public) |
Only Nginx is exposed to the host. All other services communicate on Docker's internal network.
db (healthy) ──┐
├──► api (started) ──► nginx
redis (started)┘
The API container will not start until PostgreSQL passes its healthcheck (pg_isready). This prevents connection errors at boot when the database is still initialising.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30sstart_period: 30s gives PostgreSQL time to restore data from its volume on first boot before the healthcheck starts counting retries.
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lruRedis is capped at 128 MB. When full, it evicts the least-recently-used keys (allkeys-lru). This makes it behave as a bounded cache — it will never crash the host due to unbounded memory growth.
GET /api/tasks supports pagination via ?limit=20&offset=0 query parameters:
| Parameter | Default | Max | Description |
|---|---|---|---|
limit |
20 |
100 |
Number of tasks per page |
offset |
0 |
— | Number of tasks to skip |
Each page is cached independently in Redis under the key tasks:all:<limit>:<offset> with a 60-second TTL. This means ?limit=20&offset=0 and ?limit=20&offset=20 are cached separately.
Any write operation (POST, PUT, DELETE) invalidates all paginated cache entries at once by scanning and deleting every key matching tasks:all:*.
Example response:
{
"data": [{ "id": 1, "title": "...", "status": "pending", "..." : "..." }],
"total": 42,
"limit": 20,
"offset": 0
}Nginx is the sole public entry point on port 80. It proxies all traffic to the API and strips internal routing from public view.
upstream api_backend {
server api:3000; # Docker internal hostname
}The /health location has access_log off to avoid polluting logs with automated healthcheck probes.
PostgreSQL data is stored in a named Docker volume (pgdata). It survives docker compose down and is only removed with:
docker compose down -v # ⚠ deletes all dataCopy .env.example to .env before starting the stack. Docker Compose builds the full connection URLs automatically.
| Variable | Example | Description |
|---|---|---|
POSTGRES_USER |
taskuser |
PostgreSQL username |
POSTGRES_PASSWORD |
taskpassword |
PostgreSQL password |
POSTGRES_DB |
taskdb |
PostgreSQL database name |
DATABASE_URL |
(built by Compose) | Full Postgres connection string — auto-set by Docker Compose |
REDIS_URL |
(built by Compose) | Redis connection string — auto-set by Docker Compose |
NODE_ENV |
production |
Runtime environment |
The API runs as a dedicated non-root user (appuser:appgroup) inside the container — no process has root privileges.
Verify at any time with:
docker compose exec api whoami
# Expected output: appuserThe user is created in the Dockerfile production stage and ownership is set via --chown on every COPY instruction:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
USER appuserAll base images are pinned to a specific runtime + Alpine version to guarantee reproducible builds and prevent silent CVE introduction via tag mutation.
| Service | Image | Why pinned |
|---|---|---|
| API (builder + runtime) | node:20.19-alpine3.23 |
LTS runtime, fixed OS packages |
| PostgreSQL | postgres:16.8-alpine3.23 |
Known-good patch release |
| Redis | redis:7.4-alpine3.23 |
Known-good patch release |
| Nginx | nginx:1.27-alpine3.23 |
Stable branch, fixed OS packages |
Using :latest or partial tags like postgres:16-alpine means the image silently changes on every pull. Pinning the full version makes every build deterministic across dev, CI, and production.
.envis listed in both.gitignoreand.dockerignore— it is never committed or baked into the image.- Credentials are injected at runtime via environment variables defined in
docker-compose.yml. - The
Dockerfilecontains no credentials, tokens, or environment-specific values.
Scan run against ghcr.io/kjuliek/taskflow-docker:latest in CI with --severity CRITICAL,HIGH:
| Scope | Total CVEs | CRITICAL/HIGH | Pipeline impact |
|---|---|---|---|
Alpine OS packages (alpine 3.23.x) |
0 | 0 | ✅ passes |
Application node_modules (/app/node_modules/) |
0 | 0 | ✅ passes |
npm internal packages (/usr/local/lib/node_modules/npm/) |
— | excluded via skip-dirs |
✅ passes |
The Dockerfile runs apk upgrade --no-cache in the production stage to pull the latest security patches from the Alpine repos at build time. Node.js base images are built at a fixed point in time — their OS packages become stale as CVEs are patched upstream. apk upgrade bridges this gap without waiting for the base image maintainer to publish a new tag.
The npm internal packages at /usr/local/lib/node_modules/npm/ are excluded via skip-dirs because they belong to Node.js's bundled npm CLI — not our application code — and are not reachable at runtime.
| Pin | Alpine version in image | OS CVEs | CRITICAL/HIGH | Status |
|---|---|---|---|---|
node:20-alpine (unpinned) |
3.23.x | 0 | 0 | ✅ (local only, pre-pinning) |
node:20.19-alpine3.21 |
3.21.5 | 11 | TBD by CI | bumped |
node:20.19-alpine3.22 |
3.22.2 | 11 | 2 CRITICAL (OpenSSL CVE-2025-15467) | bumped |
node:20.19-alpine3.23 |
3.23.2 | 11 | 2 CRITICAL (same — packages frozen at image build time) | + apk upgrade |
node:20.19-alpine3.23 + apk upgrade |
3.23.2 (patched) | 0 | 0 | ✅ current |
Lesson: pinning a version guarantees reproducibility but also freezes OS packages at a specific state. The Trivy step in CI catches any CRITICAL/HIGH CVEs introduced by a pin and blocks the push — forcing an explicit version bump as the resolution.
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/aquasecurity/trivy:latest image --severity CRITICAL,HIGH taskflow-api:latestIf CVEs are found:
- Update the base image Alpine pin (e.g.
alpine3.22→alpine3.23) - Run
npm audit fixto patch vulnerable Node.js dependencies - Rebuild and scan again — never push with unresolved CRITICAL CVEs
The pipeline is defined in .github/workflows/ci.yml and runs on every push and pull request to main.
push / PR to main
│
▼
┌──────────────────┐
│ Job 1: test │ always runs (push + PR)
│ ─────────────── │
│ npm ci │
│ npm run lint │
│ npm run test │
│ (with coverage) │
└────────┬─────────┘
│ needs (must pass)
▼
┌──────────────────────────┐
│ Job 2: build-and-push │ push to main only
│ ────────────────────── │
│ docker login GHCR │
│ docker buildx build │
│ → push to GHCR │
│ trivy scan (exit 1 on │
│ CRITICAL CVE) │
└──────────────────────────┘
| Step | Command | Purpose |
|---|---|---|
| Install | npm ci |
Reproducible install from lock file |
| Lint | npm run lint |
ESLint checks src/ for errors |
| Test + coverage | npm run test:coverage |
Runs unit tests and prints coverage via Node.js built-in --experimental-test-coverage |
| Step | Tool | Purpose |
|---|---|---|
| Login | docker/login-action@v3 |
Authenticates to GHCR using GITHUB_TOKEN |
| Buildx | docker/setup-buildx-action@v3 |
Enables BuildKit and layer cache |
| Metadata | docker/metadata-action@v5 |
Generates two tags: sha-<short> (immutable) and latest |
| Build & push | docker/build-push-action@v5 |
Builds with cache-from/to: type=gha to reuse layers between runs |
| Scan | aquasecurity/trivy-action@master |
Scans the pushed image — exit-code: 1 blocks the pipeline on CRITICAL |
Every push to main produces two tags:
ghcr.io/<owner>/taskflow-docker:sha-abc1234 ← immutable, tied to a specific commit
ghcr.io/<owner>/taskflow-docker:latest ← updated on every push to main
Use the SHA tag in production deployments for reproducibility.
GITHUB_TOKEN is automatically available in every workflow. The build-and-push job declares:
permissions:
contents: read
packages: write # required to push to ghcr.ioVerify in Settings → Actions → General that "Read and write permissions" is enabled.
cache-from: type=gha
cache-to: type=gha,mode=maxDocker layer cache is stored in GitHub Actions cache. On subsequent pushes, unchanged layers (e.g. node_modules) are restored instead of rebuilt, significantly reducing build time.
docker build -t taskflow-api .docker images taskflow-apiResult: ~49 MB content size (well under the 100 MB target).
| What stays out | Why |
|---|---|
devDependencies (eslint, nodemon) |
Pruned in the builder stage before copy |
| Test files | Excluded via .dockerignore |
| Build toolchain (npm cache, etc.) | Builder stage is discarded entirely |
| Git history, docs, CI config | Excluded via .dockerignore |
Stage 1 — builder node:20.19-alpine3.23 + all deps + npm prune ← discarded
Stage 2 — production node:20.19-alpine3.23 + prod deps + src only ← pushed to GHCR
npm install
cp .env.example .env
# Add POSTGRES_HOST, REDIS_HOST etc. to .env for local overrides
npm run devThe connection logic in src/db.js accepts either a DATABASE_URL / REDIS_URL (Docker Compose) or individual POSTGRES_* / REDIS_* variables (local development without Docker).
npm run lintESLint checks src/ against eslint:recommended rules. Configuration in .eslintrc.json.
npm test # unit tests only
npm run test:coverage # unit tests + coverage report (stdout)Unit tests use Node.js's built-in node:test runner — no extra test framework dependency.
| Suite | Cases |
|---|---|
parseId |
valid integer, non-numeric string, zero, negative, empty string |
status validation |
all valid values, unknown value, empty string, case-sensitivity |
POST payload validation |
title only, all fields, missing title, invalid status |
health response shape |
all keys present including version, fallback to "unknown", unhealthy state |
Cross-platform note: the test script uses an explicit file path (
tests/tasks.test.js) rather than a directory or glob.
On Node.js v22 (Windows),node --test tests/fails because the directory resolves as a module entry point.
On Node.js v20 (Linux/CI), quoted globs are not shell-expanded. An explicit path works on all versions and platforms.
taskflow-docker/
├── .github/workflows/
│ └── ci.yml # 2-job CI/CD pipeline (test → build+scan+push)
├── src/
│ ├── server.js # Express entrypoint + /health endpoint
│ ├── routes/
│ │ └── tasks.js # CRUD routes with Redis cache invalidation
│ └── db.js # pg pool + Redis client + schema init on boot
├── tests/
│ └── tasks.test.js # Unit tests (node:test, no extra deps)
├── nginx/
│ ├── default.conf # Nginx reverse proxy — upstream api_backend
│ └── blue-green.conf # Blue/Green routing — active + standby upstreams
├── Dockerfile # Multi-stage: builder (prune) + production (non-root)
├── .dockerignore # Excludes node_modules, tests, docs, .env, CI config
├── .eslintrc.json # ESLint 8 config — eslint:recommended + node env
├── docker-compose.yml # 4-service stack with pinned images and healthchecks
├── docker-compose.prod.yml # Blue/Green stack (api-blue + api-green + nginx + db + redis)
├── .env.example # 3 variables to set — Compose builds the URLs
├── package.json
└── README.md
In production, Blue/Green uses a load balancer (AWS ALB, Traefik…). Here it is simulated with Docker Compose and Nginx to demonstrate the principle.
Client → Nginx (:80) → active_backend → api-blue:3000 (live traffic)
→ standby_backend → api-green:3000 (validation only, via /test-standby/)
Both containers share the same PostgreSQL database and Redis instance — no data migration required during the switch.
| File | Role |
|---|---|
docker-compose.prod.yml |
Defines api-blue, api-green, shared db, redis, nginx |
nginx/blue-green.conf |
Routes public traffic to active_backend, exposes standby via /test-standby/ |
db (healthy) ──┐
├──► api-blue (healthy) ──┐
redis (started)┘ ├──► nginx
├──► api-green (healthy) ──┘
└──(shared)
Both api-blue and api-green have a healthcheck (wget /health, every 15 s). Nginx only starts once both slots pass their healthcheck — preventing Nginx from proxying to a container that isn't ready yet.
cp .env.example .env # fill in credentials if not done
docker compose -f docker-compose.prod.yml up -d# 1. Verify Green is healthy before touching live traffic
curl http://localhost/test-standby/health
# Expected: {"status":"healthy","version":"2.0.0-green",...}
# 2. Edit nginx/blue-green.conf — change active_backend to point to Green:
# server api-green:3000; ← was api-blue:3000
# 3. Reload Nginx — zero downtime, no restart needed
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload
# 4. Confirm live traffic now hits Green
curl http://localhost/health
# Expected: {"status":"healthy","version":"2.0.0-green",...}
# 5. Rollback to Blue if needed: reverse step 2 and reload againnginx -s reload sends a HUP signal to the Nginx master process. It spawns new worker processes with the updated config while the old workers finish serving in-flight requests before exiting. No connection is dropped.
The /health endpoint exposes APP_VERSION from the environment:
{"status":"healthy","version":"2.0.0-green","database":"connected","cache":"connected"}This makes it possible to confirm which slot is active at any time without inspecting container names.
- Step 1 — REST API with CRUD endpoints and
/health - Step 2 — Multi-stage Dockerfile (target < 100 MB)
- Step 3 — Docker Compose with health checks
- Step 4 — Trivy vulnerability scan (0 CRITICAL CVEs)
- Step 5 — GitHub Actions: lint → test → build → scan → push to GHCR
- Step 6 — Blue/Green deployment simulation