CloudNativePG Part 6: Migrating Umami Without Losing a Row

Moving a live 10 MB database onto CloudNativePG in under six minutes, and proving the verification harness could actually fail before trusting it.

Share

Introduction

bletchley-pg has been live since August 5, chaos-tested in Part 4, and took its first real tenant, lldap, in Part 5. That database was created empty. This one already had six months of analytics in it.

The umami database has lived on the legacy single-instance PostgreSQL StatefulSet in namespace postgres since June, where it landed after moving off a Docker container on docker.luwte.net. It has no HA, no point-in-time recovery, no TLS, and a bespoke per-database pg_dump-to-Garage backup system that exists only because CloudNativePG didn't when it was built. Everything that system does, bletchley-pg already does better and by default.

So the job was narrow: move one 10 MB database between two PostgreSQL instances on the same cluster, prove the data came across intact, and repoint the application. The Umami application itself, a Node.js service on sulu.luwte.net behind Apache as analytics.vluwte.nl, was not touched. Only the database moved, and only the connection string changed.

Total downtime was 5 minutes 54 seconds, of which about one minute was actually moving data. The interesting parts happened before and after that window.


This post is part of the CloudNativePG sub-series.

🏠 This is part of the Homelab Journey series - building a production Kubernetes cluster from scratch.


Data flow before the migration. Umami on sulu.luwte.net connects over VLAN 140 in clear text to Traefik's postgres TCP entrypoint on 10.0.140.100:5432, which routes to the single-instance postgres StatefulSet in namespace postgres holding the 10 MB umami database. Separate postgres-backup and postgres-housekeeping CronJobs dump each database to the postgres-backups bucket on Garage S3. Marked: no HA, no PITR, ssl = off.
One instance, an unencrypted connection, and a backup system built by hand because CloudNativePG did not exist yet.
This post assumes a working CloudNativePG cluster with the Database and DatabaseRole tenant pattern from Part 5, and External Secrets pulling credentials from OpenBao.

Every manifest, query and command in this post is shown abbreviated or omitted entirely. The complete, runnable set is in the addendum at the bottom of the post.


The Approach: Offline Dump and Restore

Logical replication would have given near-zero downtime. It was rejected: the database is 10 MB, Umami is analytics rather than anything transactional, and the source instance is being decommissioned anyway, so there would be no long-term subscription worth maintaining. A stop-dump-restore-start cutover is trivially verifiable and leaves the source untouched as the rollback.

All the dump and restore work ran from a throwaway pod inside the cluster rather than from sulu. sulu has no PostgreSQL client tooling and is not getting any, an in-cluster pod removes the client-version question entirely, and the transfer stays on ClusterIP instead of going out through a LoadBalancer and back. The pod is stored as a reusable template at apps/databases/_template/pg-migrate-pod.yaml, because the next tenant migration will want exactly the same thing.

The migration itself, in six numbered steps. Umami on sulu is stopped, leaving zero connections on the source. A pg-migrate pod running postgres:17-alpine in namespace databases runs pg_dump against the legacy StatefulSet over ClusterIP and pg_restore into bletchley-pg, whose public schema was dropped and recreated first. Both databases are then run through the same comparison scripts and the output files diffed and md5summed, matching at bfd7595bc6e685c3c0d633db1b31b963, after which Umami is started against the new DATABASE_URL.
Six steps, five minutes fifty-four seconds. The source is only ever read, which is what keeps it usable as the rollback.

Pre-flight

The whole plan rests on facts about the source, and every one of them was checked rather than assumed. The queries are in the addendum; this is what they established.

Question Answer
Database size 10 MB, so the copy window is seconds
Row counts 742 events, 351 sessions, 1 user, 1 website, 14 Prisma migrations
Object ownership all 14 tables owned by umami; schema public owned by pg_database_owner
Extensions pgcrypto 1.3 and plpgsql
Same major version both PostgreSQL 17, confirmed rather than inferred
Name collisions on bletchley-pg none
Legacy TLS ssl = off, so the traffic was in clear text

Two of those deserve a sentence each.

pgcrypto was the single biggest identified risk. bletchley-pg runs with enableSuperuserAccess: false, so there is no postgres password login and no privileged step available mid-restore. If the restore needed superuser to create an extension, the whole plan needed rewriting. It didn't: pgcrypto has been a trusted extension since PostgreSQL 13, and the target confirmed trusted = t for the same version 1.3. A trusted extension is creatable by the database owner. The biggest risk in the plan cost one line of log output.

