New to KubeDB? Please start here.
PostgreSQL Database Migration
This guide will show you how to use KubeDB Migration to migrate an existing PostgreSQL database to a KubeDB-managed PostgreSQL instance with minimal downtime. The tool supports migration from a wide range of source environments — including Amazon RDS, CloudNativePG (CNPG), Zalando PostgreSQL Operator, Bitnami Helm charts, and self-hosted PostgreSQL instances.
The migration operates in three phases:
- Schema migration — extracts DDL (tables, indexes, functions, etc.) from the source using
pg_dump. - Initial data copy — performs a full bulk copy of all table data to the target.
- Live streaming — uses PostgreSQL logical replication to continuously apply source changes to the target, keeping both databases in sync until cutover.
A brief downtime occurs only during the final cutover when application endpoints are redirected to the target database.
Before You Begin
At first, you need to have a Kubernetes cluster, and the
kubectlcommand-line tool must be configured to communicate with your cluster.Install
KubeDBoperator with the kubedb-courier operator enabled in your cluster following the steps here.The source
PostgreSQLinstance must be network-reachable from within your Kubernetes cluster.The source
PostgreSQLinstance must havewal_levelset tological. The database user provided for migration must have theREPLICATIONprivilege.You should be familiar with the following
KubeDBconcepts:
To keep everything isolated, we are going to use a separate namespace called demo throughout this tutorial.
$ kubectl create ns demo
namespace/demo created
Prepare Source Database
We will use an AWS RDS PostgreSQL instance as the source. Connect to it as the admin user to verify the prerequisites and set up the migration user and test data.
Configuring your source instance.
Self-hosted PostgreSQL
-- Set WAL level to logical (requires a PostgreSQL restart to take effect)
ALTER SYSTEM SET wal_level = 'logical';
-- Grant the replication privilege to the migration user
ALTER USER <migration-user> WITH REPLICATION;
AWS RDS
Set rds.logical_replication = 1 in your RDS Parameter Group and reboot the instance. Then grant the replication privilege via SQL as shown above.
Azure Database for PostgreSQL
Set azure.replication_support = logical under Server Parameters in the Azure Portal and restart the server. Then grant the replication privilege via SQL as shown above.
Google Cloud SQL
Enable the cloudsql.logical_decoding flag via the Cloud Console database flags. Then grant the replication privilege via SQL as shown above.
CloudNativePG (CNPG)
Add wal_level: logical under postgresql parameters in the Cluster spec.
Verify prerequisites
$ psql -h <rds-endpoint>.rds.amazonaws.com -U postgres -p 5432
SHOW wal_level;
wal_level
-----------
logical
(1 row)
Create a dedicated migration user
-- Create the user
CREATE USER migrator WITH PASSWORD '<password>';
-- Logical replication: allows creating publications and replication slots
-- (AWS RDS-specific role; on self-hosted PostgreSQL use: ALTER ROLE migrator WITH REPLICATION;)
GRANT rds_replication TO migrator;
-- pg_dump access: read all schemas and table data (PostgreSQL 14+)
GRANT pg_read_all_data TO migrator;
-- Verify
\du migrator
Note (AWS RDS):
rds_replicationis the AWS-managed equivalent of theREPLICATIONprivilege.pg_read_all_data(PostgreSQL 14+) covers allSELECTaccess needed bypg_dumpacross all schemas and tables — no per-table grants needed. For PostgreSQL 13 or earlier, grantSELECTon each table explicitly.
The migrator user is referenced in the Kubernetes secret and AppBinding for the rest of this guide.
Create the source database and seed data
-- Create and switch to the shop database
CREATE DATABASE shop;
\c shop
-- Create the orders table
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
product VARCHAR(100) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Allow the migrator user to connect to this database
-- (pg_read_all_data already covers USAGE on schemas and SELECT on all tables/sequences)
GRANT CONNECT ON DATABASE shop TO migrator;
-- Seed initial data
INSERT INTO orders (customer_name, product, quantity, status) VALUES
('Alice', 'Laptop', 1, 'shipped'),
('Bob', 'Headphones', 2, 'pending'),
('Carol', 'Keyboard', 3, 'delivered');
SELECT * FROM orders;
id | customer_name | product | quantity | status | created_at
----+---------------+------------+----------+-----------+------------------------
1 | Alice | Laptop | 1 | shipped | 2026-06-29 08:00:00
2 | Bob | Headphones | 2 | pending | 2026-06-29 08:00:01
3 | Carol | Keyboard | 3 | delivered | 2026-06-29 08:00:02
(3 rows)
Prepare Source Connection Information
First, create an authentication secret using the migrator user credentials:
$ kubectl create secret generic source-postgres-auth -n demo \
--type=kubernetes.io/basic-auth \
--from-literal=username=migrator \
--from-literal=password=<password>
If your database has TLS enabled, create a secret with the CA certificate:
kubectl create secret generic ca-secret \
--from-file=ca.crt=$CERT_PATH/ca.crt \
--namespace=demo
Now create an AppBinding with the necessary information. The kubedb-courier operator reads the source PostgreSQL connection information from this AppBinding CR. Use the following YAML to create your AppBinding:
apiVersion: appcatalog.appscode.com/v1alpha1
kind: AppBinding
metadata:
name: source-postgres
namespace: demo
spec:
type: postgresql
version: "17.4"
clientConfig:
url: "postgresql://<rds-endpoint>.rds.amazonaws.com:5432"
secret:
name: source-postgres-auth
tlsSecret: # omit if TLS is disabled
name: ca-secret
Here,
spec.clientConfig.urlis the connection URL of the source PostgreSQL instance.spec.secret.nameis the reference to the secret we created earlier, containing the PostgreSQL authentication information.
For a
KubeDB-managed database, anAppBindingis created by default. So there is no need to create one for the target database.
Create Target PostgreSQL Database
KubeDB implements a Postgres CRD to define the specification of a PostgreSQL database. Follow the Postgres object to create the target database.
apiVersion: kubedb.com/v1
kind: Postgres
metadata:
name: target-postgres
namespace: demo
spec:
version: "17.4"
storageType: Durable
storage:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
deletionPolicy: WipeOut
$ kubectl create -f https://github.com/kubedb/docs/raw/v2026.6.19/docs/examples/postgres/migration/target-postgres.yaml
postgres.kubedb.com/target-postgres created
Note: Adjust the
resources.requests.storagebased on the source database size.
Wait until target-postgres has status Ready.
Apply Migration CR
To migrate the database we have to create a Migration CR. Below is the YAML of the Migration CR that we are going to create:
apiVersion: courier.kubedb.com/v1alpha1
kind: Migration
metadata:
name: postgres-migrate
namespace: demo
spec:
source:
postgres:
connectionInfo:
appBinding:
name: source-postgres
namespace: demo
dbName: shop
maxConnections: 100
pgDump:
schemaOnly: true
logicalReplication:
copyData: true
publication:
name: "pub"
mode: default
subscription:
name: "sub"
target:
postgres:
connectionInfo:
appBinding:
name: target-postgres
namespace: demo
dbName: shop
maxConnections: 100
$ kubectl apply -f https://github.com/kubedb/docs/raw/v2026.6.19/docs/examples/postgres/migration/postgres-migrate.yaml
migration.courier.kubedb.com/postgres-migrate created
Here we connect to and migrate the shop database. Schema is extracted via pg_dump (pgDump.schemaOnly: true) and data is replicated using PostgreSQL logical replication with publication pub on the source and subscription sub on the target. For a full description of every field, see the Migration CRD reference.
Watch Migration Progress
Let’s wait for the LAG to reach near zero. Run the following command to watch Migration CR:
Every 2.0s: kubectl get migration -n demo
NAME PHASE DBTYPE STAGE LAG PROGRESS AGE
postgres-migrate Running postgres Streaming 0B 100% 4h36m
Verify initial snapshot on target
Once the migration reaches the Streaming stage, exec into the KubeDB target pod and confirm all seed rows were copied over:
$ kubectl exec -it -n demo target-postgres-0 -- psql -U postgres -d shop
SELECT * FROM orders;
id | customer_name | product | quantity | status | created_at
----+---------------+------------+----------+-----------+------------------------
1 | Alice | Laptop | 1 | shipped | 2026-06-29 08:00:00
2 | Bob | Headphones | 2 | pending | 2026-06-29 08:00:01
3 | Carol | Keyboard | 3 | delivered | 2026-06-29 08:00:02
(3 rows)
Test live CDC streaming
With the migration still running, connect to the source RDS instance and run some DML:
$ psql -h <rds-endpoint>.rds.amazonaws.com -U migrator -d shop -p 5432
-- Insert a new order
INSERT INTO orders (customer_name, product, quantity, status)
VALUES ('Dave', 'Mouse', 1, 'pending');
-- Mark Bob's headphones as delivered
UPDATE orders SET status = 'delivered' WHERE id = 2;
-- Remove the already-shipped laptop order
DELETE FROM orders WHERE id = 1;
Wait a few seconds for logical replication to propagate, then re-query the target:
SELECT * FROM orders;
id | customer_name | product | quantity | status | created_at
----+---------------+------------+----------+-----------+------------------------
2 | Bob | Headphones | 2 | delivered | 2026-06-29 08:00:01
3 | Carol | Keyboard | 3 | delivered | 2026-06-29 08:00:02
4 | Dave | Mouse | 1 | pending | 2026-06-29 08:10:00
(3 rows)
The INSERT, UPDATE, and DELETE are all reflected on the target — logical replication is working correctly.
Cutover
Once the LAG drops to near zero, stop all writes to the source database. Wait until the LAG reaches exactly zero — at that point both databases are fully in sync.
Now delete the Migration CR to stop the migration process:
$ kubectl delete migration -n demo postgres-migrate
migration.courier.kubedb.com "postgres-migrate" deleted
Finally, update your application’s connection string to point to the target KubeDB-managed PostgreSQL database. The migration is complete.































