Skip to content

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:

envFrom:
  - secretRef:
      name: app-{app_id}-database-url

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

  • pitr backup activation — barman WAL streaming + the Backup CR. Today the pro plan returns backup: "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 / opensearchcreate_addon_generic already accepts these types. Their provisioner dispatch (state.cache.create_redis, etc.) ships in a follow-up cycle.
  • Add-ons — the unified list view
  • Manual Scaling — same paas_tenant_quotas table that AC/25 added
  • Network Policiesallow-app-postgres is the per-app egress allow that lets pods reach the addon