public being owned by pg_database_owner, the PostgreSQL 15+ default, is what makes DROP SCHEMA public CASCADE work as the umami role during the cutover, with no fallback needed.


Declaring the Tenant

Four objects, applied in dependency order: SecretStore β†’ ExternalSecret β†’ DatabaseRole β†’ Database. All four live in apps/umami/ and all four are in the addendum in full.

A fresh credential was written to OpenBao at secret/umami/db-credential, deliberately beside the legacy credential at secret/umami/db rather than as a new version of it. Both live simultaneously through the migration, which is what keeps the rollback real. The password is hex rather than base64, because it goes into a postgresql:// URL and base64 output contains characters that don't survive that trip cleanly.

Two details carried over from Part 5 that are worth repeating rather than rediscovering:

    template:
      type: kubernetes.io/basic-auth      # required: CNPG rejects Opaque
      metadata:
        labels:
          cnpg.io/reload: "true"          # required: without it CNPG never picks up changes

And the one that looks like a mistake and is not:

  dataFrom:
    - extract:
        key: secret/data/umami/db-credential

The key is the full path including the KV v2 data/ segment, even though the SecretStore already sets path: secret and version: v2. That is the SecretStore URL-format quirk from Moving the First Database to the Cluster. Copy it; do not "fix" it. OpenBao itself echoes that exact string back on the bao kv put, which is a free confirmation before the manifest is ever written.

The DatabaseRole needs passwordSecret pointing at the synced Secret. CNPG never generates a role password; omit it and the role is created with a NULL password, healthy-looking and unable to log in.

Everything reconciled first time: SecretStore Valid/True in 5 s, ExternalSecret SecretSynced in 7 s, both CRs reporting APPLIED true with an empty MESSAGE, and a successful login as umami. APPLIED is the column to watch on Database and DatabaseRole, because there is no Ready condition in the default output.


The Dry Run, and Testing the Test

With the application still live, a full dump and restore ran once as a rehearsal. The point of a rehearsal is the comparison that follows it, so that came first: one script that renders the whole shape of a database as sorted, prefixed, plain text.

\pset pager off
\pset format unaligned
\pset fieldsep '|'
\pset tuples_only on

SELECT 'COL', table_name, ordinal_position, column_name, data_type, is_nullable
  FROM information_schema.columns WHERE table_schema = 'public'
 ORDER BY table_name, ordinal_position;

SELECT 'ROWS', relname,
       (xpath('/row/c/text()',
              query_to_xml(format('SELECT count(*) AS c FROM public.%I', relname),
                           false, true, '')))[1]::text::bigint
  FROM pg_stat_user_tables ORDER BY relname;

SELECT 'IDX', indexname, indexdef
  FROM pg_indexes WHERE schemaname = 'public' ORDER BY indexname;

SELECT 'CON', conrelid::regclass::text, conname, pg_get_constraintdef(oid)
  FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY 2, 3;

SELECT 'SEQ', sequencename, last_value
  FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename;

SELECT 'EXT', extname, extversion FROM pg_extension ORDER BY extname;

The four \pset lines are what make it comparable at all: unaligned output with an explicit field separator and no headers or row counts, so nothing in the text depends on column widths. The literal 'COL', 'ROWS', 'IDX' prefixes keep the six result sets identifiable once they are concatenated into one file, and every query has an explicit ORDER BY so two runs cannot differ merely by ordering.

The ROWS query is the one worth a second look. Counting rows through query_to_xml looks like an odd way to write count(*), and it is, but it is the only way to get an exact count per table from a single query that iterates the table list. The obvious alternative, n_live_tup from pg_stat_user_tables, is a planner statistic rather than a count. On this database it reported 0 rows for tables that demonstrably had rows.

A second, shorter script covers content rather than structure: md5(string_agg(...)) over the user and website rows, plus count(*) with min/max timestamps on website_event. It is in the addendum.

Run both sides through the compare script, redirect each to a file, and compare the files:

igor@granite bletchley % kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" \
  psql -h postgres.postgres.svc.cluster.local -U umami -d umami \
  -f /tmp/pg-compare.sql > /tmp/cmp-legacy.txt
igor@granite bletchley % kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
  -f /tmp/pg-compare.sql > /tmp/cmp-cnpg.txt
