Running a mission-critical website on a single server creates a significant risk, as routine maintenance or unexpected hardware failures can take your service offline instantly. This tutorial guides you through transforming a standalone web server into a resilient High Availability (HA) cluster using accessible open-source tools.
We will utilize Proxmox to clone your existing environment and deploy OPNSense with HAProxy to intelligently distribute traffic between multiple nodes. To handle dynamic content, we solve the challenge of file synchronization by implementing Syncthing, a decentralized peer-to-peer tool that keeps folders like WordPress uploads identical across servers in real-time.
We will configure Syncthing on headless Linux instances, tunnel securely to its GUI for management, and set up automated health monitoring using UptimeKuma. By the end, you will have a robust architecture that can survive individual server reboots without downtime, offering a simpler alternative to complex enterprise-grade distributed file systems.
Site 1 (Main) - all connected to a UPS managed from an RPI
OPNSense1 (192.168.8.1) - Proxmox host 1OPNSense2 (192.168.8.2) - Proxmox host 2web1 (192.168.8.9) - Proxmox host 1
web2 (192.168.8.10) - Proxmox host 2
galera1-2 (192.168.8.71 + 72) - Proxmox host 1galera3-4 (192.168.8.73 + 74) - Proxmox host 2galera-template - a pre-prepared LXC template ready for easy deployment in case a replacement is needed (can be automated with AWX for deployment)Gitea LXC (192.168.8.21) - to sync app data and deployment scripts + config.xml for each OPNSense host.Uptimekuma1 (192.168.8.60) - to monitor all Site 1 hosts’ uptime inc. services like SyncthingRPI (192.168.8.16)
APCupsd with scripts for smooth graceful shutdown of Proxmox hosts - see this tutorial for more information.Site 2 (Online Backup)
OPNSense3 (192.168.6.1) - Proxmox host 3
Web3 (192.168.6.10) - Proxmox host 3
Galera4+5 (192.168.6.75 + 76) - Proxmox host 3Uptimekuma2 (192.168.6.60) - monitors Site 2 services inc. web2’s Syncthing jobs.Site 3 (Witness)
Garb - daemon that does not hold data and provides HA if one site is down (quorum voting as +1)Uptimekuma3 - monitors uptime for websites that are provided by either site (such as bachelor-tech.com)For the purpose of reproducibility, as a side note, you can find the versions that I worked with:
There are two specific limitations to be aware of during an Active-Active setup for Vaultwarden:
web1 but their token was signed by web2 or web3 using a different key. You must ensure the RSA key files in the data directory are identical across all nodes.
web1, a device connected to web2 or web3 won't get the live update immediately. It will sync the next time the user manually refreshes or performs an action. This is a minor inconvenience but acceptable for most.ENFORCING mode for wsrep_drupal_282555_workaround or strict checking that might block INSERT statements if they momentarily lack a PK. Most likely, this will not be an issue.Now since the topology and the specifics of Vaultwarden have been covered, let us move forward with the installation and setup. We will be setting it up on Debian 13 (Trixie) - other versions will likely work in a very similar fashion.
your_secure_password’ string with your own! It is recommended to avoid using special characters for compatibility - the length is what matters the most here! If you do use special chars, you will need to escape them properly later on in the docker-compose.yml file for Vaultwarden.%’ instead of specifying the IP range.mysql -u root -p
CREATE DATABASE vaultwarden CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'vaultwarden'@'192.168.%' IDENTIFIED BY 'your_secure_password';
GRANT ALL PRIVILEGES ON vaultwarden.* TO 'vaultwarden'@'192.168.%';
FLUSH PRIVILEGES;
# Update and install basic tools
sudo apt update && sudo apt install -y ca-certificates curl
# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Refresh apt & install Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable Docker Systemd service (so containers start on boot)
sudo systemctl enable --now docker
# Create the directory
sudo mkdir -p /opt/vaultwarden
# Set permissions so your current user can edit files there
sudo chown $USER:$USER /opt/vaultwarden
cd /opt/vaultwarden
# Create the Compose file
nano /opt/vaultwarden/docker-compose.yml
compose.yml file:
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: always
ports:
- "127.0.0.1:8000:80"
environment:
- DATABASE_URL=mysql://vaultwarden:[email protected]/vaultwarden
- DOMAIN=https://vault.yourdomain.com
- SIGNUPS_ALLOWED=false
- INVITATIONS_ALLOWED=true
- IP_HEADER=X-Real-IP
- LOG_LEVEL=info
- EXTENDED_LOGGING=true
# --- SMTP Email Settings ---
- SMTP_HOST=smtp.gmail.com # Replace with your provider's host
- [email protected]
- SMTP_FROM_NAME=Vaultwarden
- SMTP_SECURITY=starttls # Options: starttls, force_tls, off
- SMTP_PORT=587 # Usually 587 for starttls, 465 for force_tls
- [email protected]
- SMTP_PASSWORD=your_app_password
- SMTP_AUTH_MECHANISM="Plain" # This is safe if used with TLS
logging:
driver: "journald"
options:
tag: "{{.Name}}"
volumes:
# This creates a 'vw-data' sub-folder as a form of persistent storage
- ./vw-data:/data
cd /opt/vaultwarden
# Sudo is needed to pull the image
sudo docker compose up -d
sudo docker container ps -a .💡 Note
Once your
docker-compose.ymlfile is launched and you make changes to it, it is best to then re-create the container by runningsudo docker compose up -d --force-recreate
X-Forwarded-Proto header so Vaultwarden knows it's secure.sudo nano /etc/nginx/conf.d/vaultwarden.conf
server {
# We listen on 80 because HAProxy handles the SSL/HTTPS before it gets here.
listen 8081;
# Replace with your actual FQDN
server_name vault.yourdomain.com;
# Replace with OPNSense's actual IP to ensure fail2ban blocks the correct users:
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Allow large attachments (optional, but good for saving PDFs/Images in vault)
client_max_body_size 128M;
location / {
proxy_pass http://127.0.0.1:8000;
# WebSocket support (used by /notifications/hub)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Pass headers so Vaultwarden knows the real IP of the user, not just "127.0.0.1"
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
💡 Note: In Vaultwarden versions prior to 1.29.0, WebSockets required a separate port (3012). Modern versions serve WebSockets from the main application port, simplifying the configuration.
# Confirm there is no typo in the syntax before reloading it
sudo nginx -t
# Reload nginx for the changes to take an effect
sudo systemctl reload nginx
vault.yourdomain.tld record pointing to the public IP of your OPNSense - you can follow these steps to set up dynamic DNS with Cloudflare.
In case you do not have it set up yet, apart from defining your web hosts (under the ‘Real Servers’ tab) with the local port (I’m using 8081), we should check the health of each web host.
web1_nginxstatic8081We should also ensure that there is a health check in place for the web servers.
web_server_cluster_check (or whatever you prefer)HTTP (default) use server settings (default)15-90s (you may get some false positives if too aggressive)8081HEAD/HTTP/1.1yourdomain.tldYou may already have a back-end pool configured for your existing web servers, yet we will need to create another pool with the same real hosts in it, since different parameters will need to be set up for Vaultwarden.
💡 Note
The challenge with the default session rate period is that it is typically about 10 seconds long as well as no pass-through option is defined, which would result in the client having to re-establish connection with the server (users would see having a ‘Connecting…’ spinning wheel appear regularly). While for example PHP-based applications receive a payload and close the connection after responding, for web sockets, we want the session to remain active.
backend_vaultwarden_nginxHTTP (Layer 7)Round Robin 2 (entirely up to you)Tickedweb_server_cluster_check (previously defined in the health check section)none TickedCookie-based persistence Insert new cookieSRVCOOKIE (or another name as you desire)Ticked (Default)1h (match your tunnel timeout)5s (fail-over fast if the server is not responding)5s (fail fast if health check fails)3600s (allows the server to keep the pipe open)3 (default)timeout tunnel 3600s (tells HAProxy that it is a tunnel)condition-vault-your-domain-tldHost matchesvault.yourdomain.tldIFcondition-your-domain-tld (find your own)backend_vaultwarden_nginx (as defined previously)Go to Virtual Services → Public Services. If you already have one defined for HTTPS, then simply add the rule at the end, test & apply the syntax and you are done.
SSL offloading’ box to ensure that traffic is decrypted on your LAN. You would need to use the ACME certificate created earlier.💡 Note
Ensure that you follow these steps on all of your sites to have the same config and can thus expect consistent behavior.
With Vaultwarden reachable through HAProxy, the next part covers keeping its data in sync across sites and monitoring and hardening the deployment.