The first time I tried running Galera in Docker, I made the classic mistake: I treated it like any other stateless container and expected docker run three times with the same image to just work. It doesn’t — Galera has strict requirements around cluster bootstrapping order, persistent storage, and network addressing that trip up a lot of people moving from bare-metal or VM deployments into containers. This guide walks through building a real three-node Galera cluster on a user-defined Docker network, the right way.
What You’ll Need to Understand First
Before touching Docker, it helps to know what makes Galera different from vanilla MySQL replication:
- Synchronous multi-master replication: every node in the cluster certifies transactions before committing, using the Write-Set Replication (wsrep) API.
- Cluster bootstrap: the very first node must be started with
--wsrep-new-cluster(orwsrep_cluster_address=gcomm://), because it has nothing to join. Every subsequent node joins by pointing at existing members. - State Snapshot Transfer (SST): when a new node joins, it needs a full copy of the dataset. This typically happens via
mariabackup/xtrabackup(physical) ormysqldump(logical). - Quorum: Galera needs an odd number of nodes (3, 5, …) to avoid split-brain — with only 2 nodes, a single node failure can’t determine if it’s the majority.
In Docker terms, this means node startup order matters, and each node needs a stable, resolvable hostname — which is exactly what a user-defined Docker network gives you via Docker’s embedded DNS.
Step 1: Create a User-Defined Docker Network
Docker’s default bridge network doesn’t provide automatic DNS resolution between containers by name — a user-defined network does. This is the foundation the whole cluster depends on.
docker network create --driver bridge galera-net
Verify:
docker network inspect galera-net
Expected output (trimmed):
[
{
"Name": "galera-net",
"Driver": "bridge",
"IPAM": {
"Config": [{"Subnet": "172.20.0.0/16"}]
},
"Containers": {}
}
]
Step 2: Create Persistent Volumes for Each Node
Never rely on container-internal storage for database data — a container recreation will wipe it. Create a named volume per node:
docker volume create galera-node1-data
docker volume create galera-node2-data
docker volume create galera-node3-data
Step 3: Write a Shared Galera Configuration File
Create a local directory with a galera.cnf that all three nodes will mount:
mkdir -p ~/galera-cluster/conf
~/galera-cluster/conf/galera.cnf:
[mysqld]
binlog_format=ROW
default-storage-engine=InnoDB
innodb_autoinc_lock_mode=2
bind-address=0.0.0.0
wsrep_on=ON
wsrep_provider=/usr/lib/galera/libgalera_smm.so
wsrep_cluster_name="docker_galera_cluster"
wsrep_cluster_address="gcomm://galera-node1,galera-node2,galera-node3"
wsrep_sst_method=mariabackup
wsrep_sst_auth="sstuser:SstPassword123!"
Note the wsrep_cluster_address uses the container names — this works because Docker’s embedded DNS resolves them automatically inside galera-net.
Step 4: Start Node 1 — Bootstrap the Cluster
The first node has no cluster to join, so it starts a brand-new one:
docker run -d \
--name galera-node1 \
--network galera-net \
--hostname galera-node1 \
-e MYSQL_ROOT_PASSWORD=RootPassword123! \
-v galera-node1-data:/var/lib/mysql \
-v ~/galera-cluster/conf/galera.cnf:/etc/mysql/conf.d/galera.cnf \
-p 3306:3306 \
mariadb:11.4 \
--wsrep-new-cluster
Check that it started cleanly:
docker logs -f galera-node1
Expected output includes:
[Note] WSREP: Synchronized with group, ready for connections
[Note] mariadbd: ready for connections.
Create the SST user (required for future nodes to sync from this one):
docker exec -it galera-node1 mariadb -uroot -pRootPassword123! -e \
"CREATE USER 'sstuser'@'%' IDENTIFIED BY 'SstPassword123!'; \
GRANT RELOAD, LOCK TABLES, PROCESS, REPLICATION CLIENT ON *.* TO 'sstuser'@'%'; \
FLUSH PRIVILEGES;"
Step 5: Start Nodes 2 and 3 — Join the Cluster
These nodes join normally (no --wsrep-new-cluster flag):
docker run -d \
--name galera-node2 \
--network galera-net \
--hostname galera-node2 \
-e MYSQL_ROOT_PASSWORD=RootPassword123! \
-v galera-node2-data:/var/lib/mysql \
-v ~/galera-cluster/conf/galera.cnf:/etc/mysql/conf.d/galera.cnf \
mariadb:11.4
docker run -d \
--name galera-node3 \
--network galera-net \
--hostname galera-node3 \
-e MYSQL_ROOT_PASSWORD=RootPassword123! \
-v galera-node3-data:/var/lib/mysql \
-v ~/galera-cluster/conf/galera.cnf:/etc/mysql/conf.d/galera.cnf \
mariadb:11.4
Watch node2 perform its SST:
docker logs -f galera-node2
Expected output:
[Note] WSREP: State transfer required: Group state: ..., Local state: ...
[Note] WSREP: Requesting state transfer: success
[Note] WSREP: SST complete, seqno: X
[Note] mariadbd: ready for connections.
Step 6: Verify Cluster Membership
From any node:
docker exec -it galera-node1 mariadb -uroot -pRootPassword123! -e \
"SHOW STATUS LIKE 'wsrep_cluster_size'; SHOW STATUS LIKE 'wsrep_local_state_comment';"
Expected output:
+--------------------+-------+
| Variable_name | Value |
+--------------------+-------+
| wsrep_cluster_size | 3 |
+--------------------+-------+
+----------------------------+--------+
| Variable_name | Value |
+----------------------------+--------+
| wsrep_local_state_comment | Synced |
+----------------------------+--------+
Test replication by writing to node1 and reading from node3:
docker exec -it galera-node1 mariadb -uroot -pRootPassword123! -e \
"CREATE DATABASE testdb; USE testdb; CREATE TABLE t1 (id INT PRIMARY KEY); INSERT INTO t1 VALUES (1);"
docker exec -it galera-node3 mariadb -uroot -pRootPassword123! -e \
"SELECT * FROM testdb.t1;"
Expected output on node3:
+----+
| id |
+----+
| 1 |
+----+
If that row appears on node3 instantly, replication across the Docker network is working.
Doing It the Right Way: Docker Compose
Manually running three docker run commands doesn’t scale for real environments. Here’s the same cluster as a Compose file:
# docker-compose.yml
version: "3.8"
networks:
galera-net:
driver: bridge
volumes:
node1-data:
node2-data:
node3-data:
services:
galera-node1:
image: mariadb:11.4
container_name: galera-node1
hostname: galera-node1
networks:
- galera-net
environment:
MYSQL_ROOT_PASSWORD: RootPassword123!
volumes:
- node1-data:/var/lib/mysql
- ./conf/galera.cnf:/etc/mysql/conf.d/galera.cnf
command: ["--wsrep-new-cluster"]
ports:
- "3306:3306"
galera-node2:
image: mariadb:11.4
container_name: galera-node2
hostname: galera-node2
networks:
- galera-net
environment:
MYSQL_ROOT_PASSWORD: RootPassword123!
volumes:
- node2-data:/var/lib/mysql
- ./conf/galera.cnf:/etc/mysql/conf.d/galera.cnf
depends_on:
- galera-node1
galera-node3:
image: mariadb:11.4
container_name: galera-node3
hostname: galera-node3
networks:
- galera-net
environment:
MYSQL_ROOT_PASSWORD: RootPassword123!
volumes:
- node3-data:/var/lib/mysql
- ./conf/galera.cnf:/etc/mysql/conf.d/galera.cnf
depends_on:
- galera-node1
Important caveat: depends_on only waits for the container to start, not for MySQL/Galera to be ready. In production Compose or Swarm setups, add a healthcheck and condition: service_healthy:
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-uroot", "-pRootPassword123!"]
interval: 5s
timeout: 5s
retries: 10
Bring it up:
docker compose up -d
docker compose logs -f
Internal Architecture: What’s Actually Happening on the Network
Each container gets its own network namespace on galera-net, with an internal IP in the subnet Docker assigned (e.g., 172.20.0.2). Docker runs an embedded DNS server at 127.0.0.11 inside each container, which resolves galera-node1, galera-node2, galera-node3 to those internal IPs — this is what lets wsrep_cluster_address use hostnames instead of hardcoded IPs, which would break every time you recreate the containers.
Galera itself uses several ports:
- 3306: standard MySQL client connections
- 4567: Galera Cluster replication traffic (gcomm)
- 4568: Incremental State Transfer (IST)
- 4444: State Snapshot Transfer (SST)
All of these need to be reachable between containers (handled automatically by the bridge network) but generally should not be exposed to the host/public internet except 3306, and even that ideally only through your load balancer.
Storage and Data Persistence
Named volumes (galera-node1-data, etc.) live under /var/lib/docker/volumes/ on the host and persist independently of container lifecycle. This means you can docker compose down and docker compose up -d again without losing data — but be careful: docker compose down -v deletes volumes, which will destroy your cluster’s data permanently. Always back up before running destructive commands.
Security Best Practices
- Never bake passwords into the image; use environment variables or Docker secrets (
docker secret createin Swarm mode). - Restrict the SST user’s grants to only what’s required (
RELOAD, LOCK TABLES, PROCESS, REPLICATION CLIENT), notALL PRIVILEGES. - Don’t expose ports 4567/4568/4444 outside the Docker network — only container-to-container traffic needs them.
- Use
docker network create --internal galera-netif you want a fully isolated network with no external routing, and put a reverse proxy/load balancer container on a second, external-facing network instead.
Troubleshooting
| Problem | Cause | Resolution |
|---|---|---|
| Node2/3 stuck in “Joining” | SST failed or sstuser grants missing | Check docker logs galera-node2 for SST errors; verify sstuser credentials match galera.cnf |
wsrep_cluster_size = 1 on each node | Nodes can’t resolve each other’s hostnames | Confirm all containers are on the same user-defined network, not default bridge |
| Cluster won’t bootstrap after crash | Old grastate.dat marks cluster as unsafe to bootstrap | Inspect /var/lib/mysql/grastate.dat; if safe_to_bootstrap: 0, manually set to 1 on the node with the most recent data only |
| Port 3306 already in use on host | Another MySQL instance running locally | Change host port mapping, e.g. -p 3307:3306 |
Real-World Deployment Notes
For anything beyond local testing, I’d strongly recommend running each Galera node on a separate host with Docker installed, connected via an overlay network (Docker Swarm mode or a CNI-based setup) rather than three containers on one machine — colocating all three nodes on a single host defeats the fault-tolerance purpose of Galera entirely, since one host failure takes down the whole cluster. In Swarm mode, docker network create --driver overlay --attachable galera-net gives you the same hostname-resolution behavior across hosts that the single-host bridge network gives you locally. I’ve also found it worth pinning exact image digests (not just mariadb:11.4) in any environment where reproducibility matters, since a silent minor-version bump across node restarts can occasionally introduce subtle SST incompatibilities between nodes running different patch versions.
Summary
Running Galera in Docker isn’t fundamentally different from running it on bare metal — the wsrep protocol doesn’t care what’s hosting it — but Docker’s networking model changes how nodes discover each other, and container lifecycle changes how you think about data persistence. Get the user-defined network, named volumes, and bootstrap ordering right, and the rest is standard Galera administration.