igor@granite bletchley % diff -u /tmp/cmp-legacy.txt /tmp/cmp-cnpg.txt && echo "STRUCTURE + COUNTS IDENTICAL"
STRUCTURE + COUNTS IDENTICAL
igor@granite bletchley % md5sum /tmp/cmp-legacy.txt /tmp/cmp-cnpg.txt
bfd7595bc6e685c3c0d633db1b31b963  /tmp/cmp-legacy.txt
bfd7595bc6e685c3c0d633db1b31b963  /tmp/cmp-cnpg.txt

Which is the wanted result, and also exactly what a completely broken comparison looks like.

The plan had a built-in guard against that: Umami keeps writing to the legacy database during the dry run, so the EVENT counts were expected to differ, proving the script reads live data from two distinct servers. They didn't differ. At roughly five events a day, no writes happened to land in the window, and the most recent event predated the dump by about nineteen hours.

That check was not satisfied. It was unproven, and a check that cannot fail proves nothing. So instead of ticking the box, the two underlying claims were tested directly.

Are these actually two different servers? Four independent facts say yes: different server addresses, postmaster start times four days apart, and cluster_name empty on one side and bletchley-pg on the other. (pg_control_system().system_identifier would be the textbook proof, but its EXECUTE is superuser-only by default, and this cluster deliberately has no superuser login. A security decision from three posts ago shaping the answer to an unrelated question.)

Would the comparison notice a difference if one existed? A sentinel table was created on the CNPG copy only:

CREATE TABLE public._liveness_probe (id int PRIMARY KEY, note text);
INSERT INTO public._liveness_probe VALUES (1, 'dry-run sensitivity check');

Then the same two commands as before, against a third pair of files:

igor@granite bletchley % diff -u /tmp/cmp-legacy-probe.txt /tmp/cmp-cnpg-probe.txt && echo "!!! HARNESS FAILED TO DETECT THE PROBE"
--- /tmp/cmp-legacy-probe.txt
+++ /tmp/cmp-cnpg-probe.txt
@@ -1,6 +1,8 @@
+COL|_liveness_probe|1|id|integer|NO
+COL|_liveness_probe|2|note|text|YES
@@ -140,6 +142,7 @@
+ROWS|_liveness_probe|1
@@ -154,6 +157,7 @@
+IDX|_liveness_probe_pkey|CREATE UNIQUE INDEX _liveness_probe_pkey ON public._liveness_probe USING btree (id)
@@ -244,6 +248,7 @@
+CON|_liveness_probe|_liveness_probe_pkey|PRIMARY KEY (id)
igor@granite bletchley % md5sum /tmp/cmp-legacy-probe.txt /tmp/cmp-cnpg-probe.txt
bfd7595bc6e685c3c0d633db1b31b963  /tmp/cmp-legacy-probe.txt
a2e740e9fc84cc6c6381c22ba2e32fd7  /tmp/cmp-cnpg-probe.txt

The harness caught one two-column table in all four relevant sections: both columns under COL, the single row under ROWS, the implicit primary-key index under IDX, the constraint under CON. The && echo guard did not fire, because diff exited non-zero as it must when the files differ. And the legacy hash did not move from bfd7595b…, which proves the probe landed only on the copy and never touched the source.

Then the probe was dropped and both sides re-compared:

bfd7595bc6e685c3c0d633db1b31b963  /tmp/cmp-legacy-after.txt
bfd7595bc6e685c3c0d633db1b31b963  /tmp/cmp-cnpg-after.txt

Three hashes tell the whole story: bfd7595b… before, a2e740e9… with the probe, bfd7595b… again after cleanup. That one number does three jobs at once: the cleanup was complete, the script is deterministic across runs, and the earlier "identical" was a genuine comparison rather than a silent failure.

A verification step you have never seen fail is not a verification step.


The Cutover

August 29. Window open 13:41:52 CEST, closed 13:47:46 CEST. 5 minutes 54 seconds.

Umami was stopped on sulu, and the legacy database confirmed to have zero remaining connections, a clean stop with no pooled connections to wait out. The CNPG target, which still held the dry-run data, was emptied:

DROP SCHEMA public CASCADE;
CREATE SCHEMA public AUTHORIZATION umami;
NOTICE:  drop cascades to 15 other objects
DETAIL:  drop cascades to extension pgcrypto
drop cascades to table _prisma_migrations
...
drop cascades to table website_event

That NOTICE is a receipt worth reading rather than scrolling past: 15 objects, pgcrypto plus exactly the 14 tables from the pre-flight baseline. Nothing more, nothing less. A following \dt returned Did not find any relations., which is the target confirmed empty before the final restore rather than hoped-for afterwards.

