Before picking a MySQL clustering approach on the RHEL family, the question worth settling is whether you want asynchronous replication with a promotion tool bolted on, or synchronous replication where every node already holds a committed copy. Percona XtraDB Cluster is the second kind. Any node can accept a write, every commit is certified across the cluster before it returns, and a node that dies takes no unreplicated data with it.
That guarantee is what we are buying, and it is why this build is worth doing carefully rather than quickly. Every command in the build path below was run on three Rocky Linux 10 nodes in August 2026 against Percona XtraDB Cluster 8.4.10-10.1, including two steps that most guides get in the wrong order and one piece of SELinux advice that quietly destroys a cluster. We tested on Rocky. AlmaLinux 10 is the same RHEL 10 rebuild with the same package set, so the steps should carry over unchanged, but we did not run them there.
This guide replaces an earlier version of this article that targeted Rocky Linux 8 and the 8.0 release line. Percona XtraDB Cluster 8.0 reached end of life on 1 April 2026 according to the Percona software lifecycle matrix, so that content described an unsupported deployment and has been rewritten from scratch against the 8.4 LTS line. If a cluster is more than you need, a single MySQL server on Rocky or AlmaLinux is the simpler road.
What we are building, and why three nodes
Three nodes is the smallest cluster that can survive losing one. Galera keeps a partition writable only while it holds a majority of members, so a two node cluster that loses a node drops to one out of two, fails the majority test, and stops accepting writes. Adding a third node means one can fail and the remaining two still form a majority. In practice this means three is the floor for production, not a recommendation you can trim to save a VM.
The trade-off is write latency. Every commit waits for certification across the cluster, so inter node network latency lands directly in your transaction time. Members belong on the same low latency segment, and they should be specified identically, because a synchronous cluster commits at the pace of its slowest member. One undersized node throttles every writer.
Sizing follows the workload rather than a template. InnoDB wants its buffer pool at roughly 70 percent of a dedicated database host’s memory, so the working set sets the RAM figure. Disk is the dataset plus binary logs plus the Galera write set cache plus headroom, on storage that handles sync writes quickly. Our test nodes ran 2 vCPU and 4 GB with 30 GB disks, which is a floor for following along rather than a production spec.
These are the addresses used throughout, and the ports the cluster needs open between members.
| Host | Address | Role |
|---|---|---|
| rpxc01 | 192.168.1.231 | bootstrap node |
| rpxc02 | 192.168.1.232 | joiner |
| rpxc03 | 192.168.1.233 | joiner |
| Port | Protocol | Carries |
|---|---|---|
| 3306 | TCP | MySQL client traffic |
| 4567 | TCP and UDP | Galera replication and membership |
| 4568 | TCP | Incremental state transfer |
| 4444 | TCP | State snapshot transfer |
Set the values every node shares
Three values get typed into commands later on, and all three are easy to fat finger. Set them on the first node. They live only in the current shell, so re-run this if you open a new session before reaching the root password step or the certificate distribution:
export PXC2="192.168.1.232"
export PXC3="192.168.1.233"
export ROOT_PASS="ChangeMeStrong2026"
One constraint on that password, and on anything else you put in a MySQL config file. A bare # starts a comment, so an unquoted value containing one is truncated at that character. Quoting the value protects it, but the safer habit in a file several people will edit is to keep # out of passwords entirely rather than rely on everyone remembering the quotes.
Give each node name resolution for the other two:
sudo vim /etc/hosts
Add the three members:
192.168.1.231 rpxc01
192.168.1.232 rpxc02
192.168.1.233 rpxc03
Add the Percona repositories
Percona distributes a helper package that switches its repositories on and off, which is easier than hand editing repo files. Install it on all three nodes:
sudo dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm
Point it at the 8.4 LTS line. The setup subcommand disables the other Percona repositories first, which stops an 8.0 package being resolved by accident:
sudo percona-release setup pxc-84-lts
Here is the part that differs from the Debian and Ubuntu instructions, and it is easy to miss because the failure arrives much later. State snapshot transfers are performed by Percona XtraBackup, and on Ubuntu that tool lives in the tools repository. On the RHEL family it does not. The tools repository carries no XtraBackup build for el10 at all, so enabling it achieves nothing here. The component you want is pxb-84-lts:
sudo percona-release enable pxb-84-lts release
Confirm the tool is now offered before going any further, because the cluster installs perfectly happily without it and only fails when the second node tries to join:
dnf list --available 'percona-xtrabackup*' | grep xtrabackup
The el10 build should appear:
percona-xtrabackup-84.x86_64 8.4.0-6.1.el10 pxb-84-lts-release-x86_64
One instruction you can ignore on Rocky 10: several guides, including Percona’s own, tell you to run dnf module disable mysql before installing. That step belongs to RHEL 8 and 9. Modularity is deprecated in DNF5 and Rocky 10 ships no MySQL module at all, so there is nothing to disable. Checking with dnf module list mysql returns a deprecation warning and no matching modules.
Install the Percona XtraDB Cluster packages
Install the server and the backup tool together on each node:
sudo dnf install -y percona-xtradb-cluster percona-xtrabackup-84
Check what landed before configuring anything:
mysqld --version
The WSREP provider version at the end of that line is the Galera build, and it needs to match across all three nodes:
/usr/sbin/mysqld Ver 8.4.10-10.1 for Linux on x86_64 (Percona XtraDB Cluster (GPL), Release rel10, Revision 4a9cd61, WSREP version 26.1.4.3)
Configure the wsrep provider, and where that config has to live
This is the step that costs people the most time on the RHEL family, and the reason is that nothing complains. On Debian and Ubuntu, /etc/mysql/my.cnf ends with !includedir lines, so configuration can be dropped into a separate file and picked up automatically. Percona’s /etc/my.cnf on RHEL has no include directive at all. The /etc/my.cnf.d/ directory exists, ships empty, and is never read.
A reader following the Debian pattern drops a tidy override file in there, sees no error, and then spends an hour debugging a state transfer failure whose actual cause is that the settings were discarded.

