A RESTful Book Catalog API built with Django and Django REST Framework, containerized with Docker, deployed to Kubernetes via Helm, and automated end-to-end with GitHub Actions. Built as the capstone project for CCT Dublin's Diploma in DevOps.
The API manages a catalog of books with full CRUD support. Each book stores:
- Title
- Author
- ISBN (validated as ISBN-10 or ISBN-13, unique per book)
- Published date (cannot be in the future)
Tech stack:
| Layer | Technology |
|---|---|
| API | Django 4.2 (LTS) + Django REST Framework |
| Database | PostgreSQL 16 |
| Containerization | Docker + docker-compose |
| Orchestration | Kubernetes (kind, locally) |
| Packaging | Helm chart |
| Deployment model | GitOps via ArgoCD |
| CI/CD | GitHub Actions |
| Registry | GitHub Container Registry (GHCR) |
Base URL locally: http://localhost:8000/api/ (via docker-compose) or http://bookcatalog.local/api/ (via the Kubernetes Ingress).
Create a book
curl -X POST http://localhost:8000/api/books/ \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884","published_date":"2008-08-01"}'{"id":1,"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884","published_date":"2008-08-01","created_at":"2026-07-27T05:32:09.569357Z","updated_at":"2026-07-27T05:32:09.569370Z"}List books (paginated, 10 per page)
curl http://localhost:8000/api/books/{"count":1,"next":null,"previous":null,"results":[{"id":1,"title":"Clean Code", "...": "..."}]}Retrieve a single book
curl http://localhost:8000/api/books/1/Update a book (full)
curl -X PUT http://localhost:8000/api/books/1/ \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code (2nd Ed)","author":"Robert C. Martin","isbn":"9780132350884","published_date":"2008-08-01"}'Partially update a book
curl -X PATCH http://localhost:8000/api/books/1/ -H "Content-Type: application/json" -d '{"author":"Uncle Bob"}'Delete a book
curl -X DELETE http://localhost:8000/api/books/1/Books can also be searched and ordered:
curl "http://localhost:8000/api/books/?search=clean&ordering=-published_date"git clone git@github.com:BarraHarrison/CCT-DevOps-Capstone.git
cd CCT-DevOps-Capstone
cp .env.example .env # adjust values if needed
docker compose up --buildThis starts a PostgreSQL container and the Django app (via Gunicorn), running migrations automatically on startup. The API is available at http://localhost:8000/api/books/.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
export DB_ENGINE=sqlite DJANGO_SECRET_KEY=dev-key DJANGO_DEBUG=True
python manage.py migrate
python manage.py runserverUsing DB_ENGINE=sqlite avoids needing a local PostgreSQL install; unset it (or set DB_ENGINE=postgres with DB_HOST/DB_USER/etc.) to run against real Postgres.
export DB_ENGINE=sqlite DJANGO_SECRET_KEY=test-key DJANGO_DEBUG=True
python manage.py test books10 unit tests cover the Book model (creation, ISBN uniqueness) and the full CRUD API (list, create, validation failures for bad ISBNs/future dates, retrieve, update, partial update, delete).
Defined in .github/workflows/ci-cd.yml, triggered on every push to main (and on pull requests, for the test stage only):
test— runs on a GitHub-hosted runner. Installs dependencies fromrequirements.txtand runs the full Django test suite against an in-memory SQLite database.build-and-push— runs only on pushes tomain, after tests pass. Builds the Docker image from theDockerfileand pushes it to GitHub Container Registry, tagged both:latestand with the commit SHA.deploy-application— runs only on pushes tomain, after the image is pushed. Updatesimage.taginenvironments/production/values.yamlto the new commit SHA usingfjogeleit/yaml-update-action, and commits that change back tomainwith[skip ci](so it doesn't re-trigger the pipeline).
Deployment is GitOps-driven, not push-driven. Earlier in this project, the pipeline deployed directly by running helm upgrade --install from a self-hosted GitHub Actions runner (since the target kind cluster is local and unreachable from GitHub-hosted runners). This has been replaced with ArgoCD, which runs inside the cluster and continuously watches this repository. Now the pipeline's only job is to update the image tag in Git — ArgoCD detects that change and deploys it automatically. This means the deploy-application job runs on a normal GitHub-hosted runner, and no self-hosted runner or direct cluster access from CI is required at all.
Why GHCR over Docker Hub? GHCR integrates directly with GitHub's built-in GITHUB_TOKEN for authentication — no extra secrets to manage — and packages pushed from a public repository are public by default, which simplifies the cluster's image pulls.
brew install kind kubectl helmkind create cluster --name bookcatalog --config kind-config.yamlkind-config.yaml maps ports 80/443 to localhost so the Ingress controller is reachable directly.
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=120secho "127.0.0.1 bookcatalog.local" | sudo tee -a /etc/hostsIn normal day-to-day use, you don't run helm install/helm upgrade manually — ArgoCD (set up below) watches this repository and deploys automatically whenever chart/bookcatalog/ or environments/production/values.yaml changes. The commands below are only needed to bootstrap the app once, before ArgoCD exists yet, or for local debugging outside the GitOps flow:
helm install bookcatalog ./chart/bookcatalog
# or, to upgrade an existing release:
helm upgrade --install bookcatalog ./chart/bookcatalogThen visit http://bookcatalog.local/api/books/.
- Deployment — runs the Django app (2 replicas by default) via Gunicorn. Includes an init container that waits for PostgreSQL to accept connections before starting, and readiness/liveness probes against
/api/books/. - Service — a
ClusterIPService exposing the app on port 80, routed to port 8000 in the pods. Selects pods by anapp.kubernetes.io/component: apilabel specifically, so it never accidentally routes traffic to the bundled PostgreSQL pod (a bug encountered and fixed during development — see the report). - Ingress — routes
bookcatalog.localtraffic to the Service via the NGINX ingress controller. - ConfigMap — non-sensitive environment variables (
DJANGO_DEBUG,DJANGO_ALLOWED_HOSTS,DB_HOST,DB_NAME, etc.). - Secret — sensitive values (
DJANGO_SECRET_KEY,DB_PASSWORD). - Bundled PostgreSQL (
postgres.yaml) — a self-contained Postgres Deployment, Service, and PersistentVolumeClaim, so the chart deploys a fully working stack with no external database dependency. In a production setting this would typically be swapped for an external managed database.
Validate the chart at any time with:
helm lint ./chart/bookcatalog
helm template ./chart/bookcatalogArgoCD runs inside the same kind cluster and continuously syncs the cluster's state to match this repository — pushing a change to main is enough to deploy it, with no CI job needing direct cluster access.
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
kubectl create namespace argocd
helm -n argocd install argocd argo/argo-cd -f ./argocd/values.yamlargocd/values.yaml configures ArgoCD to run without TLS (no certificate available locally) and exposes its UI under /argocd on the same NGINX ingress controller already used by the app.
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -dVisit http://bookcatalog.local/argocd (or http://localhost/argocd), and log in as admin with that password.
In ArgoCD: Settings → Repositories → Connect Repo → VIA HTTPS, and provide a GitHub fine-grained personal access token (Contents: Read-only, scoped to just this repository) as the password.
Applications → New App, with:
- General: name
bookcatalog, projectdefault, sync policy Automatic (with Prune + Self Heal enabled) - Source: this repository, revision
main, pathchart/bookcatalog - Destination: the same (in-cluster) destination, namespace
default - Helm → Values Files:
../../environments/production/values.yaml
That last path is relative to the chart directory (chart/bookcatalog), not the repo root — two levels up (../../) to reach the repo root, then into environments/production/values.yaml. Getting this wrong (e.g. leaving it as the default values.yaml) silently makes ArgoCD use the chart's own default values instead of the production overlay, with no visible error — worth double-checking directly against the live Application object if the deployed image tag doesn't match what's expected:
kubectl -n argocd get application bookcatalog -o jsonpath='{.spec.source.helm.valueFiles}'- A push to
maintriggers the CI/CD pipeline (test → build & push image → updateenvironments/production/values.yamlwith the new image tag, committed bygithub-actions[bot]). - ArgoCD detects the new commit on its own (polling this repo) and starts a sync.
- ArgoCD renders the Helm chart with the updated
values.yamland applies it to the cluster — new pods roll out with the new image, automatically.
CCT-DevOps-Capstone/
├── bookcatalog/ # Django project settings, root URLs
├── books/ # Django app: model, serializer, views, tests
├── chart/bookcatalog/ # Helm chart
├── argocd/values.yaml # ArgoCD's own Helm install values
├── environments/production/values.yaml # Image tag override ArgoCD deploys from
├── .github/workflows/ # CI/CD pipeline
├── Dockerfile
├── docker-compose.yml
├── kind-config.yaml
├── requirements.txt
└── manage.py