Setting up MySQL replication for the first time trips almost everyone up on the same two things: the containers can't see each other on the network, and the slave gets locked out of its own bootstrap process. This guide walks through a working 1 master + 2 read-replica setup using Docker Compose, GTID-based replication, and covers the exact errors you'll likely hit along the way.
Architecture
| Node | Container Name | Server ID | Host Port | Role |
|---|---|---|---|---|
| Master | mysql-master |
1 | 3306 | Read/Write |
| Slave 1 | mysql-slave-1 |
2 | 3307 | Read-only |
| Slave 2 | mysql-slave-2 |
3 | 3308 | Read-only |
All three containers share one Docker bridge network so they can resolve each other by hostname, and replication uses GTID auto-positioning — no manual binlog file/offset tracking required.
Prerequisites
- Docker + Docker Compose installed
- All three services defined in a single
docker-compose.yml— splitting them into separate files/folders puts each on its own isolated network and they won't be able to see each other
1. The docker-compose.yml
version: "3.8"
networks:
mysql-net:
driver: bridge
services:
mysql-master:
image: mysql:8.0.20
container_name: mysql-master
networks:
- mysql-net
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
command:
- --server-id=1
- --log-bin=mysql-bin
- --binlog-format=ROW
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
- --default-authentication-plugin=mysql_native_password
volumes:
- ./data-1/mysql:/var/lib/mysql
restart: always
mysql-slave-1:
image: mysql:8.0.20
container_name: mysql-slave-1
networks:
- mysql-net
depends_on:
- mysql-master
ports:
- "3307:3306"
environment:
MYSQL_ROOT_PASSWORD: password
command:
- --server-id=2
- --relay-log=mysql-relay-bin
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
- --default-authentication-plugin=mysql_native_password
volumes:
- ./data-2/mysql:/var/lib/mysql
restart: always
mysql-slave-2:
image: mysql:8.0.20
container_name: mysql-slave-2
networks:
- mysql-net
depends_on:
- mysql-master
ports:
- "3308:3306"
environment:
MYSQL_ROOT_PASSWORD: password
command:
- --server-id=3
- --relay-log=mysql-relay-bin
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
- --default-authentication-plugin=mysql_native_password
volumes:
- ./data-3/mysql:/var/lib/mysql
restart: always
⚠️ Don't add
--read-only/--super-read-onlyto the slavecommand:block yet. MySQL's entrypoint script spins up a temporary server on first boot to applyMYSQL_ROOT_PASSWORD. If read-only flags are already active at that point, that step silently fails, root ends up with an empty password, and you'll hitAccess deniedon every login attempt afterward. Read-only gets applied manually in Step 6, once the container has already initialized.
2. Start the cluster
docker-compose up -d
Give it 15-20 seconds to initialize, then confirm all three nodes are reachable:
docker exec -it mysql-master mysql -uroot -ppassword -e "SELECT 1;"
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "SELECT 1;"
docker exec -it mysql-slave-2 mysql -uroot -ppassword -e "SELECT 1;"
If any of these return Access denied, the data directory probably wasn't empty on first boot (leftovers from a previous attempt), so MYSQL_ROOT_PASSWORD never got applied:
docker-compose down
sudo rm -rf ./data-1/mysql ./data-2/mysql ./data-3/mysql
docker-compose up -d
3. Create the replication user on the master
docker exec -it mysql-master mysql -uroot -ppassword
CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'replpass';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
Sanity check:
SELECT user, host, plugin FROM mysql.user WHERE user='repl';
4. Point each slave at the master
slave-1:
docker exec -it mysql-slave-1 mysql -uroot -ppassword
CHANGE MASTER TO
MASTER_HOST='mysql-master',
MASTER_PORT=3306,
MASTER_USER='repl',
MASTER_PASSWORD='replpass',
MASTER_AUTO_POSITION=1;
START SLAVE;
slave-2:
docker exec -it mysql-slave-2 mysql -uroot -ppassword
CHANGE MASTER TO
MASTER_HOST='mysql-master',
MASTER_PORT=3306,
MASTER_USER='repl',
MASTER_PASSWORD='replpass',
MASTER_AUTO_POSITION=1;
START SLAVE;
💡 MySQL 8.0.20 uses the legacy
CHANGE MASTER TO/START SLAVEsyntax. From 8.0.22 onward it'sCHANGE REPLICATION SOURCE TO/START REPLICA.
5. Verify replication is actually running
SHOW SLAVE STATUS\G
You want to see:
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 0
Quick one-liner for a health check:
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "SHOW SLAVE STATUS\G" \
| grep -E "Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master"
| Status | Meaning |
|---|---|
Slave_IO_Running: Connecting |
Can't reach the master — check the network/hostname |
Slave_IO_Running: No |
Check Last_IO_Error (often an auth issue) |
Slave_SQL_Running: No |
Check Last_SQL_Error (usually a data conflict) |
6. Lock the slaves down to read-only
Now that replication is confirmed working, enforce read-only on each slave:
SET GLOBAL read_only = ON;
SET GLOBAL super_read_only = ON;
This blocks direct client writes while still letting the replication thread apply changes coming from the master. Note it won't survive a container restart on its own — either re-run it, or bake it into a mounted my.cnf once you're confident replication is stable.
7. Put it through its paces
Basic write → read:
-- on master
CREATE DATABASE testdb;
USE testdb;
CREATE TABLE t1 (id INT PRIMARY KEY, name VARCHAR(50));
INSERT INTO t1 VALUES (1, 'hello');
-- on each slave
USE testdb;
SELECT * FROM t1;
Bulk insert + row count comparison:
-- on master
CREATE TABLE t2 (id INT PRIMARY KEY AUTO_INCREMENT, val VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO t2 (val)
SELECT CONCAT('bulk-', seq)
FROM (
SELECT a.N + b.N*10 + c.N*100 + 1 AS seq
FROM (SELECT 0 N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a
CROSS JOIN (SELECT 0 N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b
CROSS JOIN (SELECT 0 N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c
) t;
docker exec -it mysql-master mysql -uroot -ppassword -e "SELECT COUNT(*) FROM testdb.t2;"
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "SELECT COUNT(*) FROM testdb.t2;"
docker exec -it mysql-slave-2 mysql -uroot -ppassword -e "SELECT COUNT(*) FROM testdb.t2;"
All three counts should match.
Checksum comparison (catches data drift a row count would miss):
docker exec -it mysql-master mysql -uroot -ppassword -e "CHECKSUM TABLE testdb.t2;"
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "CHECKSUM TABLE testdb.t2;"
docker exec -it mysql-slave-2 mysql -uroot -ppassword -e "CHECKSUM TABLE testdb.t2;"
UPDATE / DELETE propagation:
-- on master
UPDATE testdb.t2 SET val = 'updated-row' WHERE id = 1;
DELETE FROM testdb.t2 WHERE id = 2;
-- on slaves
SELECT * FROM testdb.t2 WHERE id IN (1,2);
GTID set comparison (the most rigorous check available):
docker exec -it mysql-master mysql -uroot -ppassword -e "SELECT @@GLOBAL.GTID_EXECUTED;"
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "SELECT @@GLOBAL.GTID_EXECUTED;"
docker exec -it mysql-slave-2 mysql -uroot -ppassword -e "SELECT @@GLOBAL.GTID_EXECUTED;"
Replication lag under load:
# terminal 1 — generate load on the master
docker exec -it mysql-master mysql -uroot -ppassword -e "
USE testdb;
$(for i in $(seq 1 500); do echo "INSERT INTO t2 (val) VALUES ('load-test-$i');"; done)
"
# terminal 2 — watch lag on a slave live
watch -n 1 'docker exec mysql-slave-1 mysql -uroot -ppassword -e "SHOW SLAVE STATUS\G" 2>/dev/null | grep -E "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"'
Confirm the read-only lock actually holds:
docker exec -it mysql-slave-1 mysql -uroot -ppassword -e "USE testdb; INSERT INTO t2 (val) VALUES ('should-fail');"
Expected:
ERROR 1290 (HY000): The MySQL server is running with the --super-read-only option so it cannot execute this statement
Master restart recovery:
docker stop mysql-master
docker start mysql-master
Insert new data on the master once it's back, then confirm Slave_IO_Running recovers to Yes on its own and the slaves catch up.
Troubleshooting cheat sheet
| Symptom | Cause | Fix |
|---|---|---|
Access denied for user 'root'@'localhost' right after first startup |
Data dir wasn't empty on first init, so MYSQL_ROOT_PASSWORD never got applied |
docker-compose down, wipe the affected data-N/mysql, docker-compose up -d again |
ERROR 1290: --super-read-only option in docker logs during bootstrap |
Read-only flags set at startup blocked the entrypoint's temp init server | Remove read-only flags from command:; apply SET GLOBAL read_only/super_read_only = ON manually after startup |
Slave_IO_Running: Connecting |
Slave can't reach mysql-master by hostname |
Make sure every service is on the same Docker network; don't mix network_mode: host with bridge networking |
Slave_IO_Running: No, auth error in Last_IO_Error
|
Wrong replication credentials, or caching_sha2_password handshake issue |
Recreate repl with IDENTIFIED WITH mysql_native_password; set --default-authentication-plugin=mysql_native_password
|
| Nodes in separate compose files can't see each other | Each docker-compose up creates its own isolated default network |
Merge all three services into one docker-compose.yml
|
A note on security
password and replpass here are throwaway credentials for local testing — swap them for real secrets before this touches anything beyond your laptop. Also scope 'repl'@'%' down to your actual replica subnet in production rather than allowing any host, and consider TLS for replication traffic.
That's a full working replica set: one master accepting writes, two read replicas staying in sync via GTID, and a battery of checks to prove it's actually consistent rather than just "looks running." Happy to follow up with a piece on failover / promoting a replica to master if there's interest.
Top comments (0)