Deploying a Full-Stack Application on Kubernetes: A Complete Production Guide
If you have ever searched "how to deploy on Kubernetes" and ended up with either a hello-world tutorial that skips everything important, or a 200-page official doc that assumes you already know what you are doing — this guide is written for you.
We will deploy a real full-stack application: a React frontend, a Django REST API backend, PostgreSQL, and Redis. By the end, your application will automatically heal from crashes, scale under traffic, serve HTTPS, and be ready for production.
No fluff. No hello-world shortcuts. Let's build it properly.
What We're Building
Here is the architecture we are targeting:
Users
│
▼
Cloudflare (CDN + HTTPS)
│
▼
Kubernetes Cluster
├── Nginx Ingress (routes traffic)
├── Frontend Pods (React, served by Nginx)
├── Backend Pods (Django + Gunicorn)
├── PostgreSQL (StatefulSet)
└── Redis (Deployment)
| Component | Technology |
|---|---|
| Frontend | React (SPA, built with Vite) |
| Backend | Django + Gunicorn |
| Database | PostgreSQL 16 |
| Cache | Redis 7 |
| Orchestration | Kubernetes |
| Ingress | Nginx Ingress Controller |
| TLS | cert-manager + Let's Encrypt |
Part 1 — Containerising Your Application
Before Kubernetes can run anything, every piece of your application must be packaged as a Docker image. Think of a Docker image as a frozen, portable snapshot of your app and everything it needs to run.
Containerising the React Frontend
Your React app is just static files after npm run build. We package it with Nginx to serve those files:
# frontend/Dockerfile
# Stage 1: Build the React app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
The nginx.conf is critical for single-page apps. Without it, direct URL access — say, navigating to /dashboard — returns a 404 because Nginx looks for a file that does not exist:
# frontend/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Magic line for SPAs: if a file doesn't exist, serve index.html
# React Router then reads the URL and renders the correct page
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed JS/CSS files for 1 year
location ~* \.(js|css|png|jpg|woff2|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Containerising the Django Backend
# backend/Dockerfile
FROM python:3.12-slim
# Create non-root user — never run as root in production
RUN useradd --create-home appuser
WORKDIR /home/appuser/app
# Install dependencies first (cached layer — only rebuilds when requirements change)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuser
CMD ["gunicorn", "myproject.wsgi:application", \
"--workers", "4", \
"--bind", "0.0.0.0:8000", \
"--timeout", "30", \
"--access-logfile", "-"]
Building and Pushing Images
docker build -t myapp/frontend:v1.0.0 ./frontend
docker build -t myapp/backend:v1.0.0 ./backend
docker push myapp/frontend:v1.0.0
docker push myapp/backend:v1.0.0
One thing I always tell people: always use explicit version tags (v1.0.0), never latest. Using latest makes it impossible to know what version is actually running, and causes chaotic deployments when a pod restarts and pulls something different.
Part 2 — Setting Up the Cluster
Before deploying your app, your cluster needs two foundational components.
Install the Nginx Ingress Controller
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.4/deploy/static/provider/cloud/deploy.yaml
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
Install cert-manager
cert-manager automatically obtains and renews Let's Encrypt certificates. Set it up once and never manually manage TLS again:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
Create a ClusterIssuer — this tells cert-manager how to get certificates:
# cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: [email protected]
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx
kubectl apply -f cluster-issuer.yaml
Part 3 — Secrets and Configuration
Never put passwords, API keys, or secrets in your Docker images or YAML files. Kubernetes Secrets store sensitive values safely and inject them as environment variables.
kubectl create secret generic postgres-secret \
--from-literal=password=your-very-secure-password-here \
--from-literal=username=appuser
kubectl create secret generic django-secret \
--from-literal=secret-key=your-50-char-random-django-secret-key \
--from-literal=database-url=postgresql://appuser:your-password@postgres-service:5432/myapp
For non-sensitive configuration, use a ConfigMap:
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
DJANGO_ALLOWED_HOSTS: "api.yourdomain.com"
DJANGO_DEBUG: "False"
REDIS_URL: "redis://redis-service:6379/0"
CORS_ALLOWED_ORIGINS: "https://yourdomain.com"
kubectl apply -f configmap.yaml
Part 4 — Deploying PostgreSQL
PostgreSQL is stateful — it holds your data and cannot be treated like a disposable container. It needs a StatefulSet (which gives pods stable identities) and a PersistentVolume (so data survives pod restarts).
# postgres.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: "postgres-headless"
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: myapp
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: postgres-secret
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
readinessProbe:
exec:
command: ["pg_isready", "-U", "appuser", "-d", "myapp"]
initialDelaySeconds: 15
periodSeconds: 10
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "standard"
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: postgres-service
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
clusterIP: None
kubectl apply -f postgres.yaml
kubectl wait --for=condition=ready pod/postgres-0 --timeout=120s
Part 5 — Deploying Redis
Redis is a cache — losing data on restart is acceptable. A regular Deployment is fine here:
# redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
ports:
- containerPort: 6379
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: redis-service
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
kubectl apply -f redis.yaml
Part 6 — Deploying the Backend
The Django application is stateless — any pod can handle any request — so it scales freely. The two health check probes here are not optional. They are what makes self-healing actually work.
# backend.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 3
selector:
matchLabels:
app: backend
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # never take pods offline during an update
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: myapp/backend:v1.0.0
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: app-config
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: django-secret
key: secret-key
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: django-secret
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1000m"
# If this fails → Kubernetes RESTARTS the pod
livenessProbe:
httpGet:
path: /api/health/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 3
# If this fails → Kubernetes REMOVES pod from load balancer
readinessProbe:
httpGet:
path: /api/health/ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 8000
Your Django app needs the health endpoints the probes are calling:
# myproject/urls.py
from django.http import JsonResponse
from django.db import connection
def health_live(request):
"""Liveness: is the process running?"""
return JsonResponse({"status": "ok"})
def health_ready(request):
"""Readiness: can we serve traffic? (DB connected?)"""
try:
connection.ensure_connection()
return JsonResponse({"status": "ok"})
except Exception:
return JsonResponse({"status": "error"}, status=503)
urlpatterns = [
path("api/health/live", health_live),
path("api/health/ready", health_ready),
# ... your other URLs
]
kubectl apply -f backend.yaml
# Run database migrations as a one-time Job
kubectl run migrate --image=myapp/backend:v1.0.0 \
--restart=Never \
--env="DATABASE_URL=postgresql://appuser:password@postgres-service:5432/myapp" \
-- python manage.py migrate
Part 7 — Deploying the Frontend
The frontend is the simplest deployment. Nginx serving static files uses almost no resources:
# frontend.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: myapp/frontend:v1.0.0
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: frontend-service
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
kubectl apply -f frontend.yaml
Part 8 — Ingress: The Public Door
The Ingress routes external HTTPS traffic to the right services. The cert-manager annotation is what triggers automatic certificate issuance — Kubernetes handles the rest.
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
spec:
ingressClassName: nginx
tls:
- hosts:
- yourdomain.com
- api.yourdomain.com
secretName: myapp-tls
rules:
- host: api.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 80
- host: yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
kubectl apply -f ingress.yaml
# Check that the certificate was issued (takes ~60 seconds)
kubectl get certificate
# NAME READY SECRET AGE
# myapp-tls True myapp-tls 90s
Once READY is True, your application is serving HTTPS. cert-manager will auto-renew 30 days before expiry — indefinitely.
Part 9 — Autoscaling
Right now the backend is fixed at 3 pods. The HorizontalPodAutoscaler handles traffic spikes automatically:
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
The stabilizationWindowSeconds on scale-down is important — without it, Kubernetes adds and removes pods repeatedly as traffic fluctuates, which is wasteful and can cause instability.
kubectl apply -f hpa.yaml
kubectl get hpa backend-hpa --watch
# NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
# backend-hpa Deployment/backend 23%/70% 3 20 3
Part 10 — Zero-Downtime Deployments
You have pushed v1.1.0. Here is how to deploy it without dropping a single request:
kubectl set image deployment/backend backend=myapp/backend:v1.1.0
kubectl rollout status deployment/backend
# Waiting for deployment "backend" rollout to finish: 1 out of 3 new replicas updated...
# Waiting for deployment "backend" rollout to finish: 2 out of 3 new replicas updated...
# deployment "backend" successfully rolled out
Because maxUnavailable: 0 is set, Kubernetes never terminates an old pod until its replacement is healthy and passing readiness checks. Traffic flows continuously.
Something went wrong? Roll back:
# Instantly revert to the previous version
kubectl rollout undo deployment/backend
# Or roll back to a specific revision
kubectl rollout history deployment/backend
kubectl rollout undo deployment/backend --to-revision=3
Part 11 — Monitoring and Debugging
# See all pods and their status
kubectl get pods
# Resource usage per pod
kubectl top pods
# Tail logs from all backend pods simultaneously
kubectl logs -l app=backend --follow --tail=100
# Inspect a specific pod (shows events, crash reasons, probe failures)
kubectl describe pod backend-xxx-yyy
# Get a shell inside a running pod
kubectl exec -it backend-xxx-yyy -- bash
# See logs from a crashed container (previous run)
kubectl logs backend-xxx-yyy --previous
The Complete File Structure
k8s/
cluster-issuer.yaml
configmap.yaml
postgres.yaml
redis.yaml
backend.yaml
frontend.yaml
ingress.yaml
hpa.yaml
frontend/
Dockerfile
nginx.conf
src/...
backend/
Dockerfile
manage.py
myproject/
urls.py
settings.py
Deploy in this order — dependencies first:
kubectl apply -f k8s/cluster-issuer.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/redis.yaml
kubectl apply -f k8s/backend.yaml
kubectl apply -f k8s/frontend.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/hpa.yaml
Five Mistakes That Will Ruin Your Production Deployment
1. Using latest as your image tag
# Bad
image: myapp/backend:latest
# Good
image: myapp/backend:v1.2.3
The latest tag means you cannot know what is running. If a pod restarts on a different node, it might pull a different image than the rest of your deployment.
2. Skipping resource limits
Without limits, one misbehaving pod can consume all node memory and crash everything else running on that node. Always set both requests and limits.
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1000m"
3. No health checks
Without readinessProbe, Kubernetes sends traffic to pods that are still starting up. Without livenessProbe, a deadlocked pod sits there forever receiving — and silently dropping — requests.
4. Secrets in ConfigMaps or YAML files
# Bad — visible to anyone with kubectl access
env:
- name: DATABASE_PASSWORD
value: "mysecretpassword"
# Good — reads from Kubernetes Secret
env:
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
5. Not setting maxUnavailable: 0
The default maxUnavailable is 25%, meaning on a 4-pod deployment, one pod goes offline before a replacement is ready. This causes dropped requests. Always set it to 0 for production.
What You Now Have
| Property | How |
|---|---|
| Automatic crash recovery | Deployments restart failed pods immediately |
| Zero-downtime deploys | RollingUpdate with maxUnavailable: 0 |
| Instant rollback | kubectl rollout undo |
| Automatic HTTPS | cert-manager + Let's Encrypt |
| Auto-scaling | HPA scales backend 3→20 pods based on load |
| Secret management | Kubernetes Secrets injected as env vars |
| Persistent database | StatefulSet + PersistentVolume |
| Health monitoring | Liveness + Readiness probes |
What to Tackle Next
This gets you to a solid production deployment. The natural next steps are:
Managed database — Replace self-hosted PostgreSQL with AWS RDS or Cloud SQL. You get automated backups, point-in-time recovery, and automatic failover without managing any of it yourself.
Monitoring — Install Prometheus and Grafana to see CPU, memory, error rates, and latency dashboards for every service. You cannot fix what you cannot see.
CI/CD — Automate the build-push-deploy cycle with GitHub Actions so every merged PR deploys automatically, without anyone running kubectl commands manually.
Network Policies — Add internal firewall rules so only the backend can reach the database, the frontend cannot reach Redis, and compromised pods cannot spread laterally.
GitOps with ArgoCD — Instead of running kubectl apply manually, let ArgoCD sync your cluster state from a Git repository automatically. Your Git history becomes your deployment history.
Kubernetes has a reputation for being complicated. That reputation is partly earned — but most of the complexity in tutorials comes from guides that skip the details that actually matter in production: health checks, rolling updates, autoscaling, and secret management.
The YAML looks verbose. But every line buys you something real: resilience, observability, or control. Once it runs, it keeps running — and that is the point.