Skip to content

Kubernetes Deployment

Manifests live in k8s/ in the main repo. Deploys SQLatte plus an in-cluster PostgreSQL (StatefulSet) backing analytics and config_db.

Architecture

Ingress (TLS termination)
    │
    ▼
Service: sqlatte (ClusterIP :80 → :8000)
    │
    ▼
Deployment: sqlatte (replicas: 1)
    │  reads config.yaml from Secret: sqlatte-config
    ▼
Service: postgres (headless, :5432)
    │
    ▼
StatefulSet: postgres (replicas: 1, PVC-backed)
    databases: sqlatte_analytics, sqlatte_config

One Postgres instance, two logical databases:

Database Used by Config block
sqlatte_analytics Query history, audit logs, scheduled queries/executions, email deliveries, dashboards analytics.postgresql
sqlatte_config Config overrides/history/snapshots, API tokens, MCP mask rules, semantic layer entities/columns/relationships/metrics config_db.postgresql

All tables are created by the app itself at runtime (CREATE TABLE IF NOT EXISTS — see the note in Installation). The Postgres StatefulSet's init ConfigMap only creates the databases and the app role — no manual schema migration step.

Why replicas: 1

Sessions (admin_auth.py, auth_plugin.py's SessionManager) are held in-memory, per-process — see Production for the same constraint under Gunicorn. With more than one replica, a user's session may not exist on whichever pod handles their next request, surfacing as random "session expired" errors. The Deployment manifest pins replicas: 1 and uses strategy: Recreate (so a rollout never briefly runs two pods in parallel) specifically because of this. Don't raise it without first moving session storage to a shared store (Redis/Postgres) — that's application code, not a manifest change.

Deploy Order

kubectl apply -f k8s/00-namespace.yaml

kubectl create secret generic postgres-credentials -n sqlatte \
  --from-literal=POSTGRES_PASSWORD='<superuser-password>' \
  --from-literal=SQLATTE_APP_PASSWORD='<app-role-password>'

kubectl apply -f k8s/02-postgres-init-configmap.yaml \
              -f k8s/03-postgres-statefulset.yaml \
              -f k8s/04-postgres-service.yaml

# wait for postgres-0 to be Ready before continuing
kubectl -n sqlatte wait --for=condition=ready pod -l app=postgres --timeout=120s

kubectl create secret generic sqlatte-config -n sqlatte \
  --from-file=config.yaml=./config/config.yaml

kubectl apply -f k8s/10-deployment.yaml \
              -f k8s/11-service.yaml \
              -f k8s/12-ingress.yaml   # requires an ingress controller (e.g. ingress-nginx) + cert-manager for TLS

01-postgres-secret.yaml.example and 05-app-config-secret.yaml.example are templates showing the exact Secret shapes — not meant to be applied directly. Real values only ever go in via kubectl create secret, never committed.

Point config.yaml's analytics.postgresql / config_db.postgresql blocks at the in-cluster service:

host: postgres.sqlatte.svc.cluster.local   # or just "postgres" from within the sqlatte namespace
port: 5432
user: sqlatte_app
password: <same value as SQLATTE_APP_PASSWORD>
database: sqlatte_analytics   # or sqlatte_config, respectively

Updating an Existing Deployment

Two independent things can change between deploys — a new image (code) and/or a new config.yaml (config). Update whichever actually changed.

New code — build, push, roll out:

docker build -t <your-registry>/sqlatte:latest .
docker push <your-registry>/sqlatte:latest

# 10-deployment.yaml pins a mutable `:latest` tag — re-applying identical
# manifest text is a no-op (kubectl only restarts pods on a spec diff), so
# pushing a new image alone doesn't roll it out. Force it:
kubectl rollout restart deployment/sqlatte -n sqlatte
kubectl -n sqlatte rollout status deployment/sqlatte

Prefer a versioned tag over :latest where practical — kubectl set image deployment/sqlatte sqlatte=<registry>/sqlatte:v0.7.0 -n sqlatte both updates the manifest and triggers the rollout in one step, and makes kubectl rollout undo meaningful if the new version misbehaves.

Because of strategy: Recreate (see Why replicas: 1), the rollout briefly takes the app fully offline — the old pod terminates completely before the new one starts. Every active session (UI logins, MCP SSE connections) drops and must reconnect; API tokens themselves are unaffected since they persist in Postgres, not process memory.

New config — no image change needed:

kubectl create secret generic sqlatte-config -n sqlatte \
  --from-file=config.yaml=./config/config.k8s.yaml \
  --dry-run=client -o yaml | kubectl apply -f -

# Secret volumes update in the pod's filesystem within ~60s on their own,
# but config.yaml is only read once at startup, so the running process
# still needs an explicit restart to pick up new values:
kubectl rollout restart deployment/sqlatte -n sqlatte

Schema changes (new Postgres columns/tables) never need a manual migration step — every module creates/alters its own tables on startup, so a plain restart against the existing database is enough even when the new image expects new columns.

TLS

Terminated at the Ingress (12-ingress.yaml), not in the app — same reasoning as the reverse-proxy recommendation for Docker deployment. Needs an ingress controller and, for automatic certs, cert-manager; neither is included in the manifests (cluster-level installs).

Health Checks

Both the Deployment's probes and the Postgres StatefulSet's probes hit real endpoints:

  • App: GET /health — returns database.healthy / llm.healthy flags, but the top-level status field is currently hardcoded to "healthy" regardless of those flags, so it's only useful as a liveness signal ("is the process responding"), not a dependency-health signal yet.
  • Postgres: pg_isready.