New to KubeDB? Please start here.

MySQL Database Migration

This guide will show you how to use KubeDB Migration to migrate an existing MySQL database — such as one running on AWS RDS or any external instance — entirely into a KubeDB-managed MySQL with minimal downtime.

Before You Begin

  • At first, you need to have a Kubernetes cluster, and the kubectl command-line tool must be configured to communicate with your cluster.

  • Install KubeDB operator with the kubedb-courier operator enabled in your cluster following the steps here.

  • The source MySQL instance must be network-reachable from within your Kubernetes cluster.

  • The source MySQL instance must have binary logging enabled with binlog_format=ROW and binlog_row_image=FULL. The database user provided for migration must have replication privileges.

  • You should be familiar with the following KubeDB concepts:

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 MySQL 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 MySQL

Add the following to your my.cnf and restart MySQL:

[mysqld]
log_bin          = mysql-bin
binlog_format    = ROW
binlog_row_image = FULL

Then grant the required privileges to the migration user:

GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '<migration-user>'@'%';
FLUSH PRIVILEGES;

AWS RDS MySQL

Enable Automated Backups on the instance (this activates binary logging). Set binlog_format = ROW and binlog_row_image = FULL in an RDS Parameter Group. Then grant replication privileges via SQL as shown above.



Azure Database for MySQL

Binary logging is enabled automatically when backups are on. Set binlog_format and binlog_row_image under Server Parameters in the Azure Portal. Then grant replication privileges via SQL as shown above.



Google Cloud SQL for MySQL

Enable binary logging under Backups in the Cloud Console, then set binlog_format = ROW and binlog_row_image = FULL under Database flags.

Verify prerequisites

$ mysql -h <rds-endpoint>.rds.amazonaws.com -u admin -p
-- Verify binary logging is enabled
SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin       | ON    |
+---------------+-------+

-- Verify binlog format and row image
SHOW VARIABLES LIKE 'binlog_format';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | ROW   |
+---------------+-------+

SHOW VARIABLES LIKE 'binlog_row_image';
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| binlog_row_image | FULL  |
+------------------+-------+

Create a dedicated migration user

Create a dedicated user with the minimum required privileges:

CREATE USER 'migrator'@'%' IDENTIFIED BY '<password>';

-- For CDC (binlog streaming)
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'migrator'@'%';

-- For schema migration (mysqldump) and snapshot
GRANT SELECT, SHOW DATABASES, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES, PROCESS ON *.* TO 'migrator'@'%';

FLUSH PRIVILEGES;

-- Verify
SHOW GRANTS FOR 'migrator'@'%';

The migrator user is referenced in the Kubernetes secret and AppBinding for the rest of this guide.

Create table and seed data

CREATE DATABASE shop;
USE shop;

CREATE TABLE orders (
  id            INT AUTO_INCREMENT 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 CURRENT_TIMESTAMP
);

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 in set (0.00 sec)

Prepare Source Connection Information

First, create an authentication secret using the migrator user credentials:

$ kubectl create secret generic source-mysql-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

Note: For mTLS, also include the client certificate and key by appending
--from-file=tls.crt=$CERT_PATH/tls.crt
--from-file=tls.key=$CERT_PATH/tls.key
to the command above.

Now create an AppBinding with the necessary information. The kubedb-courier operator reads the source MySQL connection information from this AppBinding CR. Use the following YAML to create your AppBinding:

apiVersion: appcatalog.appscode.com/v1alpha1
kind: AppBinding
metadata:
  name: source-mysql
  namespace: demo
spec:
  type: mysql
  version: "8.4.8"
  clientConfig:
    url: "mysql://<rds-endpoint>.rds.amazonaws.com:3306"
  secret:
    name: source-mysql-auth
  tlsSecret: # omit if TLS is disabled
    name: ca-secret

Here,

  • spec.clientConfig.url is the connection URL of the source MySQL instance.
  • spec.secret.name is the reference to the secret we created earlier, containing the MySQL authentication information.

For a KubeDB-managed database, an AppBinding is created by default. So there is no need to create one for the target database.

Create Target MySQL Database

KubeDB implements a MySQL CRD to define the specification of a MySQL database. Follow the MySQL object to create the target database.

apiVersion: kubedb.com/v1
kind: MySQL
metadata:
  name: target-mysql
  namespace: demo
spec:
  version: "8.4.8"
  storageType: Durable
  storage:
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 30Gi
  deletionPolicy: Delete
$ kubectl create -f https://github.com/kubedb/docs/raw/v2026.6.19/docs/examples/mysql/migration/target-mysql.yaml
mysql.kubedb.com/target-mysql created

Note: Adjust the resources.requests.storage based on source database.

Wait untill target-mysql has status Ready

Apply Migration CR

To Migrate 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: mysql-migrate
  namespace: demo
spec:
  source:
    mysql:
      connectionInfo:
        appBinding:
          name: source-mysql
          namespace: demo
        dbName: "mysql"
        maxConnections: 100
      schema:
        enabled: true
        database:
          - shop
        excludeDatabase: []
      snapshot:
        enabled: true
        pipeline:
          workers: 3
          sinkers: 4
          buffer: 12
          write_batch_size: 200
          read_batch_size: 1000
      streaming:
        enabled: true

  target:
    mysql:
      connectionInfo:
        appBinding:
          name: target-mysql
          namespace: demo
        dbName: "mysql"
        maxConnections: 100
$ kubectl apply -f https://github.com/kubedb/docs/raw/v2026.6.19/docs/examples/mysql/migration/mysql-migrate.yaml
migration.courier.kubedb.com/mysql-migrate created

Here we scope the migration to the shop database (schema.database: [shop]), enable both the bulk snapshot and CDC streaming phases, and cap connections at 100 on each side. 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
mysql-migrate   Running   mysql    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-mysql-0 -- mysql -u root -p<root-password>
USE 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 in set (0.00 sec)

Test live CDC streaming

With the migration still running, connect to the source RDS instance and run some DML:

$ mysql -h <rds-endpoint>.rds.amazonaws.com -u migrator -p
-- Insert a new order
INSERT INTO shop.orders (customer_name, product, quantity, status)
VALUES ('Dave', 'Mouse', 1, 'pending');

-- Mark Bob's headphones as delivered
UPDATE shop.orders SET status = 'delivered' WHERE id = 2;

-- Remove the already-shipped laptop order
DELETE FROM shop.orders WHERE id = 1;

Wait a few seconds for the binlog events to propagate, then re-query the target:

USE shop;
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 in set (0.00 sec)

The INSERT, UPDATE, and DELETE are all reflected on the target — CDC streaming 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 mysql-migrate
migration.courier.kubedb.com "mysql-migrate" deleted

Finally, update your application’s connection string to point to the target KubeDB-managed MySQL database. The migration is complete.