Migrating a Bitnami PostgreSQL Helm Release to CloudNativePG

The Bitnami catalog moved most images to bitnamilegacy and stopped updating them. CloudNativePG can bootstrap a new cluster straight from the running Bitnami instance — here's the walkthrough.

Migrating a Bitnami PostgreSQL Helm Release to CloudNativePG
Created with ChatGPT

In August 2025 the Bitnami catalog changed: the bulk of the freely available container images were moved to the bitnamilegacy repository and stopped receiving updates. Anyone who had been running the Bitnami PostgreSQL Helm chart — either directly or as a subchart of an application chart — was left with two options: pin to a bitnamilegacy/postgresql tag and accept that it will never be patched again, or move the database somewhere else.

CloudNativePG (CNPG) is the obvious destination. It is a CNCF-hosted Kubernetes operator that treats PostgreSQL as a first-class resource: failover, backups, WAL archiving, monitoring and minor-version upgrades are all part of the Cluster API rather than something bolted onto a StatefulSet.

Conveniently, CNPG can bootstrap a new cluster by importing directly from an existing PostgreSQL instance, which means the Bitnami release can serve as the migration source without any intermediate dump files.

What this migration actually is

The import bootstrap method runs pg_dump against the source and pipes it into pg_restore on the freshly initialised target. It is a one-shot logical migration, not replication:

  • Nothing is replayed after the dump starts. Any write that reaches the old database mid-import is lost.
  • It happens exactly once, during CNPG cluster bootstrap. There is no "resume" and no "sync again later".
  • The target's PostgreSQL major version must be greater than or equal to the source's.

That first point is what makes the downtime window mandatory, and it dictates the order of the steps below.

Prerequisites

The CNPG operator must already be installed in the cluster. The examples use the official cluster Helm chart from https://cloudnative-pg.io/charts, which wraps the Cluster CRD; but you can of course also you the raw manifest as well.

Step-by-Step Walkthrough

Step 1: Identify the source major version

kubectl get pod <my-app>-postgresql-0 -o yaml | grep image:

You are looking for something like bitnamilegacy/postgresql:17.6.0-debian-12-r4. The major version — 17 here — is what the new CNPG cluster has to match or exceed. However, I'm not sure if it's possible to skip a major version and go directly from 15 to 17 in one step. I recommend to simply match the old Major version for simplicity. Upgrading to a newer Major version is quite simple with CNPG as soon as the instance is migrated.

Step 2: Stop writes to the source

kubectl scale deployment <my-app> --replicas=0

If the application supports a read-only or maintenance mode, that works too. What matters is that no write reaches the old database from this point until the cutover is complete. This is where the downtime window opens.

Step 3: Collect the source credentials

Read the database name, username (both from the environment variables of the Bitnami Postgresql instance) and password out of the existing Bitnami Secret:

kubectl get secret <my-app>-postgresql -o jsonpath='{.data.password}' | base64 -d

Keep that secret in place — the import needs it later on to authenticate against the source.

Step 4: Create a basic-auth secret for the new cluster

CNPG expects the credentials for the application user as a Secret of the type kubernetes.io/basic-auth. The normal Opaque Secret will be rejected:

apiVersion: v1
kind: Secret
metadata:
  labels:
    cnpg.io/reload: "true"
  name: <my-app>-cnpg
type: kubernetes.io/basic-auth
data:
  username: b2xkLWJpdG5hbWktdXNlcm5hbWU=
  password: b2xkLWJpdG5hbWktcGFzc3dvcmQ=

You actually don't have to re-use the *same* username and password from the Bitnami instance, but it's the path of least resistance: the application's connection configuration then only needs a new hostname. The cnpg.io/reload: "true" label tells the operator to watch the secret and propagate later changes to the running cluster.

Step 5: Configure the cluster to bootstrap from the import

Two blocks are needed. The first defines the target database and its owner:

cluster:
  initdb:
    database: <old-bitnami-database-name>
    owner: <old-bitnami-username>
    secret:
      name: <my-app>-cnpg

