Kubernetes • DevOps • Docker • Django • React • PostgreSQL • Production

Deploying a Full-Stack Application on Kubernetes: A Complete Production Guide

Sep 8, 202618 min read


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)
ComponentTechnology
FrontendReact (SPA, built with Vite)
BackendDjango + Gunicorn
DatabasePostgreSQL 16
CacheRedis 7
OrchestrationKubernetes
IngressNginx Ingress Controller
TLScert-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

PropertyHow
Automatic crash recoveryDeployments restart failed pods immediately
Zero-downtime deploysRollingUpdate with maxUnavailable: 0
Instant rollbackkubectl rollout undo
Automatic HTTPScert-manager + Let's Encrypt
Auto-scalingHPA scales backend 3→20 pods based on load
Secret managementKubernetes Secrets injected as env vars
Persistent databaseStatefulSet + PersistentVolume
Health monitoringLiveness + 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.

← Back to Blog