Case study
Containerized Web App on Kubernetes with PostgreSQL
A full-stack Kanban app packaged as one container image and deployed to Kubernetes with Kustomize: app Deployment, PostgreSQL 18.6 StatefulSet, persistent storage and health probes.
- Result
- Full-stack app on Kubernetes: one image, PostgreSQL StatefulSet with persistent storage, one-command deploy
- Role
- Built
- Setting
- Personal open-source project · 2026
- Key stack
- Kubernetes · Kustomize · Docker · PostgreSQL 18.6
Personal project. Source code, Dockerfile and manifests are public.View source on GitHub
01Problem
A web application is only useful once it runs somewhere reliably. The goal was to take a Kanban board from a local prototype to a Kubernetes deployment: login, data that survives pod restarts, and a clean path from source code to a running cluster.
02Context
The app is a single-board Kanban with five renameable columns and drag-and-drop cards. The frontend is Next.js 16, exported as static files. The backend is FastAPI with SQLAlchemy. Local development uses SQLite; Kubernetes uses PostgreSQL 18.6. The source code, Dockerfile and manifests are public on GitHub.
03Architecture
How it works
- Build: a multi-stage Dockerfile builds the Next.js frontend as a static export on node:24-alpine, then copies it into a python:3.14-slim image with the FastAPI backend. One image serves both the UI and the /api routes, and it runs as a non-root user (UID 10001). The image is pushed to Docker Hub, with a buildx option for amd64 + arm64.
- Entry point: a NodePort Service exposes the app on port 30080 of every node and forwards to container port 8000.
- Startup order: an init container using the postgres:18.6 image runs pg_isready in a loop, so the app does not start until the database accepts connections. On first start the app creates its tables and seeds a demo board.
- Data: PostgreSQL runs as a single-replica StatefulSet behind a headless Service. Its volumeClaimTemplate requests a 1 Gi PersistentVolumeClaim from the default StorageClass, so board data survives pod restarts. PostgreSQL 18 images keep data under /var/lib/postgresql/18/docker, so the volume is mounted at /var/lib/postgresql.
- Configuration: one Secret provides the database user, password and name, the app login and the session-signing key to both workloads through envFrom. The app builds its DATABASE_URL from those values.
- Health: an HTTP readiness and liveness probe on /api/health for the app, and pg_isready exec probes for PostgreSQL.
- Delivery: Kustomize ties the namespace, Secret, PostgreSQL and app manifests together and sets the image tag, so the whole stack deploys with kubectl apply -k k8s/.
04Engineering approach
- Packaged the frontend and backend into one image with a multi-stage build, so the cluster runs one application workload with no separate web server.
- Kept configuration in environment variables (DATABASE_URL, admin credentials, SESSION_SECRET), so the same image runs with SQLite locally and PostgreSQL in the cluster.
- Ran PostgreSQL as a StatefulSet with a volumeClaimTemplate instead of a Deployment, which gives it a stable identity and its own persistent volume.
- Added an init container and readiness/liveness probes, so the app starts in the right order and Kubernetes only sends traffic to healthy pods.
- Set CPU and memory requests plus memory limits on both workloads, so the scheduler can place them and a runaway process cannot starve the node.
- Added a Docker Compose file with the same PostgreSQL version as the cluster, to test the production database path locally before deploying.
- Organized the manifests with Kustomize, so the image name and tag are changed in one place.
05Security
- The container runs as a non-root user (UID 10001) on a slim base image.
- Credentials and the session key come from a Kubernetes Secret, not from the image. The committed Secret holds placeholder values only, and the README says to change them before any real deployment.
- The API requires a signed session cookie on every board endpoint, and the login check uses a constant-time comparison.
- PostgreSQL is reachable only inside the cluster through its headless Service. It is not exposed through a NodePort.
- The build context excludes local databases, virtual environments and the manifests themselves (.dockerignore).
06Automation
- kubectl apply -k k8s/ creates the namespace, Secret, database and app in one command, and kubectl delete -k removes them.
- On first start, the app creates its schema and seeds a demo board automatically.
- Tests at three levels: Pytest for the API, Vitest for React components and hooks, and Playwright end-to-end tests that start their own servers.
- A multi-architecture buildx command produces an image that runs on both Apple Silicon and x86_64 cluster nodes.
07Results
- A working full-stack application deployed to Kubernetes from public, reproducible manifests.
- Board data is stored in PostgreSQL on a persistent volume, so it survives app and database pod restarts.
- The same image runs in three places: locally with SQLite, in Docker Compose with PostgreSQL, and in Kubernetes.
- Source, Dockerfile, manifests and tests are public on GitHub.
08Lessons learned
- Database start-up order matters: an init container is a simple way to avoid crash loops while PostgreSQL initializes.
- Major PostgreSQL versions can change the data directory layout. Checking the image documentation before mounting volumes avoids losing data.
- Next steps for production use: an Ingress with TLS instead of NodePort, secrets from an external secret manager, secure-only session cookies, and backups for the PostgreSQL volume.
09Skills & technologies
KubernetesContainerizationStateful workloadsKustomizeFull-stack delivery
KubernetesKustomizeDockerPostgreSQL 18.6FastAPIPythonNext.jsTypeScriptSQLAlchemyDocker ComposePytestVitestPlaywright