This is an old solution, but I believe it remains useful today for load balancing in MySQL. I know there are many options in the market. However, HAProxy still has its position because of its performance. In this blog, I just want to share how we set up HAProxy for load balancing MySQL. This is also known as High Availability when we need to have multiple MySQL slave servers for read operations. In this case, we have an app server that is writing to a database (master role) and reading data from two other databases (slave role).

Prerequisite
- An Ubuntu 24.04 server plays the role of an App server where we install HAProxy version 2.8 (default).
- 3 x Ubuntu 24.04 installed MySQL, and I assume that you have configured one DB as a master and the other two as slaves.
| Hostname | IP address | Role |
| web-01.srv.local | 192.168.68.99 | App server with HAProxy installed |
| db-01.srv.local | 192.168.68.113 | Master |
| db-02.srv.local | 192.168.68.119 | Slave |
| db-03.srv.local | 192.168.68.120 | Slave |
Note: I’m running a local DNS that allows me to use domain instead of IP address. If you want to have local DNS server so that you won’t have to enter your IP address, please check out How to Set Up a Local DNS Server with Dnsmasq in Ubuntu 24.04 (Fast & Easy) to facilitate your testing.
Create MySQL user for haproxy check
Before we configure HAProxy, we need to create a haproxy user on the master DB server. This is for web-01 server to access the DB server and check MySQL availability. On db-01, run:
CREATE USER IF NOT EXISTS 'haproxy_user'@'%' IDENTIFIED BY 'HaproxyPassword';
CREATE USER IF NOT EXISTS 'haproxy_user'@'localhost' IDENTIFIED BY 'HaproxyPassword';
GRANT ALL PRIVILEGES ON *.* TO 'haproxy_user'@'%';
GRANT ALL PRIVILEGES ON *.* TO 'haproxy_user'@'localhost';
FLUSH PRIVILEGES;Install HAProxy
On the web-01 server, we install HAProxy by running:
sudo apt install haproxy mysql-client -yConfigure HAProxy for MySQL (master/slave)
Create a copy of the original haproxy.cfg file:
sudo mv /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.original(Optional) Now we need to create a new haproxy.cfg file with custom content. Run sudo nano /etc/haproxy/haproxy.cfg and add the following content:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
maxconn 2000
user haproxy
group haproxy
daemon
defaults
log global
option tcplog
option tcpka
retries 3
timeout connect 10000
timeout client 50000
timeout server 50000
# This frontend "monitor-stats" is optional, you can exclude it if you don't need web UI
frontend monitor-stats
bind 0.0.0.0:8080
mode http
stats enable
stats uri /stats
stats realm Strictly\ Private
stats auth binh:haproxyIf you already have a haproxy.cfg file with your own global and defaults settings, you can ignore this change. I added simple default configurations to eliminate unnecessary settings.
If you want to understand the meaning of each option under global and defaults settings, can check haproxy configuration docs
Next, we move to the main configuration part for MySQL. We will create one cluster group (frontend/backend) for read operations, and one cluster group for write operations. For each cluster group, we basically need two sections: frontend and backend.
Create a configuration for the read operations, I named it as frontenddb-read in both sections. So, still in the haproxy.cfg file, we add:
frontend db-read
bind 127.0.0.1:3307
mode tcp
acl MAIN_down nbsrv(db-read) le 0
default_backend db-read
use_backend db-write if MAIN_down
Where:
bind: make thisdb-readproxy listening on the specified IP address and port. Our web-01 will connect to this <IP-address>:<port>mode: sets this proxy to usetcpmode. As HAProxy by default doesn’t have a specific mode for MySQL, we usetcphere.acl: creates Access Control List named “MAIN_down”. This ACL checks a boolean condition where it runsnbsrvmethod against thebackendsection nameddb-read(we define later below), and returns an integer value corresponding to the number of usable servers of the backend. If the usable servers are less than or equal to 0 (means non of the servers in the backend section is working), it returns true, which means “MAIN_down” is down. This condition is used by theuse_backendoption below.default_backend: sets to use thedb-readbackend by defaultuse_backend: tests the result of the acl option, if “MAIN_down”, switch connections to use backend nameddb-writeinstead. It becomes helpful when you lose both slave servers, but are still able to read by using the master DB server.
Similar to the frontend db-read, we create a frontend section named db-write for the write operations:
frontend db-write
bind 127.0.0.1:3308
mode tcp
acl MAIN_down nbsrv(db-write) le 0
default_backend db-writeNoted that we don’t use the use_backend option for the write operation, as we have only one master DB. I know it creates a single point of failure, but setting up a second master comes with a trade-off. I’ll explain later.
Now, we need to create a backend section for the read operations. We add:
backend db-read
balance roundrobin
option allbackups
server db-02.srv.local db-02.srv.local:3306 check
server db-03.srv.local db-03.srv.local:3306 checkWhere:
balance: we chooseroundrobinto split connections to each server in turns. You can check here to see other optionsoption:allbackupstreats all servers with backup option enabled, and these servers are configured in the server option below. For example:server db-02.srv.local db-02.srv.local:3306 check backup. In case you have a few spare servers, it seems a good practice to add them to this backend section as a backup. I don’t have it, but still good to set thisallbackupsby default.server: specifies which server should belong to thisdb-readbackend. The check option is enabled to do a health check. It marks the server as available when a connection can be established at the highest configured transport layer (by using TCP).
Similarly, we have backend db-write for the write operation. We add:
backend db-write
balance roundrobin
option allbackups
server db-01.srv.local db-01.srv.local:3306 checkRemember to save the content and close from the nano command. Press Ctrl + O and press Enter to save the content. Then press Ctrl + X to exit.
Finally, we need to restart the haproxy service to make the new config take effect
sudo systemctl restart haproxyCan go to the HAProxy stats dashboard to view, access your server IP that has HAProxy through port 8080 if you enabled it. My one is http://192.168.68.99:8080/stats

