A simple CRUD API for managing to-do tasks, built with TypeScript, Express, and Node.js. Data persists in Postgres, running in Docker with a volume. SQLite remains available as a no-Docker-required fallback for local dev.
The app supports two interchangeable storage backends, chosen at startup by
the DB_DRIVER env var:
postgres(default when using Docker) — a real Postgres database, running in a container with a persistent volume. This is the primary storage for this project.sqlite— a local file (tasks.db), zero setup, useful for quick local dev without Docker running.
Both implement the exact same TaskRepository interface
(src/repositories/task.repository.ts), so the service layer
(src/services/tasks.service.ts) and controller/routes never change based
on which one is active — swapping storage is a one-file decision, made in
src/repositories/index.ts.
This project originally queried SQLite directly inside the controller —
there was no repository interface or service layer to "swap," despite the
app already persisting data to disk. As part of adding Postgres, I first
refactored that: extracted the TaskRepository interface, moved the
existing SQLite logic behind it (sqlite-task.repository.ts), and added a
thin service layer so the controller only calls service functions and
never touches SQL directly.
Only after that existed did I add postgres-task.repository.ts and wire
it in via src/repositories/index.ts. That factory file is the entire
"swap" — tasks.controller.ts, tasks.routes.ts, and tasks.service.ts
are identical regardless of which backend is running.
cp .env.example .env
docker compose up --buildThis starts two containers:
db— Postgres 16, with a named volume (pgdata) so data survives container restarts, anddocker/init.sqlcreating thetaskstable on first boot.app— this API, built from theDockerfile, waiting fordb's healthcheck before it starts.
The API is reachable at http://localhost:6767, same as running it
without Docker.
To stop everything but keep the data: docker compose down
To stop everything and delete the data (fresh start): docker compose down -v
Set DB_DRIVER=sqlite in .env (or leave DB_DRIVER unset — it's the
default), then:
npm install
npm run devServer runs at http://localhost:6767. The database file (tasks.db) and its table are created automatically on first run, seeded with 3 example tasks. tasks.db is git-ignored, so every clone starts fresh with the seeded data.
- Started the stack:
docker compose up --build. Confirmed the app logged[db] using postgres repository. - Created a task via
POST /tasks,{"title":"survive a restart"}— came back asid: 4. - Confirmed it via
GET /tasks. - Fully tore down the stack:
docker compose down(no-v, so thepgdatavolume was kept while both containers were deleted). - Brought it back up:
docker compose up --build. Fresh containers, same volume. GET /tasksagain — taskid: 4was still there, unchanged, because it lives in thepgdatavolume rather than in the containers themselves.
| Var | Used by | Purpose |
|---|---|---|
DB_DRIVER |
app | sqlite or postgres — selects the repository |
SQLITE_FILE |
app (sqlite mode) | path to the local SQLite file |
DATABASE_URL |
app (postgres mode) | Postgres connection string. Inside docker compose, the host is db (the service name), not localhost |
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB |
Postgres container | read once, on first boot, to create the user/database |
| Method | Path | Description |
|---|---|---|
| GET | / | API info |
| GET | /health | Health check |
| GET | /tasks | List all tasks |
| GET | /tasks/:id | Get a single task |
| POST | /tasks | Create a task |
| PUT | /tasks/:id | Update a task's title and/or done |
| DELETE | /tasks/:id | Delete a task |
| POST | /tasks/reset | Reset to the 3 seed tasks |
Interactive Swagger UI: http://localhost:6767/docs
curl -i -X POST http://localhost:6767/tasks -H "Content-Type: application/json" -d '{"title":"Buy milk"}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{"id":4,"title":"Buy milk","done":false}
Opened tasks.db via the sqlite3 CLI and ran:
SELECT * FROM tasks WHERE done = 1;Returned one row: 3|Finish assignment|1 - the only task marked done at the time.

