Skip to content
Suiunbek Isaev
All case studies

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

Architecture as implemented in the public source repository. Swipe sideways to see all of it.

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

  1. 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.
  2. Kept configuration in environment variables (DATABASE_URL, admin credentials, SESSION_SECRET), so the same image runs with SQLite locally and PostgreSQL in the cluster.
  3. Ran PostgreSQL as a StatefulSet with a volumeClaimTemplate instead of a Deployment, which gives it a stable identity and its own persistent volume.
  4. Added an init container and readiness/liveness probes, so the app starts in the right order and Kubernetes only sends traffic to healthy pods.
  5. 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.
  6. Added a Docker Compose file with the same PostgreSQL version as the cluster, to test the production database path locally before deploying.
  7. 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