NOTED: If you’re interested in automating HAProxy configuration without manually changing backend servers when you scale out or remove any server. I wrote a use case of utilising Consul service discovery to achieve this. Please check out Consul Service Discovery: Automate HAProxy Configuration with Consul for MySQL for more information.
Testing Load Balancing
In reality, your application will make requests to 127.0.0.1:3307 (We defined in the haproxy config). This application should remain on the same server where you installed HAProxy to access 127.0.0.1. Otherwise, it won’t be able to connect unless you bind the db-read frontend to 0.0.0.0:3307
Back to our demo, we test making requests to the db-read frontend in this case. On the web-01 server, as we don’t have any application, we make a simple query, we run:
for i in `seq 1 6`; do mysql -h 127.0.0.1 -u haproxy_user -pHaproxyPassword -P 3307 -e "show variables like 'server_id'"; doneOutput:

We see requests are splitting evenly between the 2 slave DB servers: server_id 2 is db-02, and server_id 3 is db-03
That’s all we need for a simple load balancing. However, I personally think that this is not enough to establish high availability for MySQL load balancing. Look at this part:
backend db-read
...
server db-02.srv.local db-02.srv.local:3306 check
...The check option in the server option enables a health check on a server by ensuring a TCP connection is established. This method may not be useful because MySQL still responds successfully to HAProxy even when it isn’t really operating properly.
For example, while a MySQL slave is still up and running, we can test by performing stop slave;, HAProxy still considers this slave DB server as healthy and forwards requests to it. If the slave DB server is stopped for a long time (e.g. 1 day), there’s a huge lag of data between the master and slave, causing the application query to this slave DB that hasn’t had data updated (a potential bug happens there).
The best practice is to combine HAProxy with a custom script (play the role of an agent). This script runs on each DB server and performs interval checks continually to make sure the replication status or MySQL itself are working normally. And then the script returns an appropriate status to HAProxy so that HAProxy knows the server is healthy. HAProxy relies on the returned status to decide whether it forwards requests to a DB server or not.
(Advanced) Configure HAProxy with an agent script for MySQL checks
The script can be written in many languages, so it’s up to your choice. This is a high-level method and requires programming knowledge. I won’t go into details of how to write a script. Instead, I’ve created a haproxy-mysql-check script written in Golang so that you can refer to it. This script is built to produce the statuses required by HAProxy’s agent-check option.
This part is to show you an idea of how to use HAProxy with the agent script. If you don’t want to use this method, feel free to skip it.
Set up a custom agent script
Install that haproxy-mysql-check script on each DB server. Please run the following:
# On user's home directory, create a app/ folder to store this script
sudo mkdir app
sudo cd app
# Download haproxy-mysql-check package
wget https://gitlab.com/api/v4/projects/71540041/packages/generic/haproxy-mysql-check/1.0.0/haproxy-mysql-check-linux-amd64-1.0.0.tar.gz
# Extract it to get binary file
tar -xvf haproxy-mysql-check-linux-amd64-1.0.0.tar.gzA binary file haproxy-mysql-check appears then. Now we need to add a .env file with content looks like:
# Change values of these parameters to your own values
DB_HOST="localhost"
DB_USER="haproxy_user"
DB_PASS="HaproxyPassword"
DB_PROTOCAL="tcp"
DB_PORT="3306"
# Change IS_SLAVE to "false" if the DB is master
IS_SLAVE="true" The .env file contains basic settings that enable the script to connect to the MySQL service to perform checks.
Now, start the script in the background:
./haproxy-mysql-check >> haproxy-agent.log 2>&1 &This is just a quick way to start the script. It’s better that you create a systemd service called “haproxy-mysql-check.service” so that you can use it to start the haproxy-mysql-check script. That’s when you can enable the service to start at launch. Every time your DB server is rebooted, the script is up and starts checking the MySQL service, so it’s able to tell HAProxy immediately once MySQL is in operational mode.
You could use your own script if you have one, but please make sure that it returns the statuses required by the agent-check option
Update your HAProxy config file to use agent-check
Open the haproxy.cfg file and append default-server option to the backend db-read part, run sudo nano /etc/haproxy/haproxy.cfg:
# Other configurations...
backend db-read
balance roundrobin
option allbackups
default-server agent-check agent-port 9200 fall 1 rise 1 inter 10000 weight 100
server db-02.srv.local db-02.srv.local:3306 check
server db-03.srv.local db-03.srv.local:3306 check
backend db-write
balance roundrobin
option allbackups
default-server agent-check agent-port 9200 fall 1 rise 1 inter 10000 weight 100
server db-01.srv.local db-01.srv.local:3306 checkPress Ctrl + O and press Enter to save the content. Then press Ctrl + X to exit.
We append default-server option where:
default-server: supports options used in theserveroption. If we define here, these options are all passed as arguments on theserverline.agent-check: enable an auxiliary agent check, which tells HAProxy that the server has an agent running independently to perform health checks.agent-port: a TCP port used for agent checksfall: mark a server as dead after <count> consecutive unsuccessful health checks. We set it to 1 which means after 1 unsuccessful health check, the server is considered as dead.rise: as a server as operational after <count> consecutive successful health checks. The server is marked as operational after 1 successful health check.inter: interval between two consecutive health checks, it delays <milliseconds> after the first check. Here is 10s.weight: set a load proportional to each server based on specified weight. The default weight is 1, and the maximal value is 256. The higher the weight is set, the higher the load the server receives. Ourhaproxy-mysql-checkscript also calculates the weight based on theSeconds_Behind_Masterparameter on each DB server. If the slave DB server has a high lag, it will reduce the weight and send the weight’s value back to HAProxy along with its status. For example, “up 50%” means the slave DB server is still up and running, but should reduce the weight down to 50. Once the slave DB server catches up to the master DB,Seconds_Behind_Masterbecomes0, our script returns status “up 100%” to bring the weight back to 100.
The agent-check takes priority over the check option. When both options are used, agent-check performs regular checks. However, if our script isn’t running, agent-check cannot access it and will resort to the check option, which only verifies the TCP connection.
Remember to reload HAProxy service to make changes take effect:
sudo systemctl reload haproxyTesting Load Balancing
We go to the random slave DB server, in here, I test with db-03. Log in to MySQL and try stopping its replication:
binh@db-03:~/app$ sudo mysql
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 3941
Server version: 10.11.13-MariaDB-0ubuntu0.24.04.1-log Ubuntu 24.04
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]> stop slave;
Query OK, 0 rows affected, 1 warning (0.007 sec)We go back web-01 server, and check the haproxy log:
tail -100f /var/log/haproxy.logYou’ll see a line that looks similar below (with your own server hostname):

