Postgres Addon¶
A first-class managed Postgres for any app — provisioned with one
API call, exposed to the app's pods through a single DATABASE_URL
environment variable, kept healthy by CloudNative-PG (CNPG), and
scaled by the user-facing free / standard / pro plan.
How it works¶
sequenceDiagram
participant U as Operator
participant CP as Control Plane
participant DB as Postgres (app_addons)
participant K as Kubernetes
participant CNPG as CNPG operator
participant App as App pod
U->>CP: POST /v1/apps/{id}/addons { type:"postgres", plan:"standard" }
CP->>CP: validate plan + type, plan_to_resources("standard")
CP->>DB: INSERT app_addons (status='creating')
CP->>K: ensure_cnpg_cluster(version, plan)
K->>CNPG: Cluster CR (instances, storage, memory, image)
CNPG->>CNPG: bootstraps Postgres pods + replicas
CNPG->>K: Secret {cluster}-app (uri, host, port, user, pwd)
CP->>K: ensure_database_url_secret → Secret app-{id}-database-url
loop until ready
CP->>K: get Cluster.status.phase
CP->>CP: cluster_status → "running" or "provisioning"
CP->>DB: mark_ready when "running"
end
K->>App: envFrom: { secretRef: { name: app-{id}-database-url } }
App->>K: read DATABASE_URL on start
Plans¶
| Plan | Instances | Storage | Memory | Backup |
|---|---|---|---|---|
free |
1 | 1Gi | 256Mi | none |
standard |
1 | 10Gi | 1Gi | daily |
pro |
3 (HA) | 50Gi | 4Gi | pitr |
pro runs 3 replicas under CNPG's automatic failover; free and
standard run a single primary. daily triggers a barman-managed
nightly base-backup; pitr adds WAL streaming for point-in-time
recovery (Phase 2 — see below).
API¶
TOKEN=$(curl -sf -X POST https://runtime.di2amp.com/api/v1/auth/login \
-d '{"email":"…","password":"…"}' | jq -r '.data.access_token')
curl -X POST https://runtime.di2amp.com/api/v1/apps/$APP/addons \
-H "Authorization: Bearer $TOKEN" \
-d '{"type":"postgres","plan":"standard","version":"16"}'
Response:
{
"data": {
"app_id": "…",
"addon_type": "postgres",
"plan": "standard",
"version": "16",
"status": "creating",
"resources": { "instances": 1, "storage": "10Gi", "memory": "1Gi", "backup": "daily" }
}
}
| Status | Meaning |
|---|---|
creating |
Cluster CR submitted, CNPG hasn't reported phase: Cluster in healthy state yet |
running |
CNPG reports healthy; mark_ready flipped the row, ready_at stamped |
provisioning |
Transient — CNPG is restarting / failing over a replica |
deleted |
Soft-deleted via app_addons_repository::soft_delete |
DATABASE_URL secret¶
ensure_database_url_secret server-side-applies a Secret named
app-{app_id}-database-url in the app's tenant namespace, with a
single DATABASE_URL key whose value is the full
postgresql://user:pwd@{cluster}-rw.{ns}:5432/app URI sourced
from CNPG's {cluster}-app Secret.
The app pod's PodSpec mounts it via:
Result: a process.env.DATABASE_URL (or os.environ['DATABASE_URL'])
on the app side that any Postgres client library accepts directly.
Idempotency¶
| Op | Idempotency |
|---|---|
POST /addons (same app_id, same type) |
DB row rotates via ON CONFLICT (app_id, addon_type) DO UPDATE; CNPG side rotates via ensure_cnpg_cluster (get-then-create-or-patch) |
ensure_database_url_secret |
Patch::Apply(...).force() with field manager paas-control-plane — re-run after a CNPG password rotation refreshes the value |
mark_ready |
UPDATE … WHERE id = $1 is a no-op if the row is already ready (Postgres semantics) |
Naming conventions (pinned by tests)¶
| Resource | Pattern | Source |
|---|---|---|
| CNPG Cluster | db-{tenant_id}-{app_id} (truncated at 53 chars) |
cnpg::cluster_name |
CNPG …-app Secret |
db-{tenant_id}-{app_id}-app |
cnpg::cnpg_secret_names[0] |
| App-side Secret | app-{app_id}-database-url |
cnpg::database_url_secret_name |
| Cluster image | ghcr.io/cloudnative-pg/postgresql:{version} |
cnpg::PG_IMAGE_BASE |
Failure modes¶
| Symptom | Likely cause | Recovery |
|---|---|---|
status: "creating" for >5 min |
image pull, PVC binding | kubectl describe cluster … in the tenant ns |
provisioning after a previously running row |
replica restart, primary failover | wait — CNPG self-heals; if persistent, escalate via kubectl describe |
App pod restart-loops on missing DATABASE_URL |
ensure_database_url_secret failed silently |
re-call POST /addons with the same body — server-side apply re-converges |
Phase 2¶
pitrbackup activation — barman WAL streaming + theBackupCR. Today theproplan returnsbackup: "pitr"from the resources block but the WAL writer needs an S3 bucket; the operator-side runbook lands when the platform's object store is wired.mysql/valkey/opensearch—create_addon_genericalready accepts these types. Their provisioner dispatch (state.cache.create_redis, etc.) ships in a follow-up cycle.
Related¶
- Add-ons — the unified list view
- Manual Scaling — same
paas_tenant_quotastable that AC/25 added - Network Policies —
allow-app-postgresis the per-app egress allow that lets pods reach the addon