Then the final pg_dump -Fc from legacy, pg_restore --no-owner --no-privileges -j2 into CNPG, and a grep -iE 'error|permission denied' over the restore log that returned nothing at all.

The comparison scripts ran once more, and produced bfd7595bc6e685c3c0d633db1b31b963 on both sides, for the fifth and sixth time, and a hash that had been demonstrated to change when the data changes. Content checksums matched too, including EVENT|742 with identical min and max timestamps. The max is the stronger signal: a truncated dump would show a plausible-looking count with an earlier maximum.

Umami was started with a new DATABASE_URL pointing at pg.vluwte.nl:5432, and came up in βœ“ Ready in 1289ms with no database errors, which is itself a positive result, since a wrong host or password surfaces immediately as a Prisma connection error on start-up.

The dashboard loaded, admin logged in, and the history was there. The login is the single most meaningful check in the whole list: it proves the password hash in the user table survived a --no-owner --no-privileges dump and restore across two different PostgreSQL builds, 17.11 on Alpine to 17.10 on Debian. A row count can never tell you that.

Proving the old path is dead

It is easy to check that the new thing works and never check that the old thing stopped. The dry run's drift check couldn't answer that; a deliberate write could. Own-visit exclusion was disabled and the site loaded from a different browser, because the Umami tracker is a common ad-block target and "nothing appeared" would otherwise be ambiguous between a broken migration and a blocked request.

=== CNPG ===
743 | 2026-08-29 12:33:00.008+00
=== LEGACY ===
742 | 2026-08-28 14:44:31.623+00

CNPG moved 742 β†’ 743. Legacy stayed frozen at 742, with a maximum timestamp still identical to the pre-flight baseline. Both halves, four lines of output. "742, unchanged" is the sentence that actually retires the StatefulSet.


The TLS Thread: What sslmode=require Means to Prisma

Pre-flight turned up something the plan hadn't gone looking for: the legacy StatefulSet is a stock postgres:17-alpine with ssl = off. Umami's analytics traffic had been crossing VLAN 140 in clear text since June.

CNPG always runs ssl = on, so the fix looked free. verify-full was off the table. CNPG's server certificate carries sixteen SANs, all in-cluster service names, and pg.vluwte.nl is not among them; and its internal CA has a 90-day lifetime, so pinning a copy on an off-cluster host with no ESO and no trust-manager would silently expire around November. The plan settled on sslmode=require: encrypt, don't validate. That is what the PostgreSQL documentation says require means.

The application disagreed:

Error [PrismaClientKnownRequestError]:
Invalid `prisma.user.findUnique()` invocation:
Error opening a TLS connection: self-signed certificate in certificate chain
  code: 'P1011',
  clientVersion: '6.19.2'

sslmode is not portable across PostgreSQL clients. libpq's require means encrypt and do not verify. Prisma's Rust connector implements require as encrypt and verify the certificate chain, so it correctly refused CNPG's self-signed chain. The service starts; every query fails. "The PostgreSQL docs say X" is a statement about libpq, not about PostgreSQL clients in general.

sslaccept=accept_invalid_certs, Prisma's documented escape hatch for exactly this case, produced no change in behaviour. Cause not established, and recorded as an open item rather than a conclusion.

What shipped is the string with no sslmode at all:

DATABASE_URL=postgresql://umami:<password>@pg.vluwte.nl:5432/umami?schema=public

And then the question that should always follow a security assumption: is it actually encrypted?

 usename | client_addr | ssl | version |         cipher
---------+-------------+-----+---------+------------------------
 umami   | 10.244.6.25 | t   | TLSv1.3 | TLS_AES_256_GCM_SHA384
Before (legacy StatefulSet) Now (CNPG)
Server TLS ssl = off ssl = on
Connection plaintext TLS 1.3, AES-256-GCM
Chain validated n/a no
Enforced n/a no, prefer falls back to plaintext if TLS stops being offered

Prisma's default sslmode=prefer negotiates TLS without validating the chain, which is precisely the behaviour the plan wanted from require, arrived at by leaving the parameter out rather than putting it in. Six months of clear-text analytics traffic ended at 13:47 on August 29.

That is a real improvement, not a lateral move, and I am not going to pretend it was the intended route. What is missing is enforcement, and it is now blocked on something concrete rather than a preference: Prisma will not accept this certificate, so the fix is to give it one it will. That means a publicly-trusted Let's Encrypt certificate, which is work for later. It also means a hostname change first, because pg.vluwte.nl was always an interim name, and there is no sense certifying a name that is on its way out.


