How to Build a MySQL Galera Cluster on a Docker Network: Step-by-Step Setup Guide

How to Build a MySQL Galera Cluster on a Docker Network

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 (or wsrep_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) or mysqldump (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 create in Swarm mode).
  • Restrict the SST user’s grants to only what’s required (RELOAD, LOCK TABLES, PROCESS, REPLICATION CLIENT), not ALL 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-net if 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

ProblemCauseResolution
Node2/3 stuck in “Joining”SST failed or sstuser grants missingCheck docker logs galera-node2 for SST errors; verify sstuser credentials match galera.cnf
wsrep_cluster_size = 1 on each nodeNodes can’t resolve each other’s hostnamesConfirm all containers are on the same user-defined network, not default bridge
Cluster won’t bootstrap after crashOld grastate.dat marks cluster as unsafe to bootstrapInspect /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 hostAnother MySQL instance running locallyChange 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.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Build an S3-Compatible Object Store with Cassandra on Kubernetes

How to Build an S3-Compatible Object Store with Cassandra on Kubernetes: Complete Guide

Next Post
How to Dynamically Configuring a Load-Balancer for a MySQL Galera Cluster

How to Dynamically Configure a Load Balancer for a MySQL Galera Cluster: Complete Guide

Related Posts