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— returnsdatabase.healthy/llm.healthyflags, but the top-levelstatusfield 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.