Backup Coverage: the Whole Point

A tenant that is merely present in the cluster is not a tenant that is protected. The closing gate was the next morning's scheduled backup:

bletchley-pg-daily-20260828051500   2d1h   bletchley-pg   plugin   completed
bletchley-pg-daily-20260829051500   25h    bletchley-pg   plugin   completed
bletchley-pg-daily-20260830051500   75m    bletchley-pg   plugin   completed

bletchley-pg-daily-20260830051500 is the first scheduled base backup taken with umami inside the cluster, and the unbroken run of completed before it shows that adding a tenant disturbed nothing.

Combined with continuous Barman Cloud WAL archiving, umami now has point-in-time recovery. Nobody configured that. It was inherited by moving. PostgreSQL Backups on Kubernetes was an entire post about building per-database dumps, rotation, a Go exporter and two alert rules by hand, and that system is now the thing being decommissioned.

Data flow after the migration. Umami on sulu, otherwise unchanged, connects over TLS 1.3 to the bletchley-pg-external MetalLB LoadBalancer on 10.0.140.101:5432, which always routes to the current CloudNativePG primary. The bletchley-pg cluster runs three PostgreSQL 17.10 instances across both boards, with umami declared as a tenant through Database and DatabaseRole CRs and its password supplied by an ExternalSecret from OpenBao. The bletchley-pg-daily ScheduledBackup and continuous Barman Cloud WAL archiving ship to Garage S3. Marked: HA, PITR, TLS 1.3, not enforced.
Three instances, an encrypted connection and point-in-time recovery, none of which needed any per-app configuration.

What's Working Now

  • βœ… Umami runs on CloudNativePG. Cut over August 29, 5 min 54 s downtime, data verified identical
  • βœ… Dashboard loads, admin logs in, six months of history intact
  • βœ… The connection is encrypted: TLS 1.3, TLS_AES_256_GCM_SHA384
  • βœ… Live traffic verified on the new path: 742 β†’ 743 on CNPG while legacy stayed frozen at 742
  • βœ… Backups inherited from bletchley-pg-daily plus WAL archiving, with no per-app wiring
  • βœ… Reusable migration runner pod at apps/databases/_template/pg-migrate-pod.yaml
  • βœ… Comparison harness proven sensitive and deterministic
  • βœ… Rollback still available: the legacy database is untouched and secret/umami/db is still at version 1
  • ⚠️ TLS encrypted but not enforced, running on Prisma's prefer default; the fix is a publicly-trusted certificate, later
  • ⚠️ CNPG's internal CA expires October 31, 2026, and nothing is alerting on it

What's Next

Part 7 is cleanup, and only cleanup: retire the legacy StatefulSet and everything that exists only to serve it, meaning the backup CronJobs, the Go exporter, its two alert rules, the Traefik postgres TCP entrypoint, the IngressRouteTCP and the Longhorn PVC.

There is one ordering rule in there and it is not optional: suspend the legacy backup CronJobs before dropping the database. That backup system has a known, never-fixed bug where a run with zero databases writes its state files, uploads nothing, exits successfully, and publishes a healthy last-success timestamp for a backup containing no data. Dropping the last database reproduces it exactly.

Only once all of that is gone does the rename make sense: pg.vluwte.nl becomes postgres.vluwte.nl, which is a repoint between two IPs, and the new name gets a publicly-trusted Let's Encrypt certificate so sslmode=require can finally mean what it should. Those two go together and they come last. Neither is tidying up, so neither belongs in a decommissioning post.


← Previous: Secret Management Part 5


Questions or suggestions? Leave a comment below or reach out at igor@vluwte.nl.


Addendum: Every Manifest and Command

Everything below runs top to bottom. Passwords are placeholders. $LEGACY_PW and $UMAMI_PG_PW are shell variables held on the workstation, never written to a manifest.

A1: Migration runner pod

apps/databases/_template/pg-migrate-pod.yaml. Note runAsUser: 70: in postgres:17-alpine the postgres user is UID 70, not the 999 used by the Debian-based images.

apiVersion: v1
kind: Pod
metadata:
  name: pg-migrate
  namespace: databases
spec:
  restartPolicy: Never
  securityContext:
    runAsUser: 70
    runAsGroup: 70
    fsGroup: 70
  containers:
    - name: pg
      image: postgres:17-alpine
      command: ["sleep", "infinity"]
      volumeMounts:
        - name: work
          mountPath: /work
  volumes:
    - name: work
      emptyDir: {}
