DEV Community

jesus manrique
jesus manrique

Posted on • Originally published at guayoyo.tech

Zero to Kubernetes Part 4: FastAPI Backend + PostgreSQL in Production

K8s Terraform ArgoCD — Header

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            │             │
│    └───────────────────────┘             │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

⚠️ 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"}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Build and push:

docker build -t ghcr.io/your-org/backend:v1.0.0 .
docker push ghcr.io/your-org/backend:v1.0.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • secretKeyRef injects username/password from the db-credentials Secret without exposing them in the Deployment
  • livenessProbe and readinessProbe — 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)