{"id":49455415,"url":"https://github.com/thadeu/voodu-postgres","last_synced_at":"2026-05-20T04:04:14.955Z","repository":{"id":354788917,"uuid":"1219107896","full_name":"thadeu/voodu-postgres","owner":"thadeu","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-04T18:40:08.000Z","size":15771,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-04T20:38:41.342Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thadeu.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-23T14:35:40.000Z","updated_at":"2026-05-04T18:40:11.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thadeu/voodu-postgres","commit_stats":null,"previous_names":["thadeu/voodu-postgres"],"tags_count":25,"template":false,"template_full_name":null,"purl":"pkg:github/thadeu/voodu-postgres","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fvoodu-postgres","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fvoodu-postgres/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fvoodu-postgres/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fvoodu-postgres/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thadeu","download_url":"https://codeload.github.com/thadeu/voodu-postgres/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fvoodu-postgres/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33245419,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-20T03:30:51.439Z","status":"ssl_error","status_checked_at":"2026-05-20T03:30:49.443Z","response_time":356,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-30T05:01:09.781Z","updated_at":"2026-05-20T04:04:14.947Z","avatar_url":"https://github.com/thadeu.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# voodu-postgres\n\nVoodu plugin that expands a `postgres { … }` HCL block into a production-ready postgres cluster: 1 primary + N streaming-replication standbys, auto-generated passwords, dual `DATABASE_URL` injection (writer + reader endpoints, RDS-style).\n\nBare block produces a single hardened primary; `replicas = 3` flips it into a cluster with `pg_basebackup`-cloned standbys and `primary_conninfo`-driven WAL streaming.\n\n## Table of contents\n\n- [Quick start](#quick-start)\n- [Configuration](#configuration)\n  - [HCL contract](#hcl-contract)\n  - [Plugin defaults](#plugin-defaults)\n  - [Customising postgresql.conf via `pg_config`](#customising-postgresqlconf-via-pg_config)\n  - [Custom image (extensions baked)](#custom-image-extensions-baked)\n  - [Statefulset passthrough](#statefulset-passthrough)\n- [High availability — streaming replication](#high-availability--streaming-replication)\n  - [Cluster shape](#cluster-shape)\n  - [Cold-start sequence](#cold-start-sequence)\n  - [Write vs read endpoints](#write-vs-read-endpoints)\n  - [Promote a standby](#promote-a-standby)\n  - [Rejoining the old primary](#rejoining-the-old-primary)\n- [Backup automation](#backup-automation)\n  - [`vd pg:backups` (Heroku-style)](#vd-pgbackups-heroku-style-recommended)\n  - [Retention (`--keep` / `--max-age`)](#retention---keep----max-age)\n  - [`vd postgres:backup` / `vd postgres:restore` (legacy)](#vd-postgresbackup--vd-postgresrestore-legacy-pg_basebackup)\n- [Connecting via `psql`](#connecting-via-psql)\n- [Real-world examples](#real-world-examples)\n  - [Single primary + Rails app](#single-primary--rails-app)\n  - [3-replica cluster + Rails MultiDB](#3-replica-cluster--rails-multidb)\n  - [Custom image with pgvector](#custom-image-with-pgvector)\n  - [External access for DBeaver / TablePlus](#external-access-for-dbeaver--tableplus)\n- [Plugin reference](#plugin-reference)\n  - [Commands](#commands)\n  - [Asset files emitted](#asset-files-emitted)\n  - [Statefulset env vars](#statefulset-env-vars)\n  - [Bucket keys (config)](#bucket-keys-config)\n  - [Repo layout](#repo-layout)\n  - [Development](#development)\n- [Install \u0026 upgrade](#install--upgrade)\n- [Storage](#storage)\n- [License](#license)\n\n---\n\n## Quick start\n\n```hcl\n# voodu.hcl\npostgres \"clowk-lp\" \"db\" {\n  image    = \"postgres:16\"\n  replicas = 3                  # 1 primary + 2 standbys\n}\n```\n\nApply:\n\n```bash\nvd apply -f voodu.hcl\n```\n\nPlugin emits:\n\n- **`asset \"clowk-lp\" \"db\"`** — 4 files: bash entrypoint wrapper, postgresql.conf overrides, streaming-replication conf, replication-user init script\n- **`statefulset \"clowk-lp\" \"db\"`** — 3 pods (`db-0..db-2`), 1 volume claim (`data`, per-pod), 4 asset bind-mounts, host bind-mount `/opt/voodu/backups/clowk-lp/db` → `/backups`, env vars wiring everything\n- **2 config_set actions** (first apply only) — persists auto-gen `POSTGRES_PASSWORD` + `POSTGRES_REPLICATION_PASSWORD`\n\nWire your app:\n\n```bash\nvd postgres:link clowk-lp/db clowk-lp/web --reads\n# → DATABASE_URL    on web (primary)\n# → DATABASE_READ_URL on web (read pool spanning standbys)\n```\n\nInspect:\n\n```bash\nvd postgres:info clowk-lp/db\n```\n\n---\n\n## Configuration\n\n### HCL contract\n\nEvery field is optional. Defaults shown.\n\n```hcl\npostgres \"scope\" \"name\" {\n  image    = \"postgres:latest\"            # operator typically pins major (postgres:16)\n  replicas = 1                            # 1 = single primary; \u003e=2 = cluster\n  database = \"postgres\"                   # initdb arg\n  user     = \"postgres\"                   # superuser\n  port     = 5432                         # listen port\n\n  password         = \"\"                   # default empty → auto-gen 32-byte hex\n                                          # operator-set value lives in HCL plaintext\n  replication_user = \"replicator\"         # separate role, REPLICATION attribute only\n\n  initdb_locale   = \"C.UTF-8\"             # initdb --locale\n  initdb_encoding = \"UTF8\"                # initdb --encoding\n\n  # postgresql.conf overrides (key/value, anti-injection guarded)\n  pg_config = {\n    max_connections = 200\n    shared_buffers  = \"256MB\"\n    log_connections = true\n  }\n\n  # Extensions: parsed + validated, but NOT auto-installed (M-P4\n  # ships `vd postgres:exec` for explicit install).\n  extensions = [\"pgvector\", \"pg_stat_statements\"]\n\n  # Statefulset passthrough — anything the statefulset accepts\n  # flows through unchanged. See \"Statefulset passthrough\" below.\n}\n```\n\nPrint the complete spec at any time:\n\n```bash\nvoodu-postgres defaults\n```\n\n### Plugin defaults\n\nBare block — `postgres \"data\" \"db\" {}` — emits a statefulset with:\n\n- `image = \"postgres:latest\"` (override to pin major in prod)\n- `replicas = 1` (single primary)\n- `command = [\"bash\", \"/usr/local/bin/voodu-postgres-entrypoint\"]` (role-aware wrapper)\n- `ports = [\"5432\"]` (loopback by default — `vd postgres:expose` to publish)\n- `volume_claims`: `data` at `/var/lib/postgresql/data` (per-pod, statefulset-style)\n- `volumes`: host bind-mount `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e` → `/backups` (where `vd pg:backups:capture` writes)\n- `health_check = \"pg_isready -U postgres -d postgres -p 5432\"`\n- env: `POSTGRES_USER` / `POSTGRES_DB` / `POSTGRES_PASSWORD` / `POSTGRES_INITDB_ARGS` / `PGDATA` / `PG_PORT` / `PG_NAME` / `PG_SCOPE_SUFFIX` / `PG_PRIMARY_ORDINAL` / `PG_REPLICATION_USER` / `PG_REPLICATION_PASSWORD`\n- `probes`: kubelet-style liveness + readiness wired up by default — see next section\n\n### Default probes (since 0.13.0)\n\nBare `postgres \"data\" \"db\" {}` blocks ship with these probes pre-configured so the operator doesn't boot postgres blind. The values are tuned for cold-boot tolerance + serving-state precision; the canonical postgres `pg_isready` signal drives readiness so caddy / consumer apps automatically bypass standbys mid-`pg_basebackup` or primaries mid-WAL-replay.\n\n| probe | selector | timing |\n|---|---|---|\n| liveness | `tcp_socket { port = \u003cspec.port\u003e }` | `initial_delay = \"20s\"`, `period = \"10s\"`, `failure_threshold = 3` |\n| readiness | `exec { command = [\"pg_isready\", \"-U\", \"\u003cspec.user\u003e\", \"-d\", \"\u003cspec.database\u003e\", \"-p\", \"\u003cspec.port\u003e\"] }` | `period = \"5s\"`, `failure_threshold = 1`, `success_threshold = 2` |\n\n**Why these defaults:**\n\n- **Liveness via TCP**: process-alive signal at the cheapest possible cost. `initial_delay = 20s` tolerates the 5-10s postgres takes to bind on cold start + slower disks; 3 consecutive 10s failures (~30s window) before docker restarts the container survives one-off GC pauses without crash-looping.\n- **Readiness via `pg_isready`**: the strict \"actually serving\" signal. Returns exit 0 only when postgres accepts connections AND is in a serving state — NOT during startup recovery, NOT during `pg_basebackup` on standbys, NOT while WAL replay catches up. During those windows the upstream is bypassed by anything respecting the probe (caddy active health check, Rails connection pool retry, custom service discovery).\n- **User + Database + Port from spec**: the readiness command is rendered with the operator-supplied values verbatim, so a `postgres \"data\" \"db\" { user = \"appuser\" database = \"appdata\" port = 5433 }` block produces a probe matching that authentication shape without manual configuration.\n\n**Override:**\n\nDeclaring **any** `probes { ... }` block in the HCL replaces the default entirely (no sub-block merging — operator-declared partial probes do NOT inherit the plugin's defaults for the slots they didn't declare). If you want to override one probe and keep the other, redeclare both:\n\n```hcl\npostgres \"data\" \"db\" {\n  probes {\n    # Custom liveness (e.g. swap to http_get if you front postgres\n    # with pgbouncer + an HTTP health endpoint)\n    liveness {\n      http_get {\n        path = \"/healthz\"\n        port = 8080\n      }\n      period = \"15s\"\n    }\n\n    # Redeclare the plugin default explicitly to keep it\n    readiness {\n      exec { command = [\"pg_isready\", \"-U\", \"appuser\", \"-d\", \"appdata\"] }\n      period            = \"5s\"\n      failure_threshold = 1\n      success_threshold = 2\n    }\n  }\n}\n```\n\n**Disabling probes entirely:** declare `probes {}` with all three sub-blocks (`liveness`, `readiness`, `startup`) omitted — voodu accepts an empty `probes` block and runs nothing.\n\n**Upgrade note (from \u003c 0.13.0):** the first `vd apply` after upgrading the plugin re-renders the statefulset spec with probes attached, which flips the spec hash and triggers one top-down rolling restart per ordinal. Per-pod data survives via the existing per-ordinal volume claims; the restart is cosmetic.\n\n### Customising postgresql.conf via `pg_config`\n\nThe `pg_config` map renders into a `voodu-99-overrides.conf` file that postgres loads via `include_dir`. \"Last assignment wins\" — operator overrides trump plugin defaults from `voodu-50-streaming.conf`.\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  pg_config = {\n    # tuning\n    max_connections      = 200\n    shared_buffers       = \"1GB\"\n    work_mem             = \"16MB\"\n    effective_cache_size = \"3GB\"\n    random_page_cost     = 1.1     # SSD storage\n\n    # logging\n    log_connections    = true\n    log_disconnections = true\n    log_min_messages   = \"warning\"\n    log_statement      = \"ddl\"\n  }\n}\n```\n\nType rules:\n\n| HCL type | Renders as |\n|---|---|\n| `int` / `int64` / whole `float` | unquoted integer |\n| fractional `float` | unquoted decimal |\n| `bool` | `on` / `off` (postgres convention) |\n| `string` | `'...'` (single-quoted, embedded `'` escaped as `''`) |\n\nKeys validated as `^[a-z][a-z0-9_]*$` — anti-injection guard so an operator-controlled key can't smuggle additional directives.\n\n### Custom image (extensions baked)\n\nFor extensions not in the official `postgres:` image (pgvector, postgis), point `image` at a custom build:\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  image = \"ghcr.io/clowk/pg16-pgvector:1.2.0\"\n}\n```\n\nTwo ways to produce that image:\n\n**(A) Pre-built and pushed to a registry** — your CI runs `docker build` + `docker push`. HCL just references the tag.\n\n**(B) Inline build via voodu** — the controller's statefulset kind accepts `dockerfile` / `workdir` / `path` / `lang { }`. voodu apply streams your source over SSH, runs `docker build`, tags `clowk-lp-db:latest`, deploys:\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  workdir    = \"infra/postgres\"\n  dockerfile = \"Dockerfile.pg\"\n  replicas   = 3\n\n  lang {\n    name = \"generic\"\n    build_args = {\n      PG_MAJOR = \"16\"\n    }\n  }\n}\n```\n\n`infra/postgres/Dockerfile.pg`:\n\n```dockerfile\nFROM postgres:16\nRUN apt-get update \u0026\u0026 apt-get install -y postgresql-16-pgvector \\\n    \u0026\u0026 rm -rf /var/lib/apt/lists/*\n```\n\nThe same image must work as primary AND standby (standbys clone primary via `pg_basebackup`, no separate image).\n\nExtensions: enable inside the database via your app's migration system (Rails `enable_extension :pgvector`, Django RunSQL, etc.) OR with a one-off `vd postgres:psql \u003cref\u003e -c \"CREATE EXTENSION IF NOT EXISTS pgvector\"`.\n\n### Statefulset passthrough\n\nAnything the statefulset kind accepts flows through unchanged. Common cases:\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  image = \"postgres:16\"\n\n  # extra env vars (operator-supplied, merged with plugin's)\n  env = {\n    TZ        = \"America/Sao_Paulo\"\n    PGAPPNAME = \"clowk-lp\"\n  }\n\n  # config from shared buckets (e.g. shared AWS creds)\n  env_from = [\"aws/cli\", \"monitoring/secrets\"]\n\n  # custom health check (overrides plugin's pg_isready default)\n  health_check = \"pg_isready -U appuser -d appdata -p 5432\"\n\n  # kernel-level CPU/memory caps via cgroups\n  resources {\n    limits {\n      cpu    = \"2\"      # 2 cpus (or \"500m\" for 0.5 cpu, k8s-style millicores)\n      memory = \"4Gi\"    # 4 GiB (binary; \"4G\" for decimal SI; \"1024\" plain bytes)\n    }\n  }\n\n  # build-mode (instead of image = \"...\") — Dockerfile + workdir + lang { }\n  # see \"Custom image (extensions baked)\" below\n}\n```\n\nPlugin-owned fields (`database`, `user`, `password`, `port`, `initdb_locale`, `initdb_encoding`, `pg_config`, `extensions`, `replication_user`) are stripped from the merged spec before emitting — they don't leak to the statefulset wire shape.\n\n#### Resource limits\n\n`resources { limits { ... } }` translates to `docker run --cpus=\u003cn\u003e --memory=\u003cbytes\u003e` on every replica pod. Two layers of constraint apply to postgres:\n\n1. **Container-level (this block)** — kernel cap via cgroups. OOM-kills the postgres process if it exceeds memory limit. Sane budget = host RAM × 0.7 ÷ replicas, leaving headroom for other workloads.\n2. **App-level (`pg_config`)** — postgres-internal allocations. Should be SMALLER than the container cap so postgres self-limits before the kernel kills it.\n\nRecommended pairing for a `memory = \"4Gi\"` container limit:\n\n```hcl\nresources {\n  limits {\n    cpu    = \"2\"\n    memory = \"4Gi\"\n  }\n}\n\npg_config = {\n  shared_buffers       = \"1GB\"     # ~25% of container limit\n  effective_cache_size = \"3GB\"     # ~75% of container limit\n  work_mem             = \"16MB\"    # per-query, watch for high concurrency\n  max_connections      = 200\n}\n```\n\nValue formats accepted (k8s parity):\n\n| Type | Form | Examples |\n|---|---|---|\n| CPU | decimal | `\"2\"`, `\"1.5\"`, `\"0.25\"` |\n| CPU | millicores | `\"500m\"` (= 0.5), `\"100m\"` (= 0.1) |\n| Memory | binary (1024-based, preferred) | `\"4Gi\"`, `\"512Mi\"`, `\"256Ki\"` |\n| Memory | decimal SI (1000-based) | `\"4G\"`, `\"500M\"`, `\"1500K\"` |\n| Memory | plain bytes | `\"4294967296\"` |\n\nOmit `resources { }` entirely or leave individual fields empty for \"no limit\" — docker daemon defaults apply (effectively unlimited until host RAM is exhausted).\n\n---\n\n## High availability — streaming replication\n\n### Cluster shape\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  image    = \"postgres:16\"\n  replicas = 3\n}\n```\n\nProduces 3 pods with stable identity:\n\n| Pod | Role | DNS | Per-pod data volume |\n|---|---|---|---|\n| `clowk-lp-db.0` | primary | `db-0.clowk-lp.voodu` | `voodu-clowk-lp-db-data-0` |\n| `clowk-lp-db.1` | standby | `db-1.clowk-lp.voodu` | `voodu-clowk-lp-db-data-1` |\n| `clowk-lp-db.2` | standby | `db-2.clowk-lp.voodu` | `voodu-clowk-lp-db-data-2` |\n\nPlus the round-robin shared alias `db.clowk-lp.voodu` resolving to all 3 pods (use sparingly — primary writes need pod-0 specifically).\n\nStreaming config baked into `voodu-50-streaming.conf`:\n\n```ini\nhot_standby      = on\nmax_wal_senders  = 10\nprimary_conninfo = 'host=db-0.clowk-lp.voodu port=5432 user=replicator password=\u003cauto-hex\u003e application_name=voodu-postgres-standby'\nwal_keep_size    = '1GB'\n```\n\nNo replication slots — `wal_keep_size = 1GB` is more predictable. Standbys that fall behind beyond 1GB get re-cloned via `pg_basebackup` on next pod restart (wrapper detects empty PGDATA and re-runs basebackup).\n\n### Cold-start sequence\n\n```\nt=0  pod-0 (primary) boots\n     → official docker-entrypoint.sh detects empty PGDATA → initdb\n     → /docker-entrypoint-initdb.d/00_create_replication.sh runs:\n        - CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD '...'\n        - append `host replication replicator all scram-sha-256` to pg_hba.conf\n     → postgres ready\n\nt=2s pod-1, pod-2 (standbys) boot in parallel\n     → wrapper detects ORDINAL != PRIMARY_ORDINAL\n     → loops on pg_isready waiting for db-0\n     → pg_basebackup -h db-0.clowk-lp.voodu -X stream → clones data dir\n     → touch standby.signal (postgres comes up in recovery mode)\n     → docker-entrypoint.sh sees PG_VERSION exists → skips initdb\n     → postgres starts, reads primary_conninfo from voodu-50-streaming.conf\n     → connects to primary, starts streaming WAL\n```\n\nStandby retry: if primary isn't ready (race during first cluster apply), the wrapper retries `pg_isready` up to 60 times × 5s sleep (~5min budget) before giving up. In practice the primary's initdb finishes well within that window.\n\n### Write vs read endpoints\n\n`vd postgres:link \u003cpostgres\u003e \u003cconsumer\u003e --reads` emits two URLs on the consumer:\n\n| Var | Targets | Use case |\n|---|---|---|\n| `DATABASE_URL` | `db-0.scope.voodu` (primary, ordinal 0) | All writes |\n| `DATABASE_READ_URL` | `db-1.scope.voodu,db-2.scope.voodu,...` (libpq multi-host) | Read queries |\n\n`DATABASE_READ_URL` uses postgres's native multi-host syntax with `target_session_attrs=any` — libpq tries hosts left-to-right; clients can shuffle the list for round-robin distribution.\n\nWithout `--reads`, only `DATABASE_URL` is emitted (single primary endpoint). Apps without read-replica logic just use that.\n\n### Promote a standby\n\n`vd postgres:promote` is a 2-step manual failover. Plugin owns ALL the postgres-side SQL — operator never types it:\n\n```bash\n# Step 1: PROMOTE — plugin runs lag check + pg_promote() + flips\n#         PG_PRIMARY_ORDINAL + refreshes DATABASE_URL on every\n#         linked consumer + rolling restart.\nvd postgres:promote clowk-lp/db --replica 1\n\n# Step 2: REJOIN — recover the OLD primary as a standby of the new one.\n#         Required because the rolling restart hits pod-0 with\n#         PG_PRIMARY_ORDINAL=1 but PGDATA still in primary state — the\n#         wrapper's split-brain guard catches it and refuses to start.\nvd postgres:rejoin clowk-lp/db --replica 0\n```\n\nWhat `promote` does internally:\n\n1. **Lag check** — queries `pg_stat_replication` on the current primary. Refuses if any standby is behind (`max_lag_bytes \u003e 0`) unless `--force` is passed.\n2. **pg_promote** — runs `SELECT pg_promote(true, 60)` inside the target container. Postgres exits recovery mode and becomes the new primary.\n3. **Wait** — polls `pg_is_in_recovery()` until it returns `false` (max 30s).\n4. **Bucket flip** — sets `PG_PRIMARY_ORDINAL=N` so the next expand re-renders `streaming.conf` with the new primary FQDN.\n5. **Refresh consumers** — every linked consumer's `DATABASE_URL[/_READ_URL]` is rewritten to point at the new primary; rolling restart picks up the change.\n\nFlags:\n- `--force` — promote despite replication lag (operator accepts data loss). Required when the current primary is unreachable (lag check itself would error).\n- `--no-restart` — flip the bucket + URLs but skip the rolling restart on the postgres pods. Useful for staged promotions (verify, then restart on demand).\n\nLegacy alias: `vd postgres:failover` still works (same handler), kept for backward-compat with scripts using the old name.\n\n### Rejoining the old primary\n\n`vd postgres:rejoin` runs `pg_rewind` against the current primary inside a one-shot container that shares the target pod's data volume (`docker run --volumes-from`). Steps:\n\n1. `docker stop \u003ccontainer\u003e` — pg_rewind requires the target offline\n2. `docker run --rm --volumes-from \u003ccontainer\u003e postgres:\u003cver\u003e pg_rewind --target-pgdata=… --source-server=\"host=\u003cprimary\u003e user=replicator …\"`\n3. `touch standby.signal` in the data dir\n4. `docker start \u003ccontainer\u003e` — pod boots as standby, picks up streaming from the new primary\n\n**When `pg_rewind` fails**: divergence too large (WAL recycled past the divergence point) yields `could not find previous WAL record at…`. Fallback:\n\n```bash\nvd delete clowk-lp/db --replica 0 --prune    # wipes the data volume\nvd apply -f voodu.hcl                         # wrapper bootstraps fresh standby via pg_basebackup\n```\n\nThe wrapper script's split-brain guard prevents accidental writes during this window — if a pod is configured as standby (`ORDINAL != PG_PRIMARY_ORDINAL`) but PGDATA was last used as primary (no `standby.signal`), it exits 1 with a clear message pointing at `vd postgres:rejoin`.\n\n---\n\n## Backup automation\n\n### `vd pg:backups` (Heroku-style, recommended)\n\n`pg_dump` snapshots managed by the plugin. The host directory `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e/` is bind-mounted at `/backups` inside every pod; backup files live there as `bNNN-\u003ctimestamp\u003e.dump` (custom format, restorable via `pg_restore`).\n\n#### Pre-flight (once per host)\n\n```bash\nsudo mkdir -p /opt/voodu/backups/clowk-lp/db\n# chown is handled by the entrypoint wrapper at boot — no manual chown needed\n```\n\n#### Capture a snapshot\n\n`pg:backups:capture` is **detached by default** — it spawns a sibling Docker container that runs `pg_dump` and returns the new backup ID immediately. The dump survives SSH drops, terminal closure, and Ctrl-C of the `vd` command.\n\n```bash\n# Default: detached, returns ID immediately\nvd pg:backups:capture clowk-lp/db\n# postgres clowk-lp/db: backup b008 capturing in background (container clowk-lp-db-backup-b008, source: ordinal 0). track via: vd pg:backups clowk-lp/db  |  vd pg:backups:logs clowk-lp/db b008 --follow\n\n# Foreground with rolling progress\nvd pg:backups:capture clowk-lp/db --follow\n\n# From a standby (offloads work) + follow\nvd pg:backups:capture clowk-lp/db --from-replica 1 --follow\n```\n\nOutput with `--follow`:\n\n```\ncapture: pg_dump → /backups/b008-20260505T143000Z.dump (db=postgres, user=postgres; raw size 487 MB, estimated dump ~243 MB, container clowk-lp-db-backup-b008)\n  [3s] 12.0 MB written (~5% of estimate)\n  [10s] 68.0 MB written (~28% of estimate)\n  [25s] 156.0 MB written (~64% of estimate)\n  [45s] 218.0 MB written (~90% of estimate)\ndone: 251.3 MB in 52s\npostgres clowk-lp/db: backup b008 captured (b008-20260505T143000Z.dump, 251.3 MB, source: ordinal 0, elapsed 52s)\n```\n\nThe `% of estimate` is a **hint, not a real percent**. pg_dump doesn't expose a true progress signal — the plugin queries `pg_database_size()` upfront and assumes ~50% compression (varies wildly with data shape). When the estimate is clearly wrong (\u003e200%), the suffix is dropped silently and you just see bytes-written + elapsed. The trustworthy numbers are `\u003cX\u003e MB written` and `[\u003celapsed\u003es]`.\n\n#### Container shape (capture internals)\n\nEach capture spawns a one-shot container named `\u003cscope\u003e-\u003cname\u003e-backup-bNNN` that:\n\n- Reuses the source pod's image (so `pg_dump` is available)\n- Shares the source pod's network namespace via `--network container:\u003csource\u003e` (postgres reachable at `127.0.0.1`)\n- Bind-mounts the same `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e` host path at `/backups`\n- Runs `pg_dump -F c -Z 6 -f /backups/\u003cfile\u003e`; on non-zero exit, removes the partial file\n\nAuto-prune: each `:capture` removes any **exited** sibling backup containers from prior runs (free up the bNNN slot for re-use after `:delete`). Running ones are never touched — concurrent captures with different IDs work fine.\n\n\u003e **Roadmap:** Once the controller exposes a runtime-dispatch action (`apply_manifest`), capture will migrate internally to emit a voodu `job` manifest instead of running `docker run` directly. UX stays identical; plumbing gets cleaner. See [the controller proposal](#) for status.\n\n#### List (with status)\n\n```bash\nvd pg:backups clowk-lp/db\n# === Backups for clowk-lp/db\n#\n#   ID     Status     Created at            Size\n#   b007   complete   2026-05-04T02:00:00Z  245.3 MB\n#   b008   running    2026-05-05T14:30:00Z  87.0 MB\n\n# JSON for jq pipelines\nvd pg:backups clowk-lp/db -o json | jq '.backups[] | {id, status, size_bytes}'\n```\n\nThe list merges two data sources:\n\n- **Files** in `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e/` (read via host bind-mount — works without a running pod) → `complete` captures.\n- **Backup containers** matched via `docker ps -a --filter name=\u003cscope\u003e-\u003cname\u003e-backup-` → in-progress (`running`) and recently-failed (`failed`).\n\nStatus reflects which side a given `bNNN` appears on:\n\n| Status | Container | File | Meaning |\n|---|---|---|---|\n| `running` | running | partial / absent | capture in flight |\n| `complete` | exited 0 (or pruned) | present | done, restorable |\n| `failed` | exited != 0 | absent (wrapper removed) | needs investigation via `pg:backups:logs` |\n\n#### Stream live logs\n\n```bash\n# One-shot read of accumulated container output\nvd pg:backups:logs clowk-lp/db b008\n\n# Tail in real-time (blocks until the container exits)\nvd pg:backups:logs clowk-lp/db b008 --follow\n```\n\nWraps `docker logs [-f] \u003cscope\u003e-\u003cname\u003e-backup-bNNN`. Works for `running`, `complete`, and `failed` captures — until the next `:capture` auto-prunes exited siblings.\n\n#### Restore from a backup\n\n```bash\n# Local backup ID\nvd pg:backups:restore clowk-lp/db b007 --yes\n\n# http(s) URL — e.g. an S3 presigned URL pointing at a pg_dump file\nvd pg:backups:restore clowk-lp/db https://my-bucket.s3.amazonaws.com/db.dump?... --yes\n```\n\nFor URL inputs, the plugin downloads the file to a host temp dir, `docker cp`s it into `/tmp/\u003crandom\u003e.dump` inside the primary, runs `pg_restore`, then cleans up both sides.\n\nRuns `pg_restore --clean --if-exists --no-owner --no-privileges` against the primary's database. **Destructive of database content** (drops + recreates every object the dump touches), but **non-destructive of the cluster** — pods stay up, standbys replicate the restored state via streaming WAL.\n\nCompared to the legacy `vd postgres:restore`:\n\n| | `pg:backups:restore` (Heroku-style) | `postgres:restore` (legacy) |\n|---|---|---|\n| Source | `pg_dump` custom format (in `/backups/`) | `pg_basebackup` tar (operator-supplied path) |\n| Destructive of | database content (objects in the dump) | entire cluster (PGDATA on every pod) |\n| Cluster downtime | none — primary stays up | full — every pod stopped, wiped, restarted |\n| Cross-version | yes (pg_dump portable) | no (pg_basebackup is exact bit copy) |\n| Cross-host | yes (pg_dump portable) | no (same major version + SSL state) |\n\nPick `pg:backups:restore` for routine \"rollback to last night\" scenarios; `postgres:restore` for \"salvage from a tar I have on a laptop after total host loss.\"\n\n#### Download a backup file\n\nShip a backup off-host (laptop, S3 via host CLI, another voodu host):\n\n```bash\nvd pg:backups:download clowk-lp/db b007\n# → ./b007-20260507T020000Z.dump in CWD\n\nvd pg:backups:download clowk-lp/db b007 --to /srv/archive/db.dump\n```\n\nBytes are copied verbatim via `docker cp`; the resulting file is a portable `pg_dump` custom format ready for `pg_restore` anywhere.\n\n#### Delete a backup\n\n```bash\nvd pg:backups:delete clowk-lp/db b007 --yes\n```\n\nRemoves `/backups/\u003cfilename\u003e` inside the pod (host bind-mount → file disappears from disk). Sequence IDs **don't** renumber — deleting `b005` leaves a gap, next capture is `b008` (max+1 of remaining). Operator's responsibility to ensure no off-host sync (S3, rsync) is mid-flight against the file.\n\n#### Schedule (paste-and-apply HCL helper)\n\n`pg:backups:schedule` prints the cronjob block ready to paste into `voodu.hcl`:\n\n```bash\nvd pg:backups:schedule clowk-lp/db --at \"0 3 * * *\" --from-replica 1\n```\n\n```hcl\n# Add this to your voodu.hcl, then run `vd apply`:\n\ncronjob \"clowk-lp\" \"db-backup\" {\n  schedule = \"0 3 * * *\"\n  image    = \"ghcr.io/clowk/voodu-cli:latest\"\n\n  command = [\"bash\", \"-c\", \"vd pg:backups:capture clowk-lp/db --from-replica 1\"]\n}\n\n# To remove the schedule later: delete the block + run `vd apply --prune`.\n```\n\n`pg:backups:schedule` itself emits no manifests and writes nothing — it's purely a templating helper. Voodu cronjobs are declarative (HCL is the source of truth); managed-schedule state plugin-side would diverge from HCL on the next `vd apply --prune`.\n\nCombine with off-host sync (rclone, aws s3 sync, rsync) in a sibling cronjob mounting the same host path read-only.\n\n#### Cancel an in-progress capture\n\n```bash\n# Stop everything in flight for clowk-lp/db\nvd pg:backups:cancel clowk-lp/db\n# postgres clowk-lp/db: stopped 1 capture\n\n# Stop a specific capture\nvd pg:backups:cancel clowk-lp/db b008\n```\n\nRuns `docker stop` on the matching `\u003cscope\u003e-\u003cname\u003e-backup-bNNN` containers. The container's `pg_dump` receives SIGTERM (10s grace), then SIGKILL. Postgres detects the dropped connection and rolls back — non-destructive of the cluster.\n\nThe container's wrapper script removes the partial dump file on non-zero exit, so a cancelled capture leaves no garbage in `/backups/`.\n\n#### Retention (`--keep` / `--max-age`)\n\nBackups grow unboundedly without intervention — every `:capture` writes a new `.dump` and leaves the exited job container behind. On a tight host (e.g. dev/test on a single 80GB HD) you want both:\n\n- a **count cap** so the most recent N captures stay around regardless of cadence, and\n- an **age cap** so really old snapshots get reaped even if you've barely been capturing.\n\nTwo CLI flags cover both axes; the plugin auto-prunes after each capture and on demand via `:prune`. Both flags accept the same shapes everywhere they appear.\n\n**Flags:**\n\n| Flag | Meaning | Examples |\n|---|---|---|\n| `--keep \u003cN\u003e` | Keep at most N most-recent backups | `--keep 30` |\n| `--max-age \u003cD\u003e` | Drop anything older than D | `--max-age 7d`, `--max-age 2w`, `--max-age 168h` |\n| `--retention \u003cD\u003e` | Alias for `--max-age` | same as above |\n\nA backup is deleted if it fails **either** check (rank \u003e N **or** age \u003e D). Both flags are optional — pass none = no auto-prune.\n\n**Persisted defaults via the config bucket:**\n\nThe plugin seeds `BACKUP_KEEP=30` on first apply. Subsequent `:capture` invocations pick this up automatically — no flags needed:\n\n```bash\n# First apply seeds BACKUP_KEEP=30 in the bucket\nvd apply\n\n# This single command captures + auto-prunes to 30 newest\nvd pg:backups:capture clowk-lp/db\n# postgres clowk-lp/db: backup b031 captured ...\n# retention (keep 30): pruned 1 backup(s): b001\n```\n\nOverride the persisted policy any time:\n\n```bash\n# Lower cap for a specific cluster\nvd config clowk-lp/db set BACKUP_KEEP=14\n\n# Add age cap on top\nvd config clowk-lp/db set BACKUP_MAX_AGE=14d\n\n# Disable auto-prune entirely (capture stops touching old files)\nvd config clowk-lp/db set BACKUP_KEEP=0\nvd config clowk-lp/db set BACKUP_MAX_AGE=\n```\n\nPrecedence per axis:\n\n1. **CLI flag** (`--keep`, `--max-age`) — wins for that axis only\n2. **App-level bucket** (`vd config \u003cscope\u003e/\u003cname\u003e set ...`) — per-resource\n3. **Scope-level bucket** (`vd config \u003cscope\u003e set ...`) — shared default for every resource in the scope\n4. **Built-in default** — empty (no auto-prune); plugin seeds `BACKUP_KEEP=30` only at first apply, app-level\n\nPer-axis means `--keep 5` for a one-off doesn't blow away the bucket's `BACKUP_MAX_AGE=14d` — only the count axis gets overridden for that invocation.\n\n**Shared defaults via scope-level config:**\n\nThe controller's `ResolveConfig` merges **scope-level + app-level** (app wins on conflict) and the plugin reads through this — no `env_from` plumbing needed. Set the policy once for an entire scope:\n\n```bash\n# All postgres resources in scope `clowk-lp` inherit these\nvd config clowk-lp set BACKUP_KEEP=14\nvd config clowk-lp set BACKUP_MAX_AGE=7d\n\n# Override one specific cluster\nvd config clowk-lp/db set BACKUP_KEEP=30\n```\n\n\u003e **Why not `env_from`:** the plugin reads policy via `GET /config` (controller's bucket store) BEFORE the capture container is spawned. `env_from` is a runtime container-env injection mechanism — different code path. Use scope-level config for shared defaults.\n\n**On-demand prune (`:prune`):**\n\n```bash\n# Preview without touching disk — uses bucket defaults if no flags\nvd pg:backups:prune clowk-lp/db --dry-run\n\n# Tighter one-off\nvd pg:backups:prune clowk-lp/db --keep 5 --yes\n\n# Both axes, explicit\nvd pg:backups:prune clowk-lp/db --keep 14 --max-age 7d --yes\n```\n\n`:prune` reads the bucket the same way as `:capture`. With `BACKUP_KEEP=30` already set, `vd pg:backups:prune clowk-lp/db --yes` is enough — no flags needed.\n\n`--yes` is required for destructive runs; `--dry-run` skips the confirmation gate AND the disk operations, useful for cron previewing.\n\n**What gets pruned:**\n\nFor each backup that fails the policy:\n\n1. The `.dump` file in `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e/` is removed.\n2. The matching exited backup container (from `docker ps -a`) is `docker rm`'d so `vd get pd` stays clean.\n\nRunning containers are always skipped — never reap an in-flight capture.\n\n**Schedule with retention inlined:**\n\n`:schedule` accepts the same flags and inlines them into the cronjob's command, so the cron-driven captures self-trim:\n\n```bash\nvd pg:backups:schedule clowk-lp/db --at \"0 3 * * *\" --keep 30 --max-age 14d\n```\n\n```hcl\ncronjob \"clowk-lp\" \"db-backup\" {\n  schedule = \"0 3 * * *\"\n  image    = \"ghcr.io/clowk/voodu-cli:latest\"\n\n  command = [\"bash\", \"-c\", \"vd pg:backups:capture clowk-lp/db --keep 30 --max-age 14d\"]\n}\n```\n\nYou can also rely on the bucket-persisted defaults and skip the flags here — the cronjob will pick up `BACKUP_KEEP` / `BACKUP_MAX_AGE` from the config bucket the same way an interactive `:capture` does.\n\n#### Off-site durability\n\n```hcl\ncronjob \"clowk-lp\" \"db-backups-s3-sync\" {\n  schedule = \"*/30 * * * *\"\n  image    = \"amazon/aws-cli:latest\"\n  command  = [\"s3\", \"sync\", \"/backups/\", \"s3://my-bucket/postgres/clowk-lp-db/\"]\n  volumes  = [\"/opt/voodu/backups/clowk-lp/db:/backups:ro\"]\n  env_from = [\"aws/cli\"]\n}\n```\n\nBackup files are immutable once written, so `s3 sync` never re-uploads.\n\n### `vd postgres:backup` / `vd postgres:restore` (legacy, pg_basebackup)\n\nThe original commands run `pg_basebackup` (physical snapshot) instead of `pg_dump` (logical dump). They still work and are the fastest path for full-cluster restores, but require operator-supplied paths and don't integrate with the `/backups` directory.\n\n```bash\n# pg_basebackup tar to operator-chosen path\nvd postgres:backup clowk-lp/db --destination /srv/backups/db-20260504.tar\n\n# Restore (DESTRUCTIVE — wipes every pod's PGDATA)\nvd postgres:restore clowk-lp/db --from /srv/backups/db.tar --yes\n```\n\nOutput is a tar with `base.tar` + `pg_wal.tar` (`-X stream` includes WAL needed to make the backup self-consistent). Point-in-snapshot only — no PITR.\n\n\u003e **`pg:backups:restore` / `:download` / `:delete` / `:schedule` will replace this surface in the next iteration.**\n\n\n---\n\n## Real-world examples\n\n### Single primary + Rails app\n\n```hcl\n# voodu.hcl\npostgres \"clowk-lp\" \"db\" {\n  image    = \"postgres:16\"\n  database = \"appdata\"\n  user     = \"appuser\"\n}\n\ndeployment \"clowk-lp\" \"web\" {\n  image = \"ghcr.io/clowk/web:latest\"\n\n  env = {\n    RAILS_ENV = \"production\"\n  }\n\n  ports = [\"3000\"]\n}\n```\n\n```bash\nvd apply -f voodu.hcl\nvd postgres:link clowk-lp/db clowk-lp/web\n\n# Rails web pod now has DATABASE_URL set:\n# postgres://appuser:\u003chex\u003e@db-0.clowk-lp.voodu:5432/appdata\n```\n\n### 3-replica cluster + Rails MultiDB\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  image    = \"postgres:16\"\n  database = \"appdata\"\n  user     = \"appuser\"\n  replicas = 3                        # 1 primary + 2 standbys\n}\n\ndeployment \"clowk-lp\" \"web\" {\n  image = \"ghcr.io/clowk/web:latest\"\n  ports = [\"3000\"]\n}\n```\n\n```bash\nvd apply -f voodu.hcl\nvd postgres:link clowk-lp/db clowk-lp/web --reads\n\n# web now has both:\n# DATABASE_URL      = postgres://...@db-0.clowk-lp.voodu:5432/appdata\n# DATABASE_READ_URL = postgres://...@db-1.clowk-lp.voodu:5432,db-2.clowk-lp.voodu:5432/appdata?target_session_attrs=any\n```\n\n`config/database.yml`:\n\n```yaml\nproduction:\n  primary:\n    url: \u003c%= ENV[\"DATABASE_URL\"] %\u003e\n  primary_replica:\n    url: \u003c%= ENV[\"DATABASE_READ_URL\"] %\u003e\n    replica: true\n```\n\n`app/models/application_record.rb`:\n\n```ruby\nclass ApplicationRecord \u003c ActiveRecord::Base\n  primary_abstract_class\n\n  connects_to database: { writing: :primary, reading: :primary_replica }\nend\n```\n\n### Custom image with pgvector\n\n```hcl\npostgres \"clowk-lp\" \"db\" {\n  workdir    = \"infra/postgres\"\n  dockerfile = \"Dockerfile.pg\"\n  replicas   = 3\n\n  lang {\n    name = \"generic\"\n  }\n}\n```\n\n`infra/postgres/Dockerfile.pg`:\n\n```dockerfile\nFROM postgres:16\nRUN apt-get update \\\n \u0026\u0026 apt-get install -y postgresql-16-pgvector \\\n \u0026\u0026 rm -rf /var/lib/apt/lists/*\n```\n\n```bash\nvd apply -f voodu.hcl\n# voodu streams the build context, runs `docker build`, tags\n# clowk-lp-db:latest, deploys to the 3 pods.\n\n# Enable in your app's migrations:\n# Rails:  enable_extension :pgvector\n# Django: from pgvector.django import VectorField\n\n# OR inline:\nvd postgres:psql clowk-lp/db -c \"CREATE EXTENSION IF NOT EXISTS pgvector\"\n```\n\n### External access for DBeaver / TablePlus\n\n```bash\nvd postgres:expose clowk-lp/db\n# postgres clowk-lp/db now exposed on 0.0.0.0:\u003cport\u003e — pod restart triggered.\n# Reminder: rotate password (vd postgres:new-password clowk-lp/db) if it\n# ever leaked anywhere untrusted.\n\n# Get the password\nPW=$(vd config clowk-lp/db get POSTGRES_PASSWORD -o json | jq -r .POSTGRES_PASSWORD)\n\n# Connect from your laptop\npsql \"postgres://postgres:$PW@my-vm-ip:5432/postgres\"\n\n# Done? un-expose\nvd postgres:unexpose clowk-lp/db\n```\n\n⚠ Verify your firewall (ufw, security group, iptables) before exposing. The plugin flips the bind from `127.0.0.1` to `0.0.0.0`; the host firewall still controls who can reach the port from outside.\n\n---\n\n## Plugin reference\n\n### Commands\n\n| Command | Purpose |\n|---|---|\n| `vd postgres:link \u003cprovider\u003e \u003cconsumer\u003e [--reads]` | Wire `DATABASE_URL` (and optionally `DATABASE_READ_URL`) into a consumer |\n| `vd postgres:unlink \u003cprovider\u003e \u003cconsumer\u003e` | Remove the link |\n| `vd postgres:new-password \u003cpostgres\u003e [--no-restart]` | Rotate superuser password + auto-refresh every linked consumer |\n| `vd postgres:info \u003cpostgres\u003e [-o json]` | Cluster topology snapshot (text or JSON) |\n| `vd postgres:expose \u003cpostgres\u003e` | Publish on 0.0.0.0:`\u003cport\u003e` (Internet-facing) |\n| `vd postgres:unexpose \u003cpostgres\u003e` | Return to 127.0.0.1:`\u003cport\u003e` (loopback only) |\n| `vd postgres:promote \u003cpostgres\u003e --replica \u003cN\u003e [--force] [--no-restart]` | Promote a standby to primary (plugin runs `pg_promote()` internally; refuses on lag without `--force`) |\n| `vd postgres:rejoin \u003cpostgres\u003e --replica \u003cN\u003e` | Re-attach a divergent pod as standby via `pg_rewind` |\n| `vd postgres:psql \u003cpostgres\u003e [--replica N] [-c \"\u003csql\u003e\"]` | Drop into psql against the cluster (no password needed) |\n| `vd pg:backups \u003cpostgres\u003e [-o json]` | List backups + status (running/complete/failed) |\n| `vd pg:backups:capture \u003cpostgres\u003e [--from-replica N] [--follow] [--keep N] [--max-age D]` | Spawn pg_dump in a sibling container (detached default); auto-prunes after success when retention flags or `BACKUP_KEEP`/`BACKUP_MAX_AGE` set |\n| `vd pg:backups:logs \u003cpostgres\u003e \u003cid\u003e [--follow]` | docker logs on a backup container |\n| `vd pg:backups:restore \u003cpostgres\u003e \u003cid\\|url\u003e --yes` | `pg_restore` from local id or http(s) URL (DESTRUCTIVE of db content) |\n| `vd pg:backups:download \u003cpostgres\u003e \u003cid\u003e [--to \u003cpath\u003e]` | Copy a backup file from the pod to the host |\n| `vd pg:backups:delete \u003cpostgres\u003e \u003cid\u003e --yes` | Remove a backup file from `/backups/` |\n| `vd pg:backups:prune \u003cpostgres\u003e [--keep N] [--max-age D] [--dry-run] [--yes]` | Apply retention on demand (file + container cleanup) |\n| `vd pg:backups:schedule \u003cpostgres\u003e [--at \u003ccron\u003e] [--from-replica N] [--keep N] [--max-age D]` | Print cronjob HCL template for paste-and-apply (retention inlined into the captured command) |\n| `vd pg:backups:cancel \u003cpostgres\u003e [\u003cid\u003e]` | docker stop running capture(s) |\n| `vd postgres:backup \u003cpostgres\u003e --destination \u003cpath\u003e [--from-replica N]` | (legacy) `pg_basebackup` snapshot to a tar file |\n| `vd postgres:restore \u003cpostgres\u003e --from \u003cpath\u003e --yes` | (legacy) Restore PGDATA from a tar (DESTRUCTIVE of cluster) |\n| `vd postgres:help` | Plugin overview |\n\nPass `--help` to any subcommand for full usage.\n\n### Asset files emitted\n\nThe plugin emits one asset per postgres resource with 4 files:\n\n| Key | Mount path | Loaded by |\n|---|---|---|\n| `entrypoint` | `/usr/local/bin/voodu-postgres-entrypoint` | `command = [\"bash\", ...]` |\n| `pg_overrides_conf` | `/etc/postgresql/voodu-99-overrides.conf` | postgres `include_dir` (operator pg_config) |\n| `streaming_conf` | `/etc/postgresql/voodu-50-streaming.conf` | postgres `include_dir` (primary_conninfo, hot_standby) |\n| `init_replication_sh` | `/docker-entrypoint-initdb.d/00_create_replication.sh` | docker-entrypoint.sh on first boot of primary only |\n\nThe `voodu-NN-` prefixes order the include_dir scan: plugin defaults (50) load before operator overrides (99). \"Last config wins\" → operator pg_config trumps plugin defaults cleanly.\n\n### Statefulset env vars\n\n| Var | Source | Used by |\n|---|---|---|\n| `POSTGRES_USER` / `POSTGRES_DB` / `POSTGRES_PASSWORD` | plugin (HCL + auto-gen) | docker-entrypoint.sh (initdb) |\n| `POSTGRES_INITDB_ARGS` | plugin (`--locale=... --encoding=...`) | docker-entrypoint.sh |\n| `PGDATA` | plugin (`/var/lib/postgresql/data/pgdata`) | postgres |\n| `PG_PORT` | plugin (HCL `port`) | wrapper script (`-p`) |\n| `PG_NAME` / `PG_SCOPE_SUFFIX` | plugin (resource ref) | wrapper script (FQDN composition) |\n| `PG_PRIMARY_ORDINAL` | plugin (default 0; M-P5 will flip via failover) | wrapper script (role detection) |\n| `PG_REPLICATION_USER` / `PG_REPLICATION_PASSWORD` | plugin (HCL + auto-gen) | wrapper script (`pg_basebackup`), init script (`CREATE USER`) |\n| `VOODU_REPLICA_ORDINAL` | controller (per-pod) | wrapper script (role detection) |\n\n### Bucket keys (config)\n\n| Key | Owner | Purpose |\n|---|---|---|\n| `POSTGRES_PASSWORD` | plugin auto-gen | Superuser password — read by `link`, rotated by `new-password` |\n| `POSTGRES_REPLICATION_PASSWORD` | plugin auto-gen | Replication user password — read by streaming.conf renderer + `rejoin` |\n| `POSTGRES_LINKED_CONSUMERS` | `vd postgres:link/unlink` | Comma-separated `\u003cscope\u003e/\u003cname\u003e` refs for `new-password`/`failover` fan-out |\n| `PG_EXPOSE_PUBLIC` | `vd postgres:expose/unexpose` | `\"true\"` flips ports to 0.0.0.0; absent = loopback |\n| `PG_PRIMARY_ORDINAL` | `vd postgres:failover` | Current primary ordinal (default `0`); flipped by failover, read by wrapper script + streaming.conf renderer |\n| `BACKUP_KEEP` | seeded by plugin (default `30`); operator-tunable | Cap on backup count for auto-prune. `0` / empty disables count-based pruning. Read by `:capture` and `:prune`. |\n| `BACKUP_MAX_AGE` | operator | Cap on backup age for auto-prune. Accepts `7d`, `2w`, `168h`. Empty disables age-based pruning. Read by `:capture` and `:prune`. |\n\nOperator can read all of these via `vd config \u003cref\u003e`. Setting them manually before first apply pre-seeds (useful for dev environments wanting deterministic passwords).\n\n**Scope-level inheritance:** any of these keys can be set at the scope level (`vd config \u003cscope\u003e set KEY=VAL` — no `/\u003cname\u003e`) and the plugin will pick them up. App-level (`vd config \u003cscope\u003e/\u003cname\u003e set ...`) wins on conflict. Useful for shared defaults like `BACKUP_KEEP`/`BACKUP_MAX_AGE` across every postgres in a scope without touching each resource bucket.\n\n**`env_from` does NOT apply to plugin reads.** `env_from` injects vars into the container's runtime environment; plugins read through the controller's `GET /config` (etcd store). For shared defaults, use scope-level config above.\n\n### Repo layout\n\n```\ncmd/voodu-postgres/\n  main.go               # entrypoint (subcommand switch)\n  postgres.go           # postgresSpec parser/validator + plugin-owned strip\n  entrypoint.go         # bash wrapper renderer (role-aware)\n  pgconfig.go           # postgresql.conf overrides renderer\n  password.go           # superuser password lifecycle\n  replication.go        # replication password lifecycle + streaming.conf + init script\n  controller.go         # invocationContext + controllerClient\n  link.go               # cmdLink + cmdUnlink + URL builder + linked consumers\n  new_password.go       # cmdNewPassword + auto-refresh consumers\n  info.go               # cmdInfo (text + JSON snapshot)\n  expose.go             # cmdExpose + cmdUnexpose\n  help.go               # plugin overview\n  *_test.go             # unit + integration tests\nbin/                    # wrappers + the binary\nplugin.yml              # plugin metadata (declares commands)\nMakefile                # build / test / install-local\ninstall                 # post-install hook\nuninstall               # pre-uninstall hook\n```\n\n### Development\n\n```bash\nmake build               # produces bin/voodu-postgres\nmake test                # go test ./...\nmake lint                # go vet ./...\nmake cross               # cross-compile linux/amd64 + linux/arm64\n\n# Smoke test expand without dispatch\necho '{\"kind\":\"postgres\",\"scope\":\"x\",\"name\":\"y\",\"spec\":{\"replicas\":3}}' \\\n  | bin/voodu-postgres expand | jq\n\n# Smoke test help texts\nbin/voodu-postgres link --help\nbin/voodu-postgres expose --help\n\n# Install into a local plugins root for E2E\nmake install-local PLUGINS_ROOT=/opt/voodu/plugins\n```\n\n---\n\n## Install \u0026 upgrade\n\n```bash\nvd plugins:install thadeu/voodu-postgres\nvd plugins:install thadeu/voodu-postgres --version 0.3.0\n```\n\nThe plugin ships pre-built linux/amd64 + linux/arm64 binaries via GitHub Releases. The install hook downloads the right one for the host arch and drops it into `$VOODU_PLUGIN_DIR/postgres/bin/`.\n\n## Storage\n\nThe plugin uses two storage shapes:\n\n### Per-pod data (statefulset semantics)\n\nEach pod gets its own `data` volume — postgres' authoritative data directory. Pod restarts re-attach to the same volume; scale-down preserves it; `vd delete --prune` is the only thing that destroys it.\n\n| Claim | Volume name (ordinal 0) | Mount path | Survives |\n|---|---|---|---|\n| `data` | `voodu-\u003cscope\u003e-\u003cname\u003e-data-0` | `/var/lib/postgresql/data` | pod restart, scale-down |\n\nStandbys (ordinal 1+) get their own per-pod `data` volume (`voodu-\u003cscope\u003e-\u003cname\u003e-data-1`, `-2`, …). Each pod is independent — losing one pod's data volume forces a re-bootstrap from the primary via `pg_basebackup`, but the others stay healthy.\n\n### Backups directory (shared host bind-mount)\n\nEvery pod mounts `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e/` from the host at `/backups` inside the container (rw on every pod). `vd pg:backups:capture` writes `pg_dump` files here; cronjobs sync the dir to off-host storage.\n\n| Source | Container path | Mount mode | Default |\n|---|---|---|---|\n| host bind-mount | `/backups` | `:rw` (all pods) | `/opt/voodu/backups/\u003cscope\u003e/\u003cname\u003e` |\n\n**Pre-flight (operator, once per host)**:\n\n```bash\nsudo mkdir -p /opt/voodu/backups/clowk-lp/db\n```\n\nOwnership (`postgres:postgres`, uid 999) is auto-fixed by the entrypoint wrapper at boot — no manual `chown` needed.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthadeu%2Fvoodu-postgres","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthadeu%2Fvoodu-postgres","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthadeu%2Fvoodu-postgres/lists"}