kubectl apply -f apps/databases/_template/pg-migrate-pod.yaml
kubectl -n databases wait --for=condition=Ready pod/pg-migrate --timeout=120s
kubectl -n databases exec pg-migrate -- id
kubectl -n databases exec pg-migrate -- pg_dump --version

kubectl -n databases exec pg-migrate -- \
  pg_isready -h postgres.postgres.svc.cluster.local -p 5432 -U umami -d umami
kubectl -n databases exec pg-migrate -- \
  pg_isready -h bletchley-pg-rw.databases.svc.cluster.local -p 5432 -U umami -d umami

A2: Pre-flight

kubectl -n openbao port-forward svc/openbao 8200:8200 &
export BAO_ADDR=http://127.0.0.1:8200
bao login -method=userpass username=a-igor

bao kv list secret/umami/
bao kv get -format=json secret/umami/db | jq '.data.data | keys'
bao kv metadata get secret/umami/db

LEGACY_PW=$(bao kv get -field=POSTGRES_PASSWORD secret/umami/db)

Size, row counts and accounts on the source:

SELECT pg_size_pretty(pg_database_size('umami')) AS db_size;

SELECT relname,
       (xpath('/row/c/text()',
              query_to_xml(format('SELECT count(*) AS c FROM public.%I', relname),
                           false, true, '')))[1]::text::bigint AS exact_rows
  FROM pg_stat_user_tables ORDER BY relname;

SELECT user_id, username, role, created_at FROM "user";
SELECT website_id, name, domain, created_at FROM website;
Use count(*) via query_to_xml, not n_live_tup. n_live_tup is a planner statistic and was stale here, reporting 0 rows for tables that demonstrably had rows.

Roles, ownership and extensions on the source:

SELECT rolname, rolsuper, rolcanlogin FROM pg_roles WHERE rolcanlogin;
SELECT nspname, pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname = 'public';
SELECT tablename, tableowner FROM pg_tables WHERE schemaname = 'public';
SELECT extname, extversion, pg_get_userbyid(extowner) AS owner FROM pg_extension;

Extension availability on the target, where trusted = t is the column that matters under enableSuperuserAccess: false:

SELECT name, default_version, installed_version, superuser, trusted
  FROM pg_available_extensions WHERE name = 'pgcrypto';

TLS state of the source, and the certificate the target actually presents:

SHOW ssl;

SELECT a.pid, a.usename, a.client_addr, s.ssl, s.version, s.cipher
  FROM pg_stat_activity a LEFT JOIN pg_stat_ssl s USING (pid)
 WHERE a.datname = 'umami';
kubectl -n databases get secret bletchley-pg-server \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | \
  openssl x509 -noout -subject -issuer -dates -ext subjectAltName

kubectl -n databases get secret bletchley-pg-ca \
  -o jsonpath='{.data.ca\.crt}' | base64 -d | \
  openssl x509 -noout -subject -dates

ssh sulu.luwte.net 'getent hosts pg.vluwte.nl; nc -zv 10.0.140.101 5432'

A3: Credential in OpenBao

