A small Kanban-style task board: a C++20 REST backend (cpp-httplib + nlohmann/json) and a vanilla JS frontend served by nginx, wired together with Docker Compose and GitHub Actions.
┌────────────┐ /api/* ┌──────────────────┐
│ frontend │ ──────────▶ │ backend (C++20) │
│ nginx:80 │ │ httplib :8080 │
└────────────┘ └──────────────────┘
:3000 on host
docker compose up --buildOpen http://localhost:3000. The API is proxied at http://localhost:3000/api
and also exposed directly on http://localhost:8080/api.
The backend image runs the full test suite during docker build, so a red test
means no image.
cmake -S backend -B backend/build -DCMAKE_BUILD_TYPE=Release
cmake --build backend/build --parallel
ctest --test-dir backend/build --output-on-failure
PORT=8080 STATIC_DIR=frontend ./backend/build/taskboard_serverWith STATIC_DIR set, the backend serves the frontend itself, so nginx is
optional for local development.
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Liveness probe + version |
| GET | /api/stats |
Counts per status |
| GET | /api/tasks[?status=done] |
List tasks, optional status filter |
| POST | /api/tasks |
Create {title, description?, status?} |
| GET | /api/tasks/{id} |
Fetch one task |
| PUT | /api/tasks/{id} |
Partial update of title/description/status |
| DELETE | /api/tasks/{id} |
Remove task (204) |
| DELETE | /api/tasks?status=done |
Bulk remove by status, returns {removed} |
Statuses: todo, in_progress, done. Errors return {"error": "..."} with
400 or 404.
curl -X POST localhost:8080/api/tasks -H 'Content-Type: application/json' \
-d '{"title":"Write README"}'backend/
include/taskboard/ public headers (task, task_store, api)
src/ implementation + main.cpp
tests/ GoogleTest unit + HTTP API tests
Dockerfile multi-stage build, tests run in build stage
frontend/
index.html, app.js, styles.css, nginx.conf, Dockerfile
.github/workflows/
ci.yml gcc + clang build/test, frontend check, compose smoke test
cd.yml publish images to GHCR on main / v* tags
docker-compose.yml
task_test.cpp– status parsing, JSON serialisation, patch validationtask_store_test.cpp– CRUD, filtering, stats, thread-safetyapi_test.cpp– spins up the real HTTP server on a random port and exercises every route
- CI runs on every push and PR to
main/develop: builds with gcc and clang, runsctest, checks the frontend JS, then builds the compose stack and hits the API through nginx. - CD runs on pushes to
mainandv*tags and publishesghcr.io/dkrmerve/cpp-taskboard-backendandghcr.io/dkrmerve/cpp-taskboard-frontend.
| Variable | Default | Used by | Meaning |
|---|---|---|---|
PORT |
8080 |
backend | HTTP listen port |
HOST |
0.0.0.0 |
backend | Bind address (127.0.0.1 to keep it local-only) |
STATIC_DIR |
(unset) | backend | If set, serves that folder at / (frontend without nginx) |
| Host port | Service | Notes |
|---|---|---|
3000 |
frontend | nginx; /api/* is proxied to backend |
8080 |
backend | Direct API access, handy for curl/Postman |
docker compose up --build -d # start in background
docker compose logs -f backend # request log: "POST /api/tasks -> 201"
docker compose ps # both services should say (healthy)
docker compose down # stop; add -v to drop volumes (none today)- Frontend loads but shows a red dot – backend is down or not yet healthy.
docker compose logs backendandcurl localhost:8080/api/health. - Port already in use – change the host side of the mapping in
docker-compose.yml("3001:80"), the container side stays as is. - Container reports unhealthy on Alpine/nginx – healthchecks must use
127.0.0.1, notlocalhost(resolves to IPv6 first, nginx listens on IPv4). docker buildslow the first time – FetchContent downloads cpp-httplib, nlohmann/json and GoogleTest; later builds hit the layer cache.- Data disappears on restart – expected, the store is in-memory (see below).
| Case | Behaviour | Covered by test |
|---|---|---|
| Empty / whitespace-only title | 400 title is required, nothing stored |
CreateTrimsTitleAndRejectsEmpty, CreateValidatesInput |
| Title padded with spaces | Trimmed before storing | CreateTrimsTitleAndRejectsEmpty |
| Update that would blank the title | 400, original task untouched | UpdateRejectsEmptyTitleAndKeepsOriginal |
| Empty body / malformed JSON / wrong field type | 400 with a specific error message | CreateValidatesInput, ParsePatch.RejectsWrongTypes |
Unknown status value ("DONE", "in-progress") |
400, only todo, in_progress, done accepted |
Status.RejectsUnknownStrings |
Unknown ?status= filter |
400 instead of silently returning nothing | ListFilterAndStats |
| Non-existent id on GET/PUT/DELETE | 404 | GetByIdAnd404, UpdateChangesStatus, DeleteRemovesTask |
Non-numeric id (/api/tasks/abc) |
Route does not match → 404 | GetByIdAnd404 |
| Deleting the same task twice | Second call returns 404 | DeleteRemovesTask |
| Concurrent creates from many threads | Mutex-guarded store, ids stay unique and ordered | ConcurrentCreatesProduceUniqueIds |
Partial update ({"status": "done"} only) |
Other fields keep their values | UpdateAppliesPartialPatch |
| Browser on a different origin | CORS headers + OPTIONS preflight → 204 |
CorsHeadersArePresent |
Known limits, by design for this version: ids are 64-bit and never reused;
there is no maximum title length on the API side (the UI caps at 120/500 chars);
created_at is second-precision UTC.
The stack is a complete, tested demo. Before exposing it to real users:
- Persistence – the store is in-memory, so a restart wipes every task. Swap
TaskStorefor SQLite/PostgreSQL behind the same interface (the API and tests are already decoupled from the storage). - Authentication / authorisation – every endpoint is public. Put the API
behind a reverse proxy with auth, or add token checks in
register_routes. - Lock down CORS –
Access-Control-Allow-Origin: *is for development. Set it to the real frontend origin. - TLS – terminate HTTPS at nginx or a load balancer; the backend speaks plain HTTP.
- Request limits – set
server.set_payload_max_length(...), read/write timeouts and a title length cap to avoid abuse. - Graceful shutdown – handle
SIGTERMand callserver.stop()so rolling deploys do not cut requests mid-flight. - Observability – switch the stdout logger to structured JSON logs, add a
/metricsendpoint (Prometheus) and a request-id header. - Resource limits – add
deploy.resources.limits(or Kubernetes requests/limits) and a non-unless-stoppedrestart policy suited to the orchestrator. - Image hygiene – pin base images by digest, run Trivy/Grype in CI, and
tag releases (
v1.0.0) so CD publishes immutable versions. - Secrets / config – keep environment in a secrets manager, never in the compose file committed to git.
- Backups & migrations – once persistence exists, script schema migrations and automated backups before the first real deploy.
main (production) ← develop (integration) ← feature/*, fix/*.
See CONTRIBUTING.md.
MIT