Series: Zero to Kubernetes — Part 1 · Part 2 · Part 3 · Part 4 · Part 5
We now have a cluster with ingress, TLS, and GitOps. Time to put real workloads on it: a PostgreSQL database managed by an operator, and a FastAPI backend that connects to it, runs migrations on startup, and exposes a REST API with health checks.
This is the part where the tutorial stops being "look how pretty the empty cluster is" and starts being "this actually works in production."
Components of This Part
┌─────────────────────────────────────────┐
│ api.yourdomain.com │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Ingress │ │
│ │ (nginx + TLS) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Backend Service │ │
│ │ (ClusterIP) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Backend Deployment │ │
│ │ 2 replicas │ │
│ │ FastAPI + Uvicorn │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ PostgreSQL │ │
│ │ (CloudNativePG) │ │
│ │ 1 instance │ │
│ │ PVC 10Gi │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────┘
Step 1: CloudNativePG — PostgreSQL as a Native Kubernetes Resource
CloudNativePG (CNPG) is an operator that manages PostgreSQL on Kubernetes without manual StatefulSets. You install it and create a Cluster resource. The operator handles replication, backups, failover, and updates.
resource "helm_release" "cnpg" {
name = "cloudnative-pg"
namespace = kubernetes_namespace.infrastructure.metadata[0].name
repository = "https://cloudnative-pg.github.io/charts"
chart = "cloudnative-pg"
version = "0.22.0"
}
Then define the database as code in our GitOps repo:
gitops/apps/database/postgresql-cluster.yaml:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: guayoyo-db
namespace: production
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
bootstrap:
initdb:
database: guayoyo
owner: app
secret:
name: db-credentials
storage:
size: 10Gi
storageClass: local-path
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
monitoring:
enablePodMonitor: false
gitops/apps/database/db-credentials-secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: production
type: kubernetes.io/basic-auth
stringData:
username: app
password: "ChangeThisInProductionWithSealedSecrets"
⚠️ In real production: Never put passwords in plain text in git. Use SealedSecrets (encrypts the entire Secret), ExternalSecrets (syncs from Vault), or SOPS + Age.
Step 2: Backend Code (FastAPI)
main.py:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import asyncpg
import os
import time
from contextlib import asynccontextmanager
DB_HOST = os.getenv("DB_HOST", "guayoyo-db-rw")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "guayoyo")
DB_USER = os.getenv("DB_USER", "app")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
max_retries = 10
for attempt in range(max_retries):
try:
pool = await asyncpg.create_pool(
host=DB_HOST, port=DB_PORT, database=DB_NAME,
user=DB_USER, password=DB_PASSWORD,
min_size=2, max_size=10
)
async with pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
break
except Exception as e:
if attempt == max_retries - 1:
raise
time.sleep(3)
yield
await pool.close()
app = FastAPI(title="Guayoyo API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://admin.yourdomain.com",
"https://app.yourdomain.com",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ItemCreate(BaseModel):
name: str
description: str = ""
class Item(ItemCreate):
id: int
created_at: str
@app.get("/health")
async def health():
try:
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
return {"status": "ok", "db": "connected", "timestamp": time.time()}
except Exception as e:
return {"status": "degraded", "db": str(e)}
@app.post("/items", response_model=Item)
async def create_item(item: ItemCreate):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO items (name, description) VALUES ($1, $2) RETURNING *",
item.name, item.description
)
return Item(id=row["id"], name=row["name"],
description=row["description"] or "",
created_at=str(row["created_at"]))
@app.get("/items")
async def list_items():
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM items ORDER BY created_at DESC")
return [{"id": r["id"], "name": r["name"],
"description": r["description"] or "",
"created_at": str(r["created_at"])} for r in rows]
@app.get("/items/{item_id}")
async def get_item(item_id: int):
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM items WHERE id = $1", item_id)
if not row:
raise HTTPException(status_code=404, detail="Item not found")
return {"id": row["id"], "name": row["name"],
"description": row["description"] or "",
"created_at": str(row["created_at"])}
@app.delete("/items/{item_id}")
async def delete_item(item_id: int):
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM items WHERE id = $1", item_id)
if result == "DELETE 0":
raise HTTPException(status_code=404, detail="Item not found")
return {"status": "deleted"}
Dockerfile:
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && poetry install --no-dev
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Build and push:
docker build -t ghcr.io/your-org/backend:v1.0.0 .
docker push ghcr.io/your-org/backend:v1.0.0
Step 3: Deploy to Kubernetes via ArgoCD
gitops/apps/backend/deployment.yaml (key sections):
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
serviceAccountName: apps-sa
containers:
- name: backend
image: ghcr.io/your-org/backend:v1.0.0
ports:
- containerPort: 8000
env:
- name: DB_HOST
value: guayoyo-db-rw.production.svc.cluster.local
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: guayoyo
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
Key points:
-
guayoyo-db-rw.production.svc.cluster.local— internal DNS that CloudNativePG creates automatically.-rw= read-write (primary). If you had read replicas, they'd be at-ro. -
secretKeyRefinjects username/password from thedb-credentialsSecret without exposing them in the Deployment -
livenessProbeandreadinessProbe— Kubernetes uses these to know if the pod is alive and if it should receive traffic
gitops/apps/backend/ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: backend
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.yourdomain.com
secretName: api-tls
rules:
- host: api.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend
port:
number: 80
Step 4: End-to-End Verification
# Verify ArgoCD synced everything
argocd app list
# backend Healthy Synced
# database Healthy Synced
# Test the API
curl https://api.yourdomain.com:30443/health
# {"status":"ok","db":"connected","timestamp":1716163200.123}
# Create an item
curl -X POST https://api.yourdomain.com:30443/items \
-H "Content-Type: application/json" \
-d '{"name":"Test Item","description":"From curl"}'
# {"id":1,"name":"Test Item","description":"From curl",...}
# List items
curl https://api.yourdomain.com:30443/items
# [{"id":1,"name":"Test Item",...}]
# Check that both replicas are running
kubectl get pods -n production -l app=backend
# NAME READY STATUS RESTARTS AGE
# backend-xxxxx-xxxxx 1/1 Running 0 1m
# backend-xxxxx-yyyyy 1/1 Running 0 1m
What You Learned in This Part
- CloudNativePG as a PostgreSQL operator for Kubernetes
- Kubernetes internal DNS:
guayoyo-db-rw.production.svc.cluster.local - Secrets for credentials (with serious note about SealedSecrets/ExternalSecrets)
- FastAPI with connection pooling to PostgreSQL via asyncpg
- Liveness and readiness probes — the difference and why both matter
- CORS configured to accept traffic from frontends
- Multi-stage Docker build optimized for production
In Part 5, we'll deploy both frontends (React + Vue), connect them to the backend, and run a full integration test where one app creates data and the other consumes it in real time.
At Guayoyo Tech, we design backends that don't crash under real traffic. FastAPI, Go, Node.js — whatever your project needs, with PostgreSQL, health checks, metrics, and automated Kubernetes deployment. Talk to us free for 15 minutes and we'll show you how.

Top comments (0)