Our script returns status “stopped” to HAProxy, so HAProxy marked the server as DOWN.
Go back to db-03 and start the slave back, run:
MariaDB [(none)]> start slave;
Query OK, 0 rows affected (0.009 sec)Continue checking the haproxy log on web-01, you will see something like:

Our script returns status “up” to HAProxy, so HAProxy marked the server as UP.
That’s all for this part.
(Bonus) Configure HAProxy for MySQL (master/master)
In the example above, we apply Load Balancing to read operations only. For the write operations, it becomes more complicated as it really depends on how your application works, and that sounds biased to me. That’s because I encounter several situations where I have two master DB servers with their own slave servers following, and the application writes to both Master DB servers at the same time, sometimes creating dangerous bugs that we would want to avoid.

I just want to mention that if you really want to implement Load Balancing for the write operations to the master servers, and you have enough resources to have the second master just stay there as a backup. We have a way to do that safely.
Look at the diagram; that’s when HAProxy sends traffic for the write operation to only one master DB server (db-01) and treats the second one as a backup. The second one serves traffic only when the db-01 is down. If you don’t want to let the db-04 (2nd master DB) just stay there and do nothing, we could add it to the backend db-read so that we still utilise the 2nd master DB, but it’s better to set a low weight for it.
Just update your HAProxy config to:
# Other global and default configurations...
frontend db-read
bind 127.0.0.1:3307
mode tcp
acl MAIN_down nbsrv(db-read) le 0
default_backend db-read
use_backend db-write if MAIN_down
frontend db-write
bind 127.0.0.1:3308
mode tcp
acl MAIN_down nbsrv(db-write) le 0
default_backend db-write
backend db-read
balance roundrobin
option allbackups
default-server agent-check agent-port 9200 fall 1 inter 10000 rise 1 weight 100
server db-02.srv.local db-02.srv.local:3306 check
server db-03.srv.local db-03.srv.local:3306 check
# We append db-04 into LB group for read operations with weight 30
server db-04.srv.local db-04.srv.local:3306 check weight 30
# We treat db-04 as the backup server for the write operation
backend db-write
balance roundrobin
option allbackups
default-server agent-check agent-port 9200 fall 1 rise 1 inter 10000 weight 100
server db-01.srv.local db-01.srv.local:3306 check
server db-04.srv.local db-04.srv.local:3306 check backupThat’s all for this post.
Discover more from Turn DevOps Easier
Subscribe to get the latest posts sent to your email.
