Running a single web server is a gamble. No matter how stable your hardware is, maintenance updates, accidental misconfigurations, or hardware failures can take your site offline instantly. In a professional environment, we solve this with High Availability (HA), running multiple copies of the server so that if one goes down, the others pick up the slack.
Typically, setting up HA for dynamic CMS sites like WordPress is complex because you have to synchronize two things: the database and the files (uploads, plugins, themes, core files).
In this guide, we are tackling the file synchronization and traffic routing aspect. We will transform a standalone Nginx web server into a resilient cluster using Proxmox for virtualization, OPNsense (HAProxy) for load balancing, and Syncthing for real-time, peer-to-peer file replication. By the end of this tutorial, you will have a setup where you can reboot one server while your website stays online, with file uploads syncing between nodes automatically.
We need to create an additional web server with the same configuration - the best way is to clone it and reconfigure it. Alternatively, you can set it up from scratch.
# Replace 'web2' with the hostname you prefer.
hostnamectl set-hostname web2
# Optional: Modify the /etc/hosts file and add a line for both of your web servers
nano /etc/hosts
127.0.1.1 web2.yourdomain.tld web2
Now we have two (or more) clones of the web server instance. We can configure HAProxy to route traffic to either instance, such as by using round robin. This is a similar approach to when we configured a Galera cluster in my previous guide.
cookie-based persistence, which means that existing connections will be served from the same web server unless that VM becomes unavailable. The rest can be left as default.Since we have the infrastructure part prepared, we can head over to installing and setting up Syncthing. Before we do that, let’s look into what Syncthing is and what platforms it supports.
# In case it is not already installed:
sudo apt install apt-transport-https
sudo mkdir -p /etc/apt/keyrings
sudo curl -L -o /etc/apt/keyrings/syncthing-archive-keyring.gpg https://syncthing.net/release-key.gpg
# Add the "stable-v2" channel to your APT sources:
echo "deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable-v2" | sudo tee /etc/apt/sources.list.d/syncthing.list
sudo apt-get update
sudo apt-get install syncthing
www-data. Yet this user should not have a login shell available and we would therefore need to at least set up its home directory. We will be going with this option.mkdir /opt/syncthing-config
chown -R username:username /opt
override.conf file (to ensure that subsequent updates to the Syncthing app will not erase our settings). This way, we can set up a permanent home folder.sudo systemctl edit [email protected]
### Editing /etc/systemd/system/[email protected]/override.conf
### Anything between here and the comment below will become the new contents of the file
[Service]
ExecStart=/usr/bin/syncthing serve --home="/opt/syncthing-config" --no-browser --no-restart
### Lines below this comment will be discarded
...
# Save and exit
# Reload the daemon & start it
sudo systemctl daemon-reload
sudo systemctl start [email protected]
# Set the daemon to launch on boot
sudo systemctl enable [email protected]
💡 Note
Note: Some guides will be asking you to modify the config.xml file by changing the address on which the instance runs from localhost (127.0.0.1) to 0.0.0.0. This is NOT desired for our set up when we share files between servers. By making the interface available on the LAN, we open up an attack surface that would otherwise not be there. Yes, this can be mitigated by a firewall service where you whitelist only certain endpoints to have access to it + fail2ban for repeated failed login attempts (if you set up a username/password for the web GUI) + by enabling the web GUI to run on HTTPS only. For long-term usage, do not utilize this shortcut and rather create a tunnel between the instances.
ssh -p <port> -L 9090:localhost:8384 user@ip
# In my case, this was
ssh -p 2222 -L 9090:localhost:8384 [email protected]
Assuming that you have created a tunnel for each instances on your non-headless computer, you can now reach the web UI of each instance.
On the web1web UI, go to Actions > Show ID. A long device ID string will be displayed. Copy it.
On the web2 web UI, click + Add Remote Device on the bottom right. Paste the Device ID from web1. Give it a recognizable name, like web1. Click Save
Back on the web1web UI, you will see a notification banner at the top asking to connect to web2. Click Add Device to approve the connection.
To share a folder - such as the ‘uploads’ folder for WordPress:
💡 Note
Note: If you ever need to change the user under which syncthing runs, follow this order:
One highly recommended setting to enable is a "bin" for your shared folders. Imagine a WordPress user accidentally deletes an image from the Media Library, or a plugin update goes wrong and wipes a configuration file. Because Syncthing syncs changes instantly, that file will disappear from all web servers simultaneously.
To prevent this, we can configure File Versioning.
wp-content/uploads).14 days).Where do the deleted files go? When a file is deleted on one node, Syncthing on the other nodes won't just permanently delete it. Instead, it moves the file into a hidden folder located at the root of your sync path:.stversions
How to recover a file: Since this folder is hidden, you won't see it in a standard file browser. You can access it via the command line to restore lost data. Below is an example:
# Navigate to your sync folder
cd /var/www/html/bachelor-tech.com/wp-content/uploads/.stversions
# List hidden files to confirm the folder exists
ls -la
# Restore it by moving it back one folder above
cp my-deleted-image.jpg ../
There are a few cases when your intervention may be needed over time as your run Syncthing. Let’s have a look at them to be familiar, you can even simulate them before valuable data is synced over.
web2 VM gets unexpectedly deleted. What happens? Let’s go through an example scenario - a WordPress instance’s uploads folder is synced between web1 and web2VM. Then the uploads folder on web2 gets deleted.web2 has the newest version and deletes everything away in the respective folder onweb1 (you would achieve that by just creating an empty file called .stfolder in the folder to sync, which makes it feel quite fragile). So here is the safe method:
web1), edit the job → Advanced → set it to ‘Send only’. This will prevent it from losing the data.web2), edit the job → Advanced → set it to ‘Receive only’. This will make it download the files and no data will be lost.# Create the missing folder (if missing):
mkdir /path/to/uploads/.stfolder
# mkdir -p /var/www/html/bachelor-tech.com/wp-content/uploads/.stfolder
web2 may receive data that will need to be synced to web1.That is all! Now if you had the web server VM excluded from traffic, such as in HAProxy’s load balancing back-end, then you can add it back. In fact, it may be best to set the newly modified VM as the only VM in the back-end to perform tests to ensure that nothing is missing.
journalctl -xeu [email protected]sudo runuser -u username -- syncthing paths - check that the path exists (you can also move it away for a test that it starts up in case it is invalid).
chown -R username:username . → then restart the syncthing service.Another situation that may occur is if you need to move away (or re-create) one Syncthing instance on another server. Alternatively, you may get into this situation if you simply moved the service to run under a different user.
cert.pem and key.pem).https-cert.pem and https-key.pem. These only control the SSL certificate for the GUI. You generally don't need to migrate these unless you have a specific reason to preserve the browser trust for the GUI page itself.You will need to locate the previous set of keys. Here are some ideas for where to look:
ls -la <one_of_these_below:>
~/.config/syncthing/
~/.local/state/syncthing/
/var/www/html/.local/state/syncthing/ # If this was www-data's default folder
config.xml if it is safe to start):# Stop the new Syncthing service
sudo systemctl stop [email protected]
# Let's say you want to move them to the /opt/syncthing-config folder
mv old-folder/* /opt/syncthing-config/.
# Fix permissions (for example to the www-data user)
sudo chown www-data:www-data /opt/syncthing-config/cert.pem
sudo chown www-data:www-data /opt/syncthing-config/key.pem
# or just to the whole folder
sudo chown www-data:www-data /opt/syncthing-config/*
# Start the Syncthing instance again
sudo systemctl start [email protected]
What we want to prevent is a situation when the sync breaks for any reason and users would be hitting your websites and some of them would not be able to reach or see that background image, cover photo or another resource. For this reason, we need to keep an eye on the sync jobs. In this tutorial, we will cover integrating monitoring with Uptime Kuma.
Using this approach, we will be able to monitor all sync jobs in one script per a web server VM. This means that if one sync job will be down, the service will be treated as all down, which may not always be the case - just keep it in mind. I can update this guide with how to monitor individual jobs, yet it does get tedious when you have more than two web servers.
ssh -p 22 -L 9090:localhost:8384[email protected] (and then you can reach the web GUI locally on http://127.0.0.1:9090 ).# Install curl and jq in case you don't have them
sudo apt install curl jq -y
# Create a folder under /opt where we will store our future syncthing scripts on this server.
mkdir /opt/syncthing-monitor
# Grant yourself the necessary rights for this folder (replace the username with yours):
sudo chown jan:jan /opt/syncthing-monitor
# Create a script:
nano /opt/syncthing-monitor/syncthing_monitor.sh
# Copy paste the following text and edit it with your API key and URL
#!/bin/bash
# A universal Syncthing monitor script that checks all folders on a host
# and sends a single consolidated status to an Uptime Kuma push monitor.
#
# Used for debugging:
# set -x
# --- Configuration ---
# Your Syncthing instance URL and port.
SYNCTHING_HOST="http://localhost:8384"
# Your Syncthing API Key (found in Actions > Settings > General).
# This key is specific to the server this script runs on.
SYNCTHING_API_KEY="YOUR_API_KEY"
#SYNCTHING_API_KEY="your-syncthing-api-key-here"
# The Uptime Kuma push URL for THIS SERVER's Syncthing instance.
UPTIME_KUMA_PUSH_URL="http://x.x.x.x:3001/api/push/CODE" # Do not include ?status=up&msg=OK&ping=
#UPTIME_KUMA_PUSH_URL="http://192.168.8.60:3001/api/push/your-push-token-here"
# Set to 1 to treat paused folders as 'down', set to 0 to ignore them.
TREAT_PAUSED_AS_DOWN="1"
# --- Full Paths to Commands ---
# Use 'which curl' and 'which jq' on your system to find these paths.
CURL_CMD="/usr/bin/curl"
JQ_CMD="/usr/bin/jq"
# ---------------------
# A short delay to prevent errors if the script starts before the Syncthing
# API is ready after a system reboot.
sleep 15
# --- Functions ---
# Sends a status update (up/down) and a message to the configured Uptime Kuma URL.
# Arguments:
# $1: status ("up" or "down")
# $2: message (string)
send_kuma_update() {
local status="$1"
local message="$2"
# The 'curl' command sends the GET request to the Uptime Kuma push URL.
$CURL_CMD -s --get --connect-timeout 10 \
--data-urlencode "status=$status" \
--data-urlencode "msg=$message" \
"$UPTIME_KUMA_PUSH_URL" > /dev/null
echo "Sent status '$status' to Uptime Kuma: $message"
}
# Checks the status of a single Syncthing folder.
# This function will ONLY produce output if there is an error.
# Arguments:
# $1: folder_id
# $2: folder_label
# $3: is_paused ("true" or "false")
check_single_folder_status() {
local folder_id="$1"
local folder_label="$2"
local is_paused="$3"
# First, check the paused state, but only if the configuration flag is enabled.
if [[ "$TREAT_PAUSED_AS_DOWN" -eq 1 && "$is_paused" == "true" ]]; then
echo "Warning for folder '$folder_label' (ID: $folder_id): Syncing is paused."
return
fi
# If not paused (or if we're ignoring paused folders), check the sync status for errors.
local folder_status
folder_status=$($CURL_CMD -s -H "X-API-Key: $SYNCTHING_API_KEY" -H "Referer: Syncthing" "$SYNCTHING_HOST/rest/db/status?folder=$folder_id")
# Check if we got a valid JSON response.
if [[ -n "$folder_status" && "${folder_status:0:1}" == "{" ]]; then
local folder_state
folder_state=$(echo "$folder_status" | $JQ_CMD -r '.state // "unknown"')
if [[ "$folder_state" == "error" ]]; then
echo "Error on folder '$folder_label' (ID: $folder_id): The folder is in an error state. Check permissions or path."
fi
else
# This handles cases where the API is up but gives a bad response for a folder.
echo "Warning on folder '$folder_label' (ID: $folder_id): Received empty or invalid API response."
fi
}
# --- Main Loop ---
echo "Starting universal Syncthing monitor for all folders on this host."
while true; do
echo "----------------------------------------"
echo "Performing check at $(date)"
# First, get the configuration for all folders from the Syncthing API.
# Use the 'Referer' header to satisfy Syncthing's CSRF protection.
all_folders_config=$($CURL_CMD -s -H "X-API-Key: $SYNCTHING_API_KEY" -H "Referer: Syncthing" "$SYNCTHING_HOST/rest/config/folders")
# Check if the API call was successful. If not, the API is likely down.
if [[ -z "$all_folders_config" || "${all_folders_config:0:1}" != "[" ]]; then
send_kuma_update "down" "Fatal: Syncthing API cannot be reached or returned invalid data. Is the service running?"
echo "Error detected. Retrying in 60 seconds."
sleep 60
continue # Skips the rest of this loop iteration and tries again.
fi
# This variable will store all error messages found during the checks.
aggregated_errors=""
# We use a robust for-loop with jq to handle potential spaces in folder labels.
folder_count=$(echo "$all_folders_config" | $JQ_CMD '. | length')
for i in $(seq 0 $((folder_count - 1))); do
# Extract all necessary info for the current folder
folder_info=$(echo "$all_folders_config" | $JQ_CMD ".[$i]")
folder_id=$(echo "$folder_info" | $JQ_CMD -r '.id')
folder_label=$(echo "$folder_info" | $JQ_CMD -r '.label')
is_paused=$(echo "$folder_info" | $JQ_CMD -r '.paused')
echo "Checking folder: '$folder_label' ($folder_id)"
# Run the check for the current folder and capture any error message it produces.
single_folder_error=$(check_single_folder_status "$folder_id" "$folder_label" "$is_paused")
# If an error message was returned, append it to our aggregate list.
if [ -n "$single_folder_error" ]; then
# Add a newline for nice formatting if we already have other errors.
if [ -n "$aggregated_errors" ]; then
aggregated_errors+=$'\n'
fi
aggregated_errors+="$single_folder_error"
fi
done
# After checking all folders, decide on the final status.
if [ -z "$aggregated_errors" ]; then
# If the error string is empty, everything is OK.
send_kuma_update "up" "All Syncthing folders are syncing OK."
echo "All checks passed. Next check in 1 hour."
sleep 3600
else
# If we have errors, send a 'down' status with the detailed messages.
send_kuma_update "down" "$aggregated_errors"
echo "Error(s) detected. Retrying in 60 seconds."
echo "--- ERRORS ---"
echo -e "$aggregated_errors" # -e interprets the newlines correctly
echo "--------------"
sleep 360
fi
done
# Make your script executable:
chmod +x /opt/syncthing-monitor/syncthing_monitor.sh
# Let's create a log file and give it the necessary permissions:
# Change your username as you need.
sudo touch /var/log/syncthing_monitor.sh.log
sudo chown jan:jan /var/log/syncthing_monitor.sh.log
# Create a service file name, ideally with a similar name to your script.
# Replace YOUR_USERNAME with yours and ensure the file names match.
sudo nano /etc/systemd/system/syncthing_kuma_monitor.sh.service
[Unit]
Description=Syncthing to Uptime Kuma Monitor
After=network.target
[Service]
User=YOUR_USERNAME
# Make sure this path is correct
ExecStart=sh -c '/opt/syncthing-monitor/syncthing_monitor.sh >> /var/log/syncthing_monitor.sh.log 2>&1'
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start syncthing_kuma_monitor.sh.service
sudo systemctl status syncthing_kuma_monitor.sh.service
# If the above goes well, then enable it after boot.
sudo systemctl enable syncthing_kuma_monitor.sh.service
/var/log/syncthing_monitor.sh.log . In case nothing is saved in there, make sure that:
ExecStart= line starts with sh -c ‘file1 >> file2 2<&1’ , as otherwise it sees it as three separate, plain-text arguments to pass to the script. It doesn't understand that >> is a special instruction for redirecting output. By encapsulating it this way, the ExecStart parameter passes the entire string inside the quotes (-c '...') as an argument to the sh command.sudo apt install dos2unix and then dos2unix /opt/syncthing-monitor/syncthing_kuma_your_script.sh .logrotate to tackle it. This configuration will rotate the log weekly, keep 4 old compressed logs, and ensure the new log file is created with the correct permissions for your script to write to it.sudo nano /etc/logrotate.d/syncthing_kuma_monitor
# Replace the username at the end with yours.
/var/log/syncthing_monitor.sh.log
{
weekly
missingok
rotate 4
compress
notifempty
create 0644 **jan jan**
}
sudo logrotate -d /etc/logrotate.d/syncthing_kuma_monitor
If the configuration is correct, your OPNSense back-end pool will be responding to incoming requests based on a round robin principle with a sticky cookie, so that your device stays connected to the single web server. How can you know which host you have reached?
X-Served-By header in nginx:nano /etc/nginx/nginx.conf
# Find the http section and add it somewhere in there:
http {
# Include web VM hostname (added for debugging)
add_header X-Served-By $hostname;
..
}
F12 on Windows/Linux or Option + ⌘ + J on a Mac)Some might argue that since Syncthing is not a filesystem and that the sync is asynchronous, the time required for other web nodes to receive the data may not be acceptable. This could be true for cases with high load or when you cannot utilize the sticky option. What are some other options?
Originally, I intended to write this guide using lsyncd. It is a lightweight tool that uses the Linux kernel's inotify subsystem to watch for file changes in real-time and immediately triggers rsync to mirror them.
rsync alone.Critics might argue that because Syncthing is asynchronous (meaning there is a slight delay before file XYZ appears on Server B), it isn't ‘true’ High Availability. For mission-critical high-traffic sites, using a distributed filesystem like GlusterFS or Ceph is the industry standard.
/mnt/gluster) that all web servers read from simultaneously.DRBD operates at the block level, sitting underneath the filesystem. You can think of it as ‘Network RAID 1’, mirroring raw data between two drives over the network.
This tutorial showed you how you can clone your existing single web server and configure it for HA with Syncthing. There are certainly other options, yet whichever option you go for needs to be installed, configured and monitored. With Syncthing, you have a handy GUI that you can reach for a quick overview of each job from the point of view of each node.
What we have not covered in this tutorial is how do you deploy code on each web server node (including updates). For example, if you have a WordPress site and apply plugin, theme and WP core updates, how will it propagate to the other web servers, since Syncthing covers only the wp-content folder? The answer here is not to add the entire virtual host to Syncthing, as that could lead to unexpected behavior and data loss. Rather, we can utilize Gitea for this purpose and pull content to each web server automatically once it is pushed from our testing environment. Let me know in the comments below if you are interested in such a guide 😇