The same capture shows the other RHEL specific detail worth noting, which is that the Galera library sits under /usr/lib64 rather than /usr/lib. Percona’s shipped template already points at the right path, so this only matters if you are adapting a config written for Debian.
Edit the main file directly on the first node:
sudo vim /etc/my.cnf
Percona ships a working template rather than a blank file, so most of this is already present and only the identity values need setting. The block below shows the settings that matter rather than the whole file, so edit these lines in place instead of pasting over what is there. The template also carries a [client] section, a pid-file, a binary log expiry and wsrep_log_conflicts, all of which should stay. Note that wsrep_node_address ships commented out with an example address, so that one needs uncommenting rather than editing:
[mysqld]
server-id=1
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
log-error=/var/log/mysqld.log
wsrep_provider=/usr/lib64/galera4/libgalera_smm.so
wsrep_cluster_address=gcomm://192.168.1.231,192.168.1.232,192.168.1.233
wsrep_cluster_name=pxc-cluster
wsrep_node_address=192.168.1.231
wsrep_node_name=rpxc01
wsrep_applier_threads=8
wsrep_sst_method=xtrabackup-v2
innodb_autoinc_lock_mode=2
pxc_strict_mode=ENFORCING
Repeat on the other two nodes, changing only server-id, wsrep_node_address and wsrep_node_name. The cluster address is identical everywhere because it lists all members rather than peers.
Two settings in the shipped template are worth correcting while you are in the file. It still uses wsrep_slave_threads, which 8.4 warns is deprecated in favour of wsrep_applier_threads, and it sets binlog_format=ROW, which is now both the default and a deprecated variable. Leaving them produces warnings in the error log on every start.
There is also a setting you should not add, no matter what an older tutorial says. wsrep_sst_auth does not exist on the 8.4 line, because state transfers now authenticate through an internal account the server creates and locks by itself. There is no SST user to make. Put it under [mysqld] and the server refuses to start:
[ERROR] [MY-000067] [Server] unknown variable 'wsrep_sst_auth=sstuser:pass'.
Older guides sometimes place it under [sst] instead, where it is quietly ignored and the node starts normally. That is worse, because it leaves you believing a setting is doing something.
Leave the file there for now. It has no TLS settings in it yet, and adding them at this point is the single most common way to end up with a cluster that will not start. The reason becomes obvious once the first node has run.
What SELinux is actually doing to mysqld here
This section is the one to read even if you skim the rest, because the conventional advice is actively harmful on this package set. Start by checking the global mode, which on a stock Rocky install reports enforcing:
getenforce
That answer is true and misleading at the same time. Ask the more specific question, which is whether the database domain itself is being confined:
semanage permissive -l
An empty list of builtin types prints first. Under the customised heading below it, the database domain appears as an exception, put there by the package itself:
Customized Permissive Types
mysqld_t
Percona’s own post install script puts it there, and the comment it ships with tells you they expect you to reverse it:
rpm -q --scripts percona-xtradb-cluster-server | grep -A2 'permissive'
The intent is spelled out in the script:
# Let's setup mysqld_t in permissive mode, even if SELinux is in enforcing mode.
# User will switch it back to enforcing if needed after checking audit logs.
semanage permissive -a mysqld_t
Why removing that permissive rule breaks the cluster
Taking the package at its word and confining the domain looks harmless. We tried it. The bootstrap node starts perfectly, reports synced, serves queries, and gives you every reason to believe the hardening worked. It survives because a bootstrap node never performs a state transfer. The first node that tries to join is the one that dies:
Job for mysql.service failed because a fatal signal was delivered to the control process.
Nothing in that message mentions SELinux, and neither does the error log, which reports a segmentation fault after failing to launch the transfer helper:
2026-08-01T11:30:21.428234Z 0 [ERROR] [MY-000000] [WSREP] posix_spawnp() failed: 13 (Permission denied)
2026-08-01T11:30:21.428284Z 0 [ERROR] [MY-000000] [WSREP] Failed to execute: wsrep_sst_xtrabackup-v2 --role 'joiner' --address '192.168.1.233' --datadir '/var/lib/mysql/' --basedir '/usr/' --plugindir '/usr/lib64/mysql/plugin/' --defaults-file '/etc/my.cnf' --defaults-group-suffix '' --parent '21501' --mysqld-version '8.4.10-10' '' : 13 (Permission denied)
2026-08-01T11:30:21.428566Z 1 [ERROR] [MY-000000] [WSREP] Failed to prepare for 'xtrabackup-v2' SST. Unrecoverable.
The server catches the fault, prints mysqld got signal 11, and then aborts, which is why the audit log records the end of it as signal 6 rather than 11. The backup tool is installed and the helper script is present and executable, so every obvious check passes and nothing in any of it says SELinux. The denial is in the audit log, but it is easy to miss, because a crash tends to get investigated a while after it happened and the obvious ausearch -ts recent only reaches back ten minutes. Search the whole day instead:
sudo ausearch -m avc,anom_abend -ts today | grep mysqld_t
One failed start produces the chain below, trimmed here to the fields that matter. A full day’s search returns one such chain per attempt, so expect duplicates if you tried more than once:
avc: denied { dac_override } for pid=21491 comm="mysqld" capability=1 scontext=system_u:system_r:mysqld_t:s0 tclass=capability permissive=0
avc: denied { setgid } for pid=21491 comm="mysqld" capability=6 scontext=system_u:system_r:mysqld_t:s0 tclass=capability permissive=0
avc: denied { setpgid } for pid=21523 comm="sst_joiner" scontext=system_u:system_r:mysqld_t:s0 tclass=process permissive=0
type=ANOM_ABEND pid=21501 comm="applier-0" exe="/usr/sbin/mysqld" sig=6
Spawning the transfer helper sets the child’s process group, which means a setpgid call, and the running policy has no rule permitting it. The call returns EACCES, which is the 13 the server reports. The permission error is real, it just belongs to a process group operation rather than to the helper script everyone goes and checks.
Percona ships a policy for this, and Rocky 10 cannot load it
The obvious objection is that a database vendor would surely ship SELinux policy covering its own state transfers. Percona does. Two modules arrive with the server package:
rpm -ql percona-xtradb-cluster-server | grep selinux
Both are present on disk, and the second one exists specifically to permit the transfer script:
/usr/share/percona-xtradb-cluster/selinux/percona-xtradb-cluster.pp
/usr/share/percona-xtradb-cluster/selinux/wsrep-sst-xtrabackup-v2.pp
Upstream, the first of those grants allow mysqld_t self:process setpgid and allow mysqld_t self:capability dac_override, annotated with a comment naming posix_spawnp as the reason. That is precisely the pair of denials above. So ask whether they are actually loaded:
sudo semodule -l | grep -E 'percona|wsrep'
On Rocky Linux 10 that returns nothing at all. Loading one by hand shows why:
sudo semodule -i /usr/share/percona-xtradb-cluster/selinux/percona-xtradb-cluster.pp
The module was compiled against a newer policy format than this distribution’s tooling accepts:
libsemanage.semanage_compile_module: percona-xtradb-cluster: libsepol.policydb_read: policydb module version 24 does not match my version range 4-23.
libsemanage.semanage_compile_module: percona-xtradb-cluster: libsepol.sepol_module_package_read: invalid module in module package (at section 0).
semodule: Failed!
The other module fails identically. The package’s install script loads them with a trailing || :, so both failures are discarded at install time and nothing appears in any log. The result is a machine where the policy that would authorise state transfers was shipped, was never loaded, and where the only thing standing between you and a broken joiner is the permissive exception the same script sets two lines later.
That reframes the advice. Percona’s SELinux page tells you to remove the permissive exception once the system journal shows no issues, and on a distribution where their modules load that is reasonable. On Rocky 10 they do not load, so following it confines the database with no Percona policy behind it at all. Our reading, rather than Percona’s position, is that until those modules ship in a format this distribution can compile, leaving the domain permissive is the only configuration that has been shown to work here.
Putting the exception back is the whole fix. Nothing else about the node, its config or its certificates needs to change. The two commands below are the recovery for a joiner that has already failed this way, not a step in the build, so skip them if you are simply following along:
sudo semanage permissive -a mysqld_t
sudo systemctl start mysql
We tested that path on its own. The failed attempt left eighteen files in the data directory, and restoring the exception was still enough on its own to bring the node to a synced state without touching them. If yours does still refuse, clear the data directory and try once more. Do this on the failed joiner only, never on a member holding data you still need. Note the use of find rather than the obvious sudo rm -rf /var/lib/mysql/*: the data directory is mode 751, so that glob is expanded by your own unprivileged shell, matches nothing, and the command reports success while deleting nothing at all:
sudo find /var/lib/mysql -mindepth 1 -delete
sudo systemctl start mysql
So on this release the permissive domain is load bearing rather than laziness. Removing it does not harden the cluster, it removes the cluster’s ability to add a member, and it does so in a way that surfaces days later when a node needs to be replaced. If you do want the domain confined, the practical route is to build your own module from the denials the cluster actually generates, using ausearch -m avc -ts today | audit2allow -M pxc-sst after a failed join, then review the generated rules before loading them. Test whatever you produce against a joiner rather than a bootstrap node, because a bootstrap node never exercises the code path that breaks.
Label the ports and open the firewall
Label the cluster ports anyway, on all three nodes. While the domain stays permissive nothing here is enforced, so treat this as bookkeeping: an accurate local record of what the cluster uses, which is what any policy you later write would be built against. None of the three carries the database’s own label in the base policy. 4444 ships as a Kerberos port, 4567 as a TRAM port, and 4568 is claimed by the Percona package during installation:
sudo semanage port -a -t mysqld_port_t -p tcp 4567
sudo semanage port -a -t mysqld_port_t -p tcp 4568
sudo semanage port -a -t mysqld_port_t -p tcp 4444
All three answer with a notice rather than silence, because all three are already spoken for. This is expected and not an error. The -a flag corrects itself and exits successfully, which is why no fallback to -m is needed:
Port tcp/4567 already defined, modifying instead
Port tcp/4568 already defined, modifying instead
Port tcp/4444 already defined, modifying instead
If firewalld is running, open the same ports on each node. Minimal Rocky cloud images ship without firewalld installed at all, so check before assuming these rules are doing anything:
systemctl is-active firewalld
When it is active, add the cluster ports and reload:
sudo firewall-cmd --permanent --add-port={3306,4567,4568,4444}/tcp
sudo firewall-cmd --permanent --add-port=4567/udp
sudo firewall-cmd --reload
Bootstrap the first node
The first node has no peers to synchronise from, so it starts through a dedicated unit that tells Galera to form a new primary component. Run this on the first node only, and never on more than one node at a time. Systemd fills in the unit suffix, so the name below is complete as written:
sudo systemctl start mysql@bootstrap
The RHEL packages generate a temporary root password on first start and write it to the error log, rather than prompting during installation the way the Debian packages do:
sudo grep 'temporary password' /var/log/mysqld.log
It appears at the end of the line:
2026-08-01T10:52:17.722033Z 6 [Note] [MY-010454] [Server] A temporary password is generated for root@localhost: xxxxxxxxxxxx
That password only permits one statement. Anything else returns ERROR 1820 until it has been changed, so change it immediately:
mysql -u root -p --connect-expired-password -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${ROOT_PASS}';"
Confirm the single member cluster is up before touching the other nodes:
mysql -u root -p -e "SHOW STATUS LIKE 'wsrep_cluster_size'; SHOW STATUS LIKE 'wsrep_cluster_status';"
A size of one with primary status is exactly right at this stage:
Variable_name Value
wsrep_cluster_size 1
Variable_name Value
wsrep_cluster_status Primary
Give every node the same certificate authority
Galera traffic is encrypted by default on this line, and every node signs its own certificate set the first time it initialises. Three nodes that each trust a different authority cannot complete a handshake, and the giveaway shows up in the Galera membership lines rather than in the transfer output. One authority has to be shared before the first join.
The ordering here is what most guides get wrong, including the earlier version of this one. The certificates do not exist after dnf install, and they do not exist after you write the config. They are created by the first server start, which is the step you have just completed. Look in the data directory now and the full set is there:
sudo sh -c 'ls -1 /var/lib/mysql/*.pem'
Eight files, of which the certificate authority and the server pair are the ones the cluster needs to agree on:
/var/lib/mysql/ca-key.pem
/var/lib/mysql/ca.pem
/var/lib/mysql/client-cert.pem
/var/lib/mysql/client-key.pem
/var/lib/mysql/private_key.pem
/var/lib/mysql/public_key.pem
/var/lib/mysql/server-cert.pem
/var/lib/mysql/server-key.pem
Try this before bootstrapping and every copy command fails with cannot stat '/var/lib/mysql/ca.pem', which is the tell that you are following a guide with the steps in the wrong order.
Put a copy of the set somewhere outside the data directory. The reason is not that a transfer would delete it, because the transfer script explicitly keeps .pem files on the joiner, and you can confirm that yourself in the cpat keep pattern inside /usr/bin/wsrep_sst_xtrabackup-v2. The reason is the opposite. Node one has just generated and started trusting its own authority, and any node that starts without explicit paths does the same, so left to defaults you end up with as many certificate authorities as you have members. Naming one shared directory in the config is what removes that dependence on defaults. Percona’s own documentation gives a different reason, that the data directory is overwritten during a transfer. That holds for the clone transfer method, but not for the XtraBackup one used here, where the keep pattern protects the files:
sudo mkdir -p /etc/mysql/certs
sudo cp /var/lib/mysql/ca.pem /var/lib/mysql/ca-key.pem \
/var/lib/mysql/server-cert.pem /var/lib/mysql/server-key.pem \
/var/lib/mysql/client-cert.pem /var/lib/mysql/client-key.pem \
/etc/mysql/certs/
sudo chown -R mysql:mysql /etc/mysql/certs
sudo chmod 600 /etc/mysql/certs/*-key.pem
sudo chmod 644 /etc/mysql/certs/ca.pem /etc/mysql/certs/*-cert.pem
Now the config can name them. Open the main file on all three nodes, appending rather than using a drop-in for the reason covered earlier:
sudo vim /etc/my.cnf
Three lines, which belong under the existing [mysqld] section. The shipped template’s last section is [mysqld], so appending to the end of the file puts them in the right place:
ssl-ca=/etc/mysql/certs/ca.pem
ssl-cert=/etc/mysql/certs/server-cert.pem
ssl-key=/etc/mysql/certs/server-key.pem
Most guides, Percona’s own included, also have you repeat those three paths in an [sst] section alongside encrypt=4. On a default install that block is dead weight, and Percona’s encryption documentation says as much: the system does not check the [sst] section for key and certificate files. Cluster traffic encryption is on out of the box, and in that mode the transfer script reads the server’s own settings, with a comment in the source saying so explicitly:
# Look for the [mysqld] files only, not the ones in [sst]
ssl_ca=$(parse_cnf mysqld ssl-ca "")
Adding it does no harm, and it matters if you ever turn cluster traffic encryption off, but it is not what makes the transfers work here. Check the setting yourself with SHOW VARIABLES LIKE 'pxc_encrypt_cluster_traffic' before deciding you need it.
Ship the directory to the other two nodes, leaving the authority’s private key behind. Every node needs to trust the CA and present a certificate, but none of them needs to sign anything, and a copy of ca-key.pem on every node means any one compromised node can mint certificates the whole cluster trusts. This is a cluster where all three members share one server key already, so keeping the signing key on a single node is the one boundary still worth holding. The client pair travels along so that anything you later point at mutual TLS finds it in the same place, though nothing in this build uses it yet. The package creates /etc/mysql/certs on every node, so the receiving side already has somewhere to unpack into:
sudo tar -C /etc/mysql --exclude=certs/ca-key.pem -cf - certs | ssh root@${PXC2} tar -C /etc/mysql -xf -
sudo tar -C /etc/mysql --exclude=certs/ca-key.pem -cf - certs | ssh root@${PXC3} tar -C /etc/mysql -xf -
ssh root@${PXC2} chown -R mysql:mysql /etc/mysql/certs
ssh root@${PXC3} chown -R mysql:mysql /etc/mysql/certs
These run as root over SSH deliberately. Piping a tar stream into ssh host "sudo tar -xf -" looks tidier and fails, because the stream occupies standard input and leaves sudo with no terminal on which to ask for a password. If root SSH is closed on your hosts, land the tarball in the remote user’s home directory first and unpack it in a second, interactive session.
Confirm all three now trust the same authority. A mismatch here is the cause of most joins that hang without explanation:
md5sum /etc/mysql/certs/ca.pem
The same digest must come back on every node. Restart the first node so it picks up the relocated files before anything tries to join it:
sudo systemctl restart mysql@bootstrap
The error log confirms which authority Galera loaded, and it should be the relocated path rather than a bare filename resolved against the data directory:
socket.ssl_ca = /etc/mysql/certs/ca.pem
Failed to load provider, or a config pointing at files that are not there
Add those ssl- lines before the certificates exist and the server will not start at all. This is the failure that follows from doing the certificate work too early, and the message names the file rather than the mistake:
[ERROR] [Galera] Bad value '/etc/mysql/certs/server-cert.pem' for SSL parameter 'socket.ssl_cert': -2147483646: 'error:FFFFFFFF80000002:system library::No such file or directory'
[ERROR] [WSREP] Failed to load provider
[ERROR] [Server] Aborting
The fix is the order used above: start the node on the shipped config first, let it generate the set, then relocate and reference it.
Bring in the other two nodes
The remaining nodes use the ordinary service unit. Start them one at a time and let each reach a synced state before starting the next, so the donor is not serving two transfers at once:
sudo systemctl start mysql
Each joiner asks for a state transfer and the donor streams the dataset with XtraBackup. On the joiner we timed, against an empty dataset, the gap between the request and completion was 19 seconds. Treat that as a floor rather than a forecast, because a real dataset scales with its size and the donor’s disk throughput. Note also which node donated:
2026-08-01T11:33:32.790596Z 2 [Note] [MY-000000] [Galera] Requesting state transfer: success, donor: 0
2026-08-01T11:33:51.721435Z 3 [System] [MY-000000] [WSREP] SST completed
2026-08-01T11:33:51.724385Z 0 [Note] [MY-000000] [Galera] 2.0 (rpxc03): State transfer from 0.0 (rpxc02) complete.
2026-08-01T11:33:51.724677Z 0 [Note] [MY-000000] [Galera] Member 2.0 (rpxc03) synced with group.
The donor was the second node rather than the bootstrap node, which surprises people who assume node one serves every transfer. Galera picks a donor from whichever synced members are available, so a busy first node is not automatically the one paying for a rebuild. With all three running, every node should report the same membership and a synced local state.

SELinux stayed globally enforcing through all of that, with the database domain permissive as the package intends. A permissive domain reports rather than blocks, so the transfers you just watched succeed are the same operations that were refused outright a moment ago. The denials are still being written, now with permissive=1 against them, which means the audit log here is a record of what a confined policy would have stopped rather than a clean bill of health. On our cluster the successful transfers logged the same setpgid denial, this time from the donor side, with the flag as the only meaningful difference:
avc: denied { setpgid } for pid=24276 comm="sst_donor" scontext=system_u:system_r:mysqld_t:s0 tclass=process permissive=1
Pulling the three checks together gives you the state to expect on a working cluster:

That capture is from the finished cluster, which is why the bootstrap unit already shows as inactive there. The next section covers that handover. The three ports listed by semanage port -l -C are local customisations, which is a useful way to see exactly what you changed rather than what the policy already shipped, and one of those three was added by the package rather than by you.
Take the first node off the bootstrap unit
The cluster is formed, and there is one loose end that will bite months from now if it is left. Node one is still running under the bootstrap unit, and that unit exists to force a node to ignore its peers and declare itself a new cluster. Percona’s own service file spells out the consequence:
# For bootstrapping, you may not want to enable mysql@bootstrap
# but start it directly since you don't want
# a bootstrapped mysqld coming up on reboot.
The reason is the argument that unit injects, which you can read for yourself:
cat /etc/sysconfig/mysql.bootstrap
Every start through that unit carries it:
EXTRA_ARGS=" --wsrep-new-cluster "
A node that restarts with that flag while the others are running does not rejoin them. It forms a second cluster of one, and you now have two primary components accepting writes that will never reconcile. Hand node one over to the ordinary unit, stopping the bootstrap unit first so the two never contend for the port:
sudo systemctl stop mysql@bootstrap
sudo systemctl start mysql
The other two members keep a majority while node one is down, so the cluster stays writable throughout and node one rejoins by incremental transfer rather than a full copy. Confirm the handover and the boot behaviour on all three:
systemctl is-active mysql mysql@bootstrap
systemctl is-enabled mysql mysql@bootstrap
What you want is the ordinary unit running and set to start at boot, with the bootstrap unit stopped and left disabled:
active
inactive
enabled
disabled
Bootstrapping is a deliberate act you perform after a full cluster shutdown, on the one node with the most recent data. It is not a state a node should ever wake up in on its own.
Prove that replication and strict mode both work
Write on one node and read on another. This is a two minute check that catches a cluster which formed but is not actually replicating:
mysql -u root -p -e "CREATE DATABASE shopdb;"
mysql -u root -p -e "CREATE TABLE shopdb.orders (id INT AUTO_INCREMENT PRIMARY KEY, item VARCHAR(50)) ENGINE=InnoDB;"
mysql -u root -p -e "INSERT INTO shopdb.orders (item) VALUES ('widget'),('sprocket');"
Then read it back from either of the other two, where the rows should already be present:
mysql -u root -p -e "SELECT * FROM shopdb.orders;"
While you are testing, it is worth understanding what pxc_strict_mode will and will not stop, because it does not behave the way most people expect. Try creating a table with no primary key:
mysql -u root -p -e "CREATE TABLE shopdb.nopk (id INT, note VARCHAR(50)) ENGINE=InnoDB;"
mysql -u root -p -e "INSERT INTO shopdb.nopk VALUES (1,'first');"
The create succeeds without complaint, and so does creating a MyISAM table. The rejection lands on the first write instead:
ERROR 1105 (HY000) at line 1: Percona-XtraDB-Cluster prohibits use of DML command on a table (shopdb.nopk) without an explicit primary key with pxc_strict_mode = ENFORCING or MASTER
In practice this means a schema migration can pass review and deploy cleanly, and the application then fails at runtime. Every table needs an explicit primary key and an InnoDB engine, and that is a design constraint rather than a tuning suggestion.
Where to take this next
What we have is a cluster that survives losing a node without losing data. What it does not yet have is anything deciding where queries go, and that gap matters more than it looks. Point an application at one node’s address and you have moved the single point of failure rather than removed it. Point it at all three and concurrent writes to the same rows start failing certification with deadlock errors at commit time, because Galera resolves those conflicts with first committer wins.
The usual answer is a routing layer that sends writes to one node and spreads reads across the rest. We measured what that layer is worth on a matching Ubuntu build: losing a database node cost zero failed writes, while losing the single proxy in front of it cost 1,282. The full setup, the query rules and the failover numbers are in the MySQL high availability build with ProxySQL, and everything in it applies to this cluster once the paths in this article are substituted.
Two things are worth doing before this carries real traffic. Size the Galera write set cache against your write rate multiplied by your worst case node downtime, because a node that is offline longer than the cache holds has to re-copy the entire dataset instead of replaying the gap. And decide your backup story separately from replication: a cluster replicates a bad DELETE to all three nodes in milliseconds, so three synced copies are not a backup. The Prometheus and Grafana monitoring setup covers the wsrep counters worth alerting on, of which cluster size and flow control pauses are the two that predict trouble early.
If you are running the same workload on Debian or Ubuntu, the Percona Server install for Ubuntu and Debian is the single node starting point, and teams standardised on MariaDB instead will find the equivalent architecture in the MariaDB Galera cluster with HAProxy and Keepalived build.
Latest percona has SSL/TLS enabled by default. So, when starting the cluster, you get errors like:
[Galera] Handshake failed: tlsv1 alert decrypt error
You need to copy *.pem files from node1 (or whatever node you bootstrap first) to all other nodes. PEM files are located in /var/lib/mysql folder
Thanks this is helpful information.