The second points the bootstrap at the running Bitnami instance:

cluster:
  # recover running postgres instance
  mode: recovery
  recovery:
    method: import
    import:
      type: microservice
      databases:
        - <old-bitnami-database-name>
      source:
        host: <my-app>-postgresql
        port: 5432
        username: <old-bitnami-username>
        database: <old-bitnami-database-name>
        sslMode: disable
        passwordSecret:
          name: <my-app>-postgresql
          key: password

Please be aware that these are the Helm Values for the cluster Chart as mentioned above. If you write the Cluster manifest by hand rather than through the chart, the structure will be like this:

  spec:
    bootstrap:
      initdb:
        database: <old-bitnami-database-name>
        owner: <old-bitnami-username>
        secret:
          name: <my-app>-cnpg
        import:
          source:
            externalCluster: importSource
          type: microservice
          databases: ["<old-bitnami-database-name>"]
          schemaOnly: false
    externalClusters:
      - name: importSource
        connectionParameters:
          host: "<my-app>-postgresql"
          port: "5432"
          user: "<old-bitnami-username>"
          dbname: ""<old-bitnami-database-name>""
          sslmode: "disable"
        password:
          name: <my-app>-postgresql
          key: password


A few notes on these configurations:

  • type: microservice: imports exactly one database into the cluster's application database and does not carry roles across. That is the right choice when a database belongs to a single application. If the Bitnami instance hosts several databases, or you depend on custom roles, use type: monolith instead, which accepts multiple databases and an explicit roles list.
  • sslMode: disable matches the Bitnami chart's default, which does not enable TLS. Since the traffic stays inside the cluster for the duration of a single dump, that is usually acceptable — but it is a deliberate choice, not a default to copy blindly.
  • Pin the PostgreSQL version explicitly (version.postgresql: "17" in the chart, or imageName in the raw manifest) so the target does not silently land on a different major version than the source.

Step 6: Create the cluster

helm upgrade --install <my-app>-cluster ...

After a succesful installation of the Chart, the import runs as part of the bootstrap job on the new primary. Follow it with kubectl logs -f job/<my-app>-cluster-1-full-recovery. Duration scales with the size of the dump.

💡
If the bootstrap fails, fixing the values and re-applying is not enough. Bootstrap runs only once. To retry, delete the Cluster object, verify that its PVCs are gone, and recreate it — otherwise the operator finds a half-initialised data directory and will not start over.

Step 7: Verify the import

The easiest (but not 100% complete) way to verify the successful import is connecting to the cnpg-pod and reading the list of databases:

kubectl exec -it <my-app>-cluster-1 -- /bin/bash
psql -U <old-bitnami-username> -h localhost -d <old-bitnami-database-name>

\dt should list every table from the Bitnami instance. Spot-check a row count or two on the largest tables before you commit to the cutover — the import either succeeds completely or fails loudly, but a quick comparison against the old instance costs nothing.

Step 8: Remove the recovery section

When the import is verified we can remove the recovery-section and the mode-attributes from the Helm Values of the Cluster Chart again.

Step 9: Point the application at the new cluster

Update the application's database host to the CNPG read-write service, <my-app>-cluster-rw, and scale it back up: kubectl scale deployment <my—app> replicas=1.

If you reused the credentials from step 4, the hostname is the only thing that changes. The downtime window closes here.

Step 10: Remove the Bitnami instance

Once the application is verified against the new database, disable the postgresql subchart (or delete the release) and clean up what it leaves behind — the PVC in particular is not removed automatically.

Keep the old PVC around for a few days as a rollback option before deleting it. The import is one-way, and once the application has written to the new cluster, that snapshot is the only copy of the pre-migration state.

What to do next

CNPG does not configure backups on its own. A Cluster without a backup section and a configured object store is exactly as durable as its PVC. Setting up WAL archiving and scheduled base backups to S3-compatible storage is the natural follow-up — and arguably the reason the migration was worth doing in the first place.