No policy or role changes were needed: the existing umami policy already grants read on secret/data/umami/*, and the umami Kubernetes auth role binds ESO's ServiceAccount, which is the same regardless of which namespace the SecretStore lives in.

UMAMI_PG_PW=$(openssl rand -hex 24)      # hex, NOT base64: goes into a postgresql:// URL

bao kv put secret/umami/db-credential \
  username=umami \
  password="$UMAMI_PG_PW"

bao kv get -format=json secret/umami/db-credential | jq '.data.data | keys'
bao kv metadata get secret/umami/db          # confirm still version 1, unmodified

A4: The four tenant manifests

apps/umami/secretstore-umami.yaml

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: umami
  namespace: databases
spec:
  provider:
    vault:
      server: http://openbao.openbao.svc:8200
      path: secret
      version: v2
      auth:
        kubernetes:
          mountPath: kubernetes
          role: umami

apps/umami/externalsecret-umami-db-owner.yaml

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: umami-db-owner
  namespace: databases
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: SecretStore
    name: umami
  dataFrom:
    - extract:
        key: secret/data/umami/db-credential
  target:
    name: umami-db-owner
    creationPolicy: Owner
    template:
      type: kubernetes.io/basic-auth      # required: CNPG rejects Opaque
      metadata:
        labels:
          cnpg.io/reload: "true"          # required: without it CNPG never picks up changes
      data:
        username: "{{ .username }}"
        password: "{{ .password }}"

apps/umami/databaserole-umami.yaml

apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
metadata:
  name: umami
  namespace: databases
spec:
  cluster:
    name: bletchley-pg
  name: umami
  ensure: present
  login: true
  superuser: false
  createdb: false
  createrole: false
  passwordSecret:
    name: umami-db-owner       # CNPG never generates a password; omit and the role gets NULL

apps/umami/database-umami.yaml

apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
  name: umami
  namespace: databases
spec:
  cluster:
    name: bletchley-pg
  name: umami
  owner: umami
  encoding: UTF8
  databaseReclaimPolicy: retain      # deleting the CR must never drop the data

Apply and verify:

kubectl apply -f apps/umami/secretstore-umami.yaml
kubectl -n databases get secretstore umami

kubectl apply -f apps/umami/externalsecret-umami-db-owner.yaml
kubectl -n databases get externalsecret umami-db-owner
kubectl -n databases get secret umami-db-owner -o jsonpath='{.type}{"\n"}'

kubectl apply -f apps/umami/databaserole-umami.yaml
kubectl apply -f apps/umami/database-umami.yaml
kubectl -n databases get databaseroles.postgresql.cnpg.io,databases.postgresql.cnpg.io

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
  -c "SELECT current_user, current_database(), version();"

A5: The comparison scripts

kubectl -n databases exec -i pg-migrate -- sh -c 'cat > /tmp/pg-compare.sql' <<'SQL'
\pset pager off
\pset format unaligned
\pset fieldsep '|'
\pset tuples_only on

SELECT 'COL', table_name, ordinal_position, column_name, data_type, is_nullable
  FROM information_schema.columns WHERE table_schema = 'public'
 ORDER BY table_name, ordinal_position;

SELECT 'ROWS', relname,
       (xpath('/row/c/text()',
              query_to_xml(format('SELECT count(*) AS c FROM public.%I', relname),
                           false, true, '')))[1]::text::bigint
  FROM pg_stat_user_tables ORDER BY relname;

SELECT 'IDX', indexname, indexdef
  FROM pg_indexes WHERE schemaname = 'public' ORDER BY indexname;

SELECT 'CON', conrelid::regclass::text, conname, pg_get_constraintdef(oid)
  FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY 2, 3;

SELECT 'SEQ', sequencename, last_value
  FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename;

SELECT 'EXT', extname, extversion FROM pg_extension ORDER BY extname;
SQL

kubectl -n databases exec -i pg-migrate -- sh -c 'cat > /tmp/pg-content.sql' <<'SQL'
\pset pager off
\pset format unaligned
\pset fieldsep '|'
\pset tuples_only on

SELECT 'USER', count(*),
       md5(string_agg(user_id::text || ':' || username || ':' || role, ',' ORDER BY user_id))
  FROM "user";

SELECT 'SITE', count(*),
       md5(string_agg(website_id::text || ':' || domain, ',' ORDER BY website_id))
  FROM website;

SELECT 'EVENT', count(*), min(created_at), max(created_at) FROM website_event;
SELECT 'SESSION', count(*) FROM session;
SQL
Both files live in the pod's /tmp emptyDir. Deleting the pod loses them.

Running the comparison. This exact pair of commands is reused at every checkpoint:

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" \
  psql -h postgres.postgres.svc.cluster.local -U umami -d umami \
  -f /tmp/pg-compare.sql > /tmp/cmp-legacy.txt

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
  -f /tmp/pg-compare.sql > /tmp/cmp-cnpg.txt

diff -u /tmp/cmp-legacy.txt /tmp/cmp-cnpg.txt && echo "STRUCTURE + COUNTS IDENTICAL"
md5sum /tmp/cmp-legacy.txt /tmp/cmp-cnpg.txt

A6: Dry run

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" sh -c \
  'pg_dump -Fc -h postgres.postgres.svc.cluster.local -U umami -d umami -f /tmp/umami-dryrun.dump'

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" sh -c \
  'pg_restore -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
     --no-owner --no-privileges -j2 --verbose /tmp/umami-dryrun.dump' \
  2>&1 | tee /tmp/umami-dryrun-restore.log

grep -iE 'error|warning|must be owner|permission denied' /tmp/umami-dryrun-restore.log

Then run the A5 comparison, and the content script against both sides.

A7: Proving the harness works

Two different servers:

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" \
  psql -h postgres.postgres.svc.cluster.local -U umami -d umami -At -c \
  "SELECT inet_server_addr() || ' | ' || inet_server_port()
       || ' | ' || coalesce(current_setting('cluster_name', true), '(none)')
       || ' | ' || pg_postmaster_start_time();"

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami -At -c \
  "SELECT inet_server_addr() || ' | ' || inet_server_port()
       || ' | ' || coalesce(current_setting('cluster_name', true), '(none)')
       || ' | ' || pg_postmaster_start_time();"

Sentinel probe on the CNPG copy only, then re-compare, then clean up and re-compare again:

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami <<'SQL'
CREATE TABLE public._liveness_probe (id int PRIMARY KEY, note text);
INSERT INTO public._liveness_probe VALUES (1, 'dry-run sensitivity check');
SQL

# ... A5 comparison here: expect a diff and two different md5s ...

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
  -c 'DROP TABLE public._liveness_probe;'

# ... A5 comparison again: expect the original hash on both sides ...

A8: Cutover

# 1. Stop the writer, on sulu
sudo systemctl stop umami && systemctl is-active umami
# 2. Confirm zero connections remain on the legacy database
kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" \
  psql -h postgres.postgres.svc.cluster.local -U umami -d umami -c \
  "SELECT count(*), array_agg(DISTINCT client_addr) FROM pg_stat_activity
    WHERE datname='umami' AND pid <> pg_backend_pid();"
# 3. Empty the CNPG target and confirm it
kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami <<'SQL'
DROP SCHEMA public CASCADE;
CREATE SCHEMA public AUTHORIZATION umami;
SQL

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami -c '\dt'
# 4. Final dump and restore
TS=$(date +%Y%m%d-%H%M)

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" sh -c \
  "pg_dump -Fc -h postgres.postgres.svc.cluster.local -U umami -d umami -f /tmp/umami-final-$TS.dump"

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" sh -c \
  "pg_restore -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami \
     --no-owner --no-privileges -j2 --verbose /tmp/umami-final-$TS.dump" \
  2>&1 | tee /tmp/umami-final-restore-$TS.log

grep -iE 'error|permission denied' /tmp/umami-final-restore-$TS.log

# keep a copy off-cluster
kubectl -n databases cp pg-migrate:/tmp/umami-final-$TS.dump ./umami-final-$TS.dump
# 5. Comparison: must be identical, both scripts (see A5)
# 6. Repoint and start Umami, on sulu
#    /etc/systemd/system/umami.service (or its EnvironmentFile):
#    DATABASE_URL=postgresql://umami:<password>@pg.vluwte.nl:5432/umami?schema=public
#    Comment out the old line rather than deleting it; it is the rollback.
systemctl start umami && sleep 5 && systemctl status umami --no-pager
journalctl -u umami -n 50 --no-pager

A9: Post-cutover verification

Encryption:

kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami -c \
  "SELECT a.usename, a.client_addr, s.ssl, s.version, s.cipher
     FROM pg_stat_activity a LEFT JOIN pg_stat_ssl s USING (pid)
    WHERE a.datname = 'umami' AND a.backend_type = 'client backend';"

Liveness. Generate a real pageview first, from a browser without an ad-blocker and with Umami's own visit exclusion disabled:

echo "=== CNPG ===" && kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$UMAMI_PG_PW" \
  psql -h bletchley-pg-rw.databases.svc.cluster.local -U umami -d umami -At -c \
  "SELECT count(*) || ' | ' || max(created_at) FROM website_event;"

echo "=== LEGACY ===" && kubectl -n databases exec -i pg-migrate -- env PGPASSWORD="$LEGACY_PW" \
  psql -h postgres.postgres.svc.cluster.local -U umami -d umami -At -c \
  "SELECT count(*) || ' | ' || max(created_at) FROM website_event;"

Backup coverage, the morning after:

kubectl -n databases get backups.postgresql.cnpg.io \
  --sort-by=.metadata.creationTimestamp | tail -5

A10: Rollback

Until the legacy database is dropped in Part 7:

# on sulu
sudo systemctl stop umami
# restore the commented-out DATABASE_URL line
sudo systemctl start umami

The legacy database is untouched, and secret/umami/db is still at version 1, so the old credential still resolves. The cost is every pageview since the cutover, plus a return to an unencrypted connection, since the legacy server has ssl = off.


← Previous: Secret Management Part 5


Questions or suggestions? Leave a comment below or reach out at igor@vluwte.nl.