CNPG Consolidation
How 21 single-purpose PostgreSQL clusters became 3, over 2026-07-28 and 2026-07-29. This is a retrospective record: what the estate looked like, the procedure that moved each database, the exact commands, the traps that cost time, and what is still outstanding.
Summary
| Before | After | |
|---|---|---|
| PostgreSQL clusters | 21 | 3 |
| Postgres pods | 41 | 6 |
| Namespaces holding databases | 2 (cloudnative-pg, database) | 1 (database) |
| CNPG operators | 2 | 1 |
| Databases served | 21 | 18 |
| Backup streams to configure | 21 | 3 |
| Consumer components | 1 (cnpg-db) | 2 (cnpg-db-shared, cnpg-db-database) |
Three databases were deleted rather than migrated (no consumer). One cluster stays dedicated. Total live data across all 18 databases is roughly 290 MB -- which is precisely why the consolidation was worth doing and why it was cheap.
The win was operational, not capacity: provisioned storage went from about 70 GiB to 60 GiB, essentially flat. What collapsed was the number of things to patch, back up, monitor, fail over and reason about.
Final shape
flowchart TD
subgraph DB["database namespace"]
OP[CNPG operator<br/>WATCH_NAMESPACE: database]
BARMAN[barman-cloud plugin]
OS[ObjectStore: garage<br/>s3://cnpg/]
SHARED[("shared<br/>pg18 stock<br/>11 tenants")]
AI[("ai<br/>vectorchord 18-1.1.1<br/>6 tenants")]
IMMICH[("immich<br/>vchord 16-1.1.1<br/>dedicated")]
end
subgraph Consumers["Application namespaces"]
C1[16 apps via<br/>cnpg-db-shared]
C2[immich via an<br/>inline ExternalSecret]
end
SHARED --> OS
AI --> OS
IMMICH --> OS
C1 -.reads tenant secret.-> SHARED
C1 -.reads tenant secret.-> AI
C2 -.reads CNPG secret.-> IMMICH
| Cluster | Image | Instances | Tenants |
|---|---|---|---|
shared | ghcr.io/cloudnative-pg/postgresql:18 | 2 | autobrr, firefly, forgejo, gatus, ghostfolio, house-hunter, miniflux, paperless, propagit, rackrat, rybbit |
ai | ghcr.io/tensorchord/cloudnative-vectorchord:18-1.1.1 | 2 | garrison, goat, litellm, memini, open-webui, phoenix |
immich | ghcr.io/tensorchord/cloudnative-vectorchord:16-1.1.1 | 2 | immich (dedicated) |
immich stays dedicated because it needs cube/earthdistance/vchord and
a superuser role -- its migrations create extensions themselves. Granting
SUPERUSER is defensible on a single-tenant cluster and not on a shared one.
Inventory: what happened to every database
Starting estate, 2026-07-28 07:03 -- 19 single-database clusters in
cloudnative-pg, plus immich (created 2026-07-24) and phoenix
(2026-07-25) already in database.
| Database | Source | Source PG | Destination | Outcome |
|---|---|---|---|---|
| miniflux | cloudnative-pg | 16 | shared | tenant (pilot) |
| gatus | cloudnative-pg | 16 | shared | tenant |
| autobrr | cloudnative-pg | 16 | shared | tenant |
| house_hunter | cloudnative-pg | 16 | shared | tenant |
| firefly | cloudnative-pg | 16 | shared | tenant |
| ghostfolio | cloudnative-pg | 16 | shared | tenant |
| paperless | cloudnative-pg | 16 | shared | tenant |
| rackrat | cloudnative-pg | 18 | shared | tenant |
| litellm | cloudnative-pg | 17 | ai | tenant |
| open_webui | cloudnative-pg | 17 | ai | tenant |
| garrison | cloudnative-pg | 18 | ai | tenant |
| goat | cloudnative-pg | 18 | ai | tenant |
| memini | cloudnative-pg | vchord 0.5.1 | ai | tenant (only one with extensions) |
| phoenix | database (dedicated) | 18 | ai | tenant |
| forgejo | cloudnative-pg → dedicated | 17 | shared | tenant (two hops) |
| propagit | cloudnative-pg → dedicated | 17 | shared | tenant (two hops) |
| rybbit | cloudnative-pg → dedicated | 17 | shared | tenant (two hops) |
| immich | database | vchord 16 | -- | stays dedicated |
| backstage | cloudnative-pg | 16 | -- | deleted, empty, no consumer |
| mattermost | cloudnative-pg | 16 | -- | deleted, 21 MB, no consumer |
| temporal | cloudnative-pg | 17 | -- | deleted, unused |
Target architecture
Tenancy model
Tenants are declared as Database + DatabaseRole CRs in their own file under
kubernetes/apps/pitower/database/tenants/, never as spec.managed.roles
entries on the Cluster. This is the single most important design choice: it
means onboarding an app never mutates the Cluster resource, so the primary is
never rolled to add a tenant.
The tenants directory is also its own ArgoCD Application, separate from
clusters/, so a tenant change can never show up as a diff against a Cluster.
The tenant credential Secret
One kubernetes.io/basic-auth Secret per tenant in database, named
<cluster>-<app> (e.g. shared-miniflux), doing double duty:
- CNPG reads
username/passwordfrom it viaDatabaseRole.spec.passwordSecret - the consuming app's ExternalSecret extracts all five keys through the
cnpg-secrets-databaseClusterSecretStore
Extra keys (host, port, dbname) alongside username/password are accepted
by the API server on a basic-auth Secret -- verified by server dry-run. Because
the FQDN lives in the Secret, components/cnpg-db-shared templates no
namespace at all, which is what allowed consumers to migrate one at a time
instead of in a single 17-app commit.
Note the key is username, not the user that CNPG's own -app Secrets use.
That difference is the whole reason two consumer components exist:
| Component | Source secret | Key for user | Host in secret |
|---|---|---|---|
cnpg-db-shared | hand-authored <cluster>-<app> | username | fully qualified |
cnpg-db-database | CNPG-generated <cluster>-app | user | bare service name |
Both are consumed identically by the app -- they emit the same
DB_HOST/DB_PORT/DB_USER/DB_PASS/DB_NAME/DB_URL Secret -- so flipping
an app between them is a two-line kustomization change.
Backups
One ObjectStore (garage) serves every cluster. Barman namespaces each
cluster's backups under its own serverName, which defaults to the cluster
name, so shared and ai land in s3://cnpg/shared/ and s3://cnpg/ai/
without extra configuration. spec.configuration.serverName must stay unset.
WAL archiving is continuous via the isWALArchiver plugin entry.
ScheduledBackup only sets how far back a PITR must replay from. Its schedule
is six fields, not five -- CNPG prepends a seconds field.
The migration procedure
This is the canonical per-app sequence, validated on all 17 tenants.
1. Seed or mint the tenant password
Two cases:
- Source is a hand-managed credential (the 13 apps from
cloudnative-pg): seed the app's existing password into Infisical so credentials do not change across the cutover. - Source is CNPG-generated (phoenix, forgejo, propagit, rybbit): there is nothing to preserve. Mint a new one.
Minting, as run for the final batch:
for app in FORGEJO PROPAGIT RYBBIT; do
pw=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 64)
env -u INFISICAL_TOKEN infisical secrets set "${app}_PASSWORD=$pw" \
--path=/database/tenants --env=prod --silent >/dev/null 2>&1 \
&& echo "set ${app}_PASSWORD" || echo "FAILED ${app}"
doneThe value is never printed. env -u INFISICAL_TOKEN is load-bearing: a stale
INFISICAL_TOKEN in the environment overrides the logged-in session and fails
with a confusing 404 on a service token that no longer exists.
2. Commit the tenant file
ExternalSecret + Database + DatabaseRole, added to
tenants/kustomization.yaml. Wait for both CRs to report applied: true:
kubectl --context=admin@pitower -n database get database,databaserole3. Hold the writers at zero, in git
kubectl scale does not hold. Replicas are part of the rendered manifest,
so the next automated ArgoCD sync restores them (selfHeal: false does not
prevent this) and the app resumes writing to the source mid-dump. Editing the
Application's syncPolicy does not help either -- it is ApplicationSet-generated
and gets reasserted. Declaring it in git is the only durable hold.
For app-template charts, in values.yaml:
controllers:
backend:
replicas: 0For forgejo, whose chart schema forbids it, a kustomize patch on the rendered Deployment (see Traps):
patches:
- target:
kind: Deployment
name: forgejo
patch: |
- op: replace
path: /spec/replicas
value: 0Always confirm the render, never assume:
kustomize build --enable-helm kubernetes/apps/pitower/dev/forgejo \
| yq 'select(.kind=="Deployment" or .kind=="StatefulSet")
| [.kind, .metadata.name, .spec.replicas]' -o=json -I=04. Confirm the source is quiesced
Verify every writer, which is not always one Deployment. garrison runs
keep + herald + warhorn, one schema each, all writing directly. goat runs
api + hooks. Apps on the custom oci://ghcr.io/swibrow/charts charts take
per-service knobs (keep.replicas, api.replicas), not the app-template
controllers.<name>.replicas.
for c in forgejo propagit rybbit; do
echo -n "$c: "
kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
psql -U postgres -tAc \
"select coalesce(string_agg(usename||'/'||coalesce(client_addr::text,'local'),','),'NONE')
from pg_stat_activity where datname='$c';"
doneExpect NONE for all.
5. Capture the baseline
Three fingerprints per database, taken from the source before the dump. Object counts alone are not enough -- a wrong-schema restore passes every one of them -- so the column signature is captured too.
COUNTS="select table_schema||'.'||table_name||' '||
(xpath('/row/c/text()', query_to_xml(
format('select count(*) as c from %I.%I', table_schema, table_name),
false, true, '')))[1]::text::bigint
from information_schema.tables
where table_type='BASE TABLE'
and table_schema not in ('pg_catalog','information_schema')
order by 1;"
COLS="select table_schema||'|'||table_name||'|'||column_name||'|'||data_type||'|'||
is_nullable||'|'||coalesce(column_default,'-')
from information_schema.columns
where table_schema not in ('pg_catalog','information_schema')
order by 1;"
OBJ="select 'indexes '||count(*) from pg_index i
join pg_class c on c.oid=i.indrelid
join pg_namespace n on n.oid=c.relnamespace
where n.nspname not in ('pg_catalog','information_schema')
union all
select 'sequences '||count(*) from information_schema.sequences
union all
select 'constraint_'||contype::text||' '||count(*) from pg_constraint co
join pg_namespace n on n.oid=co.connamespace
where n.nspname not in ('pg_catalog','information_schema')
group by contype
order by 1;"
for c in forgejo propagit rybbit; do
kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
psql -U postgres -d $c -tAc "$COUNTS" > src-$c-counts.txt
kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
psql -U postgres -d $c -tAc "$COLS" > src-$c-cols.txt
kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
psql -U postgres -d $c -tAc "$OBJ" > src-$c-obj.txt
doneAlso check whether the dump will emit ACL statements that the tenant role cannot replay:
kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
pg_dump -U postgres -d $c -s | rg -c '^(GRANT|REVOKE|ALTER DEFAULT)'6. Dump and restore in one pipe
The migration is logical, not physical. Merging N databases into one
cluster cannot use pg_basebackup, which is whole-cluster. pg_dump -Fc →
pg_restore crosses namespaces and major versions natively.
Run it from the target pod, so the newer pg_dump reads the older server
(the supported direction), and pipe it -- nothing touches disk, because CNPG
pods have a read-only /tmp.
for c in forgejo propagit rybbit; do
SRC=$(kubectl --context=admin@pitower -n database get secret $c-app \
-o jsonpath='{.data.password}' | base64 -d)
DST=$(kubectl --context=admin@pitower -n database get secret shared-$c \
-o jsonpath='{.data.password}' | base64 -d)
kubectl --context=admin@pitower -n database exec shared-1 -c postgres -- \
env SRC="$SRC" DST="$DST" APP="$c" sh -c '
PGPASSWORD="$SRC" pg_dump -Fc --no-acl \
-h '"$c"'-rw.database.svc.cluster.local -U "$APP" -d "$APP" \
| PGPASSWORD="$DST" pg_restore --no-owner --role="$APP" --exit-on-error \
-h shared-rw.database.svc.cluster.local -U "$APP" -d "$APP"
'
doneThree flags carry weight:
--no-acl-- every database bootstrapped by the old cluster ends its dump withALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public ... TO <app>. The tenant role may not replay a default-privilege rule owned bypostgres, so with--exit-on-errorand no--no-acl,pg_restoreaborts on them -- after all data, indexes and constraints have landed, which makes it look far worse than it is. The rules carry nothing forward: on the shared cluster the tenant owns the database, every table and every sequence.--exit-on-error-- without it a partial restore reports success.- Two passwords, not one. When both ends have independent credentials, the
single
PGPASSWORDtrick fails. Each pipeline stage is its own process, soenv SRC=.. DST=.. sh -c '...'gives each stage its own.
Extensions: dump with --exclude-extension, do not restore them. Only
memini had any (vector + vchord). Its dump emits CREATE EXTENSION IF NOT EXISTS and COMMENT ON EXTENSION; the tenant role owns neither extension, so
the COMMENT aborts the restore, and --no-acl does not skip it. Neither
shared nor ai sets enableSuperuserAccess, so there is no superuser to
restore as. The tenant's Database CR already declares the extensions and the
operator creates them as superuser before any restore.
7. Verify against the baseline
for c in forgejo propagit rybbit; do
kubectl --context=admin@pitower -n database exec shared-1 -c postgres -- \
psql -U postgres -d $c -tAc "$COUNTS" > dst-$c-counts.txt
# ... same for COLS and OBJ ...
echo "### $c"
diff src-$c-counts.txt dst-$c-counts.txt && echo "counts IDENTICAL"
diff src-$c-cols.txt dst-$c-cols.txt && echo "columns IDENTICAL"
diff src-$c-obj.txt dst-$c-obj.txt && echo "objects IDENTICAL"
donePlus sequence values, which no object count would catch:
SEQ="select schemaname||'.'||sequencename||' '||coalesce(last_value::text,'null')
from pg_sequences order by 1;"
diff <(kubectl --context=admin@pitower -n database exec $c-1 -c postgres -- \
psql -U postgres -d $c -tAc "$SEQ") \
<(kubectl --context=admin@pitower -n database exec shared-1 -c postgres -- \
psql -U postgres -d $c -tAc "$SEQ")Two verification rules learned the hard way:
- Do not compare
pg_constrainttotals across majors. pg18 catalogues NOT NULL ascontype='n'and pg16 does not, so a 37-vs-111 gap is expected. Compare percontype. - Do not trust
n_live_tupfor a baseline. It is stats-collector data and was stale by 3 tables on miniflux. Use realcount(*).
8. Flip the consumer
Two lines in the app's kustomization.yaml, releasing the replica hold in the
same commit:
components:
- - ../../../../components/cnpg-db-database
+ - ../../../../components/cnpg-db-shared
configMapGenerator:
- name: cnpg-db-config
literals:
- - CNPG_SECRET_KEY=forgejo-app
+ - CNPG_SECRET_KEY=shared-forgejoSECRET_NAME and the DB_URL key stay the same, so values.yaml normally
never needs editing. Except where a chart hardcodes the host -- see
Traps.
9. Verify the cutover
# derived secret points at the new cluster
kubectl --context=admin@pitower -n dev get secret forgejo-db-secret \
-o jsonpath='{.data.DB_HOST}' | base64 -d
# connections appear on the new cluster
kubectl --context=admin@pitower -n database exec shared-1 -c postgres -- \
psql -U postgres -c "select datname,usename,count(*) from pg_stat_activity
where datname in ('forgejo','propagit','rybbit')
group by 1,2 order by 1;"
# and are zero on the old
# ... same query against <app>-1 ...
# and a health endpoint that actually touches the database
curl -sS https://git.wibrow.dev/api/healthz | jq '.checks."database:ping"'The source cluster is only ever read from, so rollback at any point is reverting the flip commit.
Scripts
No scripts were committed to the repository. Everything was run as ad-hoc shell in a scratch directory, for two reasons: the SQL fingerprints needed tweaking per batch, and a committed migration script would be dead code the moment the migration finished.
What was made and run, in order of reuse value:
| Script | Purpose | Fate |
|---|---|---|
seed-tenant-passwords.sh | Read <app>-app in cloudnative-pg, write to Infisical via CLI, never print the value | Scratch, not committed |
| password-mint loop | /dev/urandom → Infisical for the CNPG-generated cases | Reproduced in step 1 |
| baseline/verify fingerprints | The three SQL blocks + diff | Reproduced in steps 5 and 7 |
| dump/restore pipe | The one-pipe migration | Reproduced in step 6 |
The database-toolbox generator (generate.sh, in
ai/toolhive/toolhive-config/mcpserver-database-toolbox.yaml) is committed
and did have to change: it was rewritten to enumerate credentials in
database and emit one genai-toolbox source per database rather than per
cluster, since shared and ai each host many. Coverage is now dynamic --
a new tenant is picked up on the next pod restart with no edits.
Timeline
Every commit, in order. All times local.
Phase 1 -- foundations (2026-07-28 07:03 → 08:40)
| Commit | Time | Change |
|---|---|---|
42267819 | 07:03 | Collapse to a single CNPG operator, owned by database |
54ac4243 | 08:23 | Deploy barman-cloud plugin into database |
61cb9af1 | 08:32 | Garage ObjectStore + namespace-agnostic db component |
eeadb68d | 08:33 | Add shared and ai multi-tenant clusters |
0194ec5d | 08:40 | Nightly base backups for shared and ai |
Phase 2 -- tenant migrations (10:02 → 13:21)
| Commit | Time | Change |
|---|---|---|
34a719fb | 10:02 | Onboard miniflux as the pilot tenant |
06f33576 | 13:21 | Onboard the remaining 12 tenants of shared and ai |
miniflux went first, alone, deliberately -- it validated the whole tenancy design end to end before 12 more were committed at once.
Phase 3 -- the dedicated detour (20:02 → 20:25)
| Commit | Time | Change |
|---|---|---|
29727246 | 20:02 | Add dedicated forgejo, propagit, rybbit, temporal clusters + cnpg-db-database |
207d890c | 20:05 | Hold propagit at zero |
a7383f6f | 20:07 | Cut propagit over |
f2a74bec | 20:08 | Hold rybbit backend at zero |
db21702d | 20:10 | Cut rybbit over |
58a69c70 | 20:12 | Hold forgejo at zero (failed -- see traps) |
ae86925a | 20:23 | Hold forgejo at zero with a patch, not replicaCount |
c599ddc5 | 20:25 | Cut forgejo over |
Phase 4 -- teardown (20:43 → 21:43)
| Commit | Time | Change |
|---|---|---|
fb01cedc | 20:43 | Remove temporal entirely |
ebb740b3 | 20:53 | Repoint database-toolbox, one source per database |
fc788ec4 | 21:25 | Delete the backstage and mattermost databases |
13b6b458 | 21:29 | Take over the CNPG CRDs and the shared ESO ClusterRole |
0c4284f7 | 21:41 | Retire the cloudnative-pg namespace |
ce9fe700 | 21:43 | Confine the operator to database |
The ordering here is the part that made it safe, and each step exists because the next one would otherwise have been destructive:
- An on-demand
Backupper destination cluster first, because the nightly ScheduledBackups had never fired and the old PVCs were about to be the only other copy. These are still visible as the*-premigration-cutoverBackups. - CRD + ClusterRole handover before deleting anything.
includeCRDs: truemoves to the surviving release; the chart stampshelm.sh/resource-policy: keepon all 12 CNPG CRDs and ArgoCD honours it, so deleting the old Application could not prune them out from under live clusters. Theexternal-secrets-pgClusterRole had no such protection and had to be re-declared -- it was defined next to the retiredcnpg-secretsstore but bound by the survivingcnpg-secrets-databaseone. - Only then delete the app directory, letting the ApplicationSet drop the Applications and prune 16 Clusters, pods and PVCs.
- Only then narrow
WATCH_NAMESPACE. Narrowing it first would have stranded the old Clusters in Terminating with no operator to run their finalizers. kubectl delete ns cloudnative-pgby hand -- ArgoCD created it viaCreateNamespace=true, so it was never in git.
Phase 5 -- phoenix (21:50 → 21:56)
| Commit | Time | Change |
|---|---|---|
4bf83cd7 | 21:50 | Back immich up to Garage |
484042f8 | 21:50 | Onboard phoenix as a tenant of ai |
0b371980 | 21:51 | Hold phoenix at zero |
0fa570e1 | 21:54 | Cut phoenix over |
056a436c | 21:56 | Drop phoenix's dedicated cluster |
Phase 6 -- the reversal (2026-07-29 00:46 → 00:55)
| Commit | Time | Change |
|---|---|---|
747a104d | 00:46 | Onboard forgejo, propagit, rybbit as shared tenants |
206bc44b | 00:48 | Hold all three writers at zero |
2d1f0da1 | 00:52 | Flip all three to cnpg-db-shared, release the hold |
c202d1e0 | 00:55 | Retire the three dedicated clusters |
Total elapsed: about 18 hours across two sessions, of which the actual data-in-flight windows were minutes.
Traps and gotchas
The expensive ones, in rough order of how much time they cost.
replicaCount: 0 silently becomes 1
When a chart renders {{ .Values.replicaCount | default 1 }}, Helm's default
treats 0 as empty. Hit on arizephoenix/phoenix-helm 11.0.7. It fails
silently: the render succeeds, ArgoCD syncs, and the pod keeps writing to
the source mid-dump.
Grep the chart's deployment template for | default before trusting a hold,
and always confirm with kustomize build ... | rg 'replicas:'.
forgejo's chart schema rejects replicaCount: 0
Its JSON schema sets minimum 1, so replicaCount: 0 fails helm template and
the whole Application stops rendering -- ArgoCD sync status Unknown,
nothing applied, and the pod stayed up on the old database. Cost one wasted
cutover attempt (58a69c70 → ae86925a).
Hold it with a kustomize patch on the rendered Deployment instead. This is the documented exception to the "helm values over patches" preference: the value is not expressible in the chart.
Some charts hardcode the DB host
The component flip alone does not move them. forgejo's
gitea.config.database.HOST and temporal's connectAddr both had to be edited
directly. Grep values.yaml for the old service name before assuming a flip
is complete.
rg -n "forgejo-rw|propagit-rw|rybbit-rw" kubernetes/Two CNPG operators freeze every webhook
Running an operator in both cloudnative-pg and database makes them fight
over the shared caBundle, which freezes every Cluster update
cluster-wide and leaves ArgoCD stuck Unknown. There is no opt-out. This is
why 42267819 (collapse to one operator) had to be the very first commit.
CNPG pods have a read-only /tmp
No intermediate dump file is possible. The one-pipe form is not a stylistic choice.
Immutable Job pod templates wedge the whole Application
Changing a DB host that appears inside an already-completed Job (temporal's
schema setup) makes ArgoCD's server-side diff dry-run fail with field is immutable, which blocks the diff for every resource in that app, not just
the Job. Fix by deleting the Job, or setting
argocd.argoproj.io/sync-options: Replace=true on it.
database-toolbox holds stale credentials
Its init container renders passwords into tools.yaml at pod start, so a
rotated tenant password leaves it holding a stale credential until it restarts.
Restart statefulset/database-toolbox in ai, not
deploy/database-toolbox -- both exist with the same name, and the Deployment
is the ToolHive proxy. The real workload is the pod database-toolbox-0, and
its containers are distroless (no cat, no grep), so tools.yaml cannot be
inspected in place. Read the generate-tools init container log for the source
count instead:
kubectl --context=admin@pitower -n ai delete pod database-toolbox-0
kubectl --context=admin@pitower -n ai logs database-toolbox-0 -c generate-tools
# database-toolbox: generated 18 postgres source(s) -> /shared/tools.yamlTool names are unchanged by a migration -- the generator strips
shared-forgejo to forgejo, exactly as forgejo-app reduced to forgejo --
so garrison's allowlist needed no edit.
An unbounded connection pool is antisocial on a shared postmaster
Forgejo defaults MAX_OPEN_CONNS to unlimited. Harmless on a postmaster of its
own; on shared it is a way to eat the cluster-wide max_connections out from
under ten other tenants. Capped at 20/5 as part of the move.
Worth auditing for any future tenant: max_connections: "200" on shared is
the sum across all eleven, not a per-app allowance. Current steady state is
39 of 200.
A stale INFISICAL_TOKEN masks a working login
The CLI prefers the env var and fails with a 404 on a service token ID that no
longer exists, which reads like an auth problem rather than a stale-env one.
env -u INFISICAL_TOKEN in front of the command.
Verification results
Final batch, all three databases identical on every axis:
| tables | columns | indexes / sequences / constraints | sequence values | |
|---|---|---|---|---|
| forgejo | 130 ✓ | 1108 ✓ | 649 / 117 / per-contype ✓ | ✓ |
| propagit | 18 ✓ | 140 ✓ | 98 / 1 / per-contype ✓ | ✓ |
| rybbit | 36 ✓ | 353 ✓ | 135 / 17 / per-contype ✓ | ✓ |
Post-cutover: all three Applications Synced/Healthy, connections present on
shared and zero on the retired clusters, health endpoints returning 200
(forgejo's reporting database:ping: pass).
The column-signature diff caught nothing wrong on any of the 17 tenants, but it earned its place anyway: it is what let litellm's "column does not exist" startup errors be pinned on pre-existing image/schema drift rather than the migration -- the columns were absent from source and target, 926 = 926.
Follow-ups
Known-stale and outstanding, none blocking:
Done — deleted 2026-07-29.components/cnpg-db-databasehas no consumers.Done — rewritten against the three-cluster shape 2026-07-29.docs/applications/databases/index.mdis badly out of date.- Three orphaned
BackupCRs.forgejo-premigration-cutover,propagit-premigration-cutoverandrybbit-premigration-cutoverreference Clusters that no longer exist. They were created on-demand, so ArgoCD has no reason to prune them. Harmless metadata; the objects in Garage are the actual record. - Stale comment in
clusters/patches/affinity.yaml. It says the file is "Duplicated from cloudnative-pg/cluster/patches/affinity.yaml ... Keep the two files in step." That file no longer exists; there is nothing to keep in step. - Retired barman streams.
s3://cnpg/{forgejo,propagit,rybbit,phoenix}/and the 19cloudnative-pg-era prefixes still hold WAL and base backups under the 30-day retention policy. They will age out on their own, and until they do they are the deepest rollback available.