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.
Let’s try accessing the web UI. Try accessing the Vaultwarden interface on https://vault.yourdomain.com .
In case the web UI is not coming up, we need to determine where the traffic got stuck. Here are the layers:
DNS - e.g. CloudFlare - is your DNS record set up? Does it point to the correct IP address?OPNSense - do you have a firewall + NAT rule set up for forwarding traffic to a port that HAPRoxy is listening on?HAProxy - is your public (front-end) service on and listening on the desired port? Have you attached your SSL certificate for decrypting traffic? Is your back-end pool forwarding traffic to the right host? Is your ‘real’ host configure on the right port?Nginx on the VM - is nginx on and listening on the right port? Is the syntax of your virtual host for forwarding ok?Docker - is the docker instance up? What do the logs say?Galera DB Cluster - is it reachable for the web servers? Is the virtual IP operational? Is the DB up, esp. if you are reaching it on a virtual IP via OPNSense / pfSense?The example below shows that the Docker instance is running but spent the last two hours in a crash loop due to incorrect DB details:
Let’s say you set up just one web server VM and were able to reach the web UI and then later, it stopped loading. You may wish you double check that the docker container is still up.
sudo docker container ps -a
cd /opt/vaultwarden
sudo docker compose up -d
sudo systemctl enable docker
If you log in successfully but are immediately logged out when refreshing or performing an action, your nodes likely have different signing keys.
/data on first startup. If web1 generated its own keys and web2 generated different ones, a login token signed by web1 will be rejected by web2.rsa_key.pem, rsa_key.pub.pem, and rsa_key.der files. You may need to manually copy the keys from the "primary" node to the others once to establish a baseline, then let Syncthing handle future updates.SIGNUPS_ALLOWED=false in the config, you will need to temporarily change that to true in docker-compose.yml, run docker compose up -d to apply, create your accounts, and then flip it back to false and restart again. This is intentional as a matter of exercising how you can easily tweak settings with Docker Compose, esp. when applying updates in the future.To ensure that the uploaded data stay consistent across multiple web servers, we will employ Syncthing, which I already use on my web servers. If you would like some help with deploying it, check out my previous tutorial on How to Configure HA for Web Servers.
www-data. We will need to ensure that this user has access to Docker for monitoring purposes:# Stop the container briefly to ensure the folder is free:
cd /opt/vaultwarden
sudo docker compose down
# Grant the 'www-data' user full Read/Write access to the docker volume
# using ACLs (Access Control Lists). This persists even if Docker recreates files.
sudo setfacl -R -m u:www-data:rwx /opt/vaultwarden/vw-data
sudo setfacl -d -m u:www-data:rwx /opt/vaultwarden/vw-data
# Start the container again (in a detached mode to free up the shell):
sudo docker compose up -d
/opt/vaultwarden/vw-dataicon_cache/
tmp/
temp/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.log
/opt/vaultwarden folder are not correct: # List all running processes, filter 'syncthing' and filter out the command itself:
sudo ps aux | grep syncthing | grep -v grep
sudo setfacl -R’ and ‘sudo setfacl -d’ commands above accordingly.There are three types of monitors that would be good to set up. If you only have one site or do not have your ‘witness’ site set up yet, then you can have all these checks running of just one UptimeKuma instance.
On Site 3 (VPS - witness site), you can monitor the service as a whole (e.g. does the website load regardless of which site it is being loaded from?).
HTTP(s)Vaultwarden or similarFor UptimeKuma to see the container, we will need to deploy a Socket Proxy container. The idea is to use the proxy as a ‘gatekeeper’ that provides read-only access to UptimeKuma on a specific port. We will need to apply this on each web server node.
# Create a directory for the proxy
sudo mkdir -p /opt/socket-proxy-container
# Go into that directory
cd /opt/socket-proxy-container
# Create the compose file
sudo nano docker-compose.yaml
services:
docker-socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: docker-socket-proxy
restart: unless-stopped
# High privilege is required to access the raw docker socket
privileged: true
ports:
- "2375:2375"
volumes:
# We give it access to the host's docker socket (read-only)
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
# ENABLE specific read-only permissions
- CONTAINERS=1 # Allows listing and checking container status
- INFO=1 # Allows checking general docker info (optional)
# BLOCK write permissions (security)
- POST=0 # Blocks any commands that change state (restart/kill/create)
💡 Security Note: The socket proxy exposes read-only Docker API access on port 2375. In a homelab environment on a trusted LAN, this is generally acceptable. For stricter security, bind to a specific IP (
192.168.8.9:2375:2375) or implement firewall rules to allow only your UptimeKuma host on port2375. The most secure method would be to bind it tolocalhostonly and use SSH tunneling, instead.
cd /opt/socket-proxy-container
sudo docker compose up -d
# Check that it is running together with the Vaultwarden container:
sudo docker ps -a
Docker Container Web1 - Docker Monitor - Vaultwarden vaultwarden (as per how it’s shown when you run docker container ps -a on your web server)Web1 Docker HostTCP / HTTPtcp://192.168.8.9:2375 (use your local IP with port 2375)web1 may look like this - remember to repeat this for each web server:The other thing that could stop working over time is Syncthing itself. We can leverage Syncthing’s API for that and push information regularly from each web server into UptimeKuma as a passive ‘push’ monitor.
On the VM that runs docker with our Vaultwarden container, we should install fail2ban and secure the docker container. In case you do not have it set up already, you can also secure SSH and your other sites.
# 1. Install Fail2ban
sudo apt update
sudo apt install fail2ban -y
# 2. Create a local configuration file (never edit jail.conf directly)
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
/etc/ssh/sshd_config .sudo nano /etc/fail2ban/jail.local
# Un-commment these:
ignoreself = true
# Add your own IP range (oneself + LAN + docker subnet)
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/16 172.20.0.0/16
# Comment out
# ignorecommand =
# Find an existing section called [sshd] and modify it as follows:
[sshd]
enabled = true
port = 2222
mode = aggressive
# The more modern method instead of reading text files
backend = systemd
maxretry = 3
bantime = 1h
sudo fail2ban-client start . Otherwise run sudo fail2ban-client reload for the changes above to kick in.# Ensure these three are somewhere in your 'environment':
environment:
- LOG_FILE=/data/vaultwarden.log
- LOG_LEVEL=info
- EXTENDED_LOGGING=true
docker compose up -d to apply changes.fail2ban on what a failed login to Vaultwarden looks like:sudo nano /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
# Matches "Admin login attempt failed" and "Invalid password for user"
failregex = .*Username or password is incorrect.*IP: <HOST>
sudo nano /etc/fail2ban/action.d/nginx-deny.conf
[Definition]
actionstart =
actionstop =
actioncheck =
actionban = printf "deny <ip>;\n" >> /etc/nginx/blocklist.conf; systemctl reload nginx
actionunban = sed -i "/deny <ip>;/d" /etc/nginx/blocklist.conf; systemctl reload nginx
sudo touch /etc/nginx/blocklist.conf
sudo chmod 664 /etc/nginx/blocklist.conf
sudo nano /etc/nginx/conf.d/vaultwarden.conf
# Block Banned IPs from fail2ban
include /etc/nginx/blocklist.conf;
sudo nano /etc/fail2ban/jail.local
[vaultwarden]
enabled = true
# Read logs directly from Docker
backend = systemd
# Which container to watch
journalmatch = CONTAINER_NAME=vaultwarden
# The attacker's port, not the internal port
port = 80,443,8081
filter = vaultwarden
# The 'chain=DOCKER-USER' blocks the IP before Docker forwards it
action = nginx-deny
maxretry = 3
bantime = 300m # 5min
findtime = 300m # 5 min
Reload the fail2ban service - sudo fail2ban-client reload .
Then let’s run a real test: Attempt some failed login attempts from your phone while on mobile network. Attempt some bad logins and refresh the page. You should see your custom 403 page. Then check: sudo fail2ban-client status vaultwarden . Then you can remove your banned IP by running sudo fail2ban-client set vaultwarden unbanip 1.2.3.4 on the affected web server.
Ensure that the log is seeing repeated failed login attempts: sudo docker logs --tail 20 vaultwarden
Compare the errors with the filter we created earlier - check sudo nano /etc/fail2ban/filter.d/vaultwarden.conf .
sudo fail2ban-client restart .Run a dry-run using sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/vaultwarden.conf
Is the count increasing but the connection with the device is not being blocked?
include /etc/nginx/blocklist.conf;, as otherwise, whatever is there to be blocked will be ignored.sudo nano /etc/fail2ban/action.d/nginx-deny.conf .If you are using Cloudflare or another proxy-like traffic management that provides you with CDN and traffic filtering and an attacker tries to log into your instance of Vaultwarden, nginx will end up blocking the IP address of the proxy server from Cloudflare rather than their actual one. Such behavior is undesirable, as it would then block legitimate traffic coming from the proxy to your nginx server.
The solution is to whitelist the local and Cloudflare servers as shown on their website. Yet the list changes (not often but from time to time) and it would be tedious to keep it up to date manually. So let’s automate it!
sudo mkdir /opt/cloudflare-proxies
sudo nano /opt/cloudflare-proxies/update-cloudflare-ips.sh
#!/bin/bash
# Download Cloudflare IPs
echo "# Cloudflare IPs - Auto Updated" > /etc/nginx/snippets/trusted-proxies.conf
# Internal IPs - modify this to reflect your local subnet
echo "set_real_ip_from 192.168.0.0/16;" >> /etc/nginx/snippets/trusted-proxies.conf
# IPv4
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
echo "set_real_ip_from $ip;" >> /etc/nginx/snippets/trusted-proxies.conf
done
# IPv6
for ip in $(curl -s https://www.cloudflare.com/ips-v6); do
echo "set_real_ip_from $ip;" >> /etc/nginx/snippets/trusted-proxies.conf
done
# Headers
echo "real_ip_header X-Forwarded-For;" >> /etc/nginx/snippets/trusted-proxies.conf
echo "real_ip_recursive on;" >> /etc/nginx/snippets/trusted-proxies.conf
# Reload Nginx as part of the script
sudo systemctl reload nginx
# Run it
sudo bash /opt/cloudflare-proxies/update-cloudflare-ips.sh
# You should see the IPs from the script in there
cat /etc/nginx/snippets/trusted-proxies.conf
# Make the script executable
sudo chmod +x /opt/cloudflare-proxies/update-cloudflare-ips.sh
# Add it to run weekly via a cron job
sudo crontab -e
# Add the following entry in there
0 4 * * 1 /bin/bash /opt/cloudflare-proxies/update-cloudflare-ips.sh
sudo nano /etc/nginx/conf.d/vaultwarden.conf
# Add the text below under the server { directive:
# Whitelist Cloudflare proxies & internal subnet:
include /etc/nginx/snippets/trusted-proxies.conf;
sudo nginx -t
sudo systemctl reload nginx
While you might have a custom page on your reverse proxy side for when your nodes are down, you may not have one for nginx. Let’s create it.
sudo nano /etc/nginx/snippets/custom-error-403.conf
error_page 403 /custom-403.html;
location = /custom-403.html {
allow all;
root /var/www/html/;
internal;
sub_filter 'SERVER_ID' $hostname; # Replace placeholder with hostname
sub_filter_once on;
}
/var/www/html folder, which is typical for Ubuntu and Debian installations. Some other flavors may use /usr/share/nginx/html . The choice is yours :)sudo nano /var/www/html/custom-403.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 Forbidden</title>
<style>
@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");
html,
body {
width: 100%;
height: 100%;
margin: 0;
}
* {
font-family: 'Press Start 2P', cursive;
box-sizing: border-box;
}
#app {
padding: 1rem;
background: black;
display: flex;
height: 100%;
justify-content: center;
align-items: center;
color: #54FE55;
text-shadow: 0px 0px 10px;
font-size: 6rem;
flex-direction: column;
}
#app .txt {
font-size: 1.8rem;
text-align: center; /* This centers the text content */
/* Removed justify-content and align-items as they don't apply here */
}
@keyframes blink {
0% {
opacity: 0;
}
49% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.blink {
animation-name: blink;
animation-duration: 1s;
animation-iteration-count: infinite;
}
</style>
</head>
<body>
<div id="app">
<div>403</div>
<div class="txt"> Blocked by fail2ban (SERVER_ID)<span class="blink">_</span> </div>
</div>
</body>
</html>
www-data:sudo chown www-data:www-data /var/www/html/custom-403.html
/etc/nginx/conf.d/vaultwarden.conf ) and add a snippet in there:sudo nano /etc/nginx/conf.d/vaultwarden.conf
# Find the server directive and add the following:
include /etc/nginx/snippets/custom-error-403.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.tld;
# Handled below by the whitelisting script already - do not duplicate!
# Replace OPNSense's IP with the actual one to block the right users with fail2ban:
# set_real_ip_from 192.168.0.0/16; real_ip_header X-Forwarded-For; real_ip_recursive on;
# Whitelist Cloudflare proxies & internal subnet:
include /etc/nginx/snippets/trusted-proxies.conf;
# Find the server directive and add the following:
include /etc/nginx/snippets/custom-error-403.conf;
# Block Banned IPs from fail2ban
include /etc/nginx/blocklist.conf;
# 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;
}
}
sudo nginx -t
sudo systemctl reload nginx
As mentioned before, if you have more nginx servers that you spread the load amongst, you would need to apply these steps on each of them.
Congratulations, the hardest part is done! Your infrastructure is in place including detailed monitoring on a granular level. Let’s migrate your existing data from Bitwarden. How can you move them over?
Each user (you, your spouse, kids, etc.) imports their own private items.
SIGNUPS_ALLOWED=true in the docker-compose.yml file at least at the beginning for your own account, the others you can invite).- Give it some time without refreshing the page for the credentials to import.
- You will then be greeted with a confirmation that the import has finished.
💡 Note
Moving an Organization (or a Family org) is done in the same way as the personal vault, you just need to export them and import them separately. Collections are re-created automatically during the import, you do not need to create them before the import.
.zip export that includes attachments is available in Bitwarden, importing this into a self-hosted Organization often fails or isn't fully supported by the importer yet. Manual is the safest bet for integrity.💡 Note
Once your initial accounts are set up and no more sign ups are required, remember to open each
docker-compose.ymlfile on each web server and ensure thatSIGNUPS_ALLOWEDis set back tofalse. This is especially sensitive if your Vaultwarden instance is public-facing, as otherwise, anyone could create accounts and use your service, including bots.
Before inviting users, it is vital to set some ‘ground’ rules for authentication and other security policies.
Since this is a new server, your family members technically need "new" accounts.
docker-compose.yml file previously.Feel free to play with additional settings in Vaultwarden web UI. Are there additional considerations to take into account? How about backup and restoration? Or how can you go about updating your Vaultwarden instance? We will review these in our last step.
In this article, I did not cover how to best harden your web and DB servers. It is assumed that it is already done.
While this set up is fully HA in terms of the web and DB servers being available on two sites (with a Galera witness on a VPS outside), HA does not equal backups. In the event of a breach (like ransomware) or a cascading service failure, you will need a form of archived backups, ideally going back a few months, so that you can determine the nearest and still safest version of your data.
| Component | Location | Priority | Notes |
|---|---|---|---|
| Vaultwarden data directory | /opt/vaultwarden/vw-data/ |
Critical | Contains RSA keys, attachments, icons, config |
| MariaDB/Galera database | Vaultwarden database | Critical | Contains all vault entries, users, organizations |
| Docker Compose file | /opt/vaultwarden/docker-compose.yml |
High | Contains your environment configuration |
| Nginx virtual host | /etc/nginx/conf.d/vaultwarden.conf |
Medium | Can be recreated but saves time |
| SSL certificates | Managed by ACME/OPNSense | Low | Can be regenerated |
Check out my previous article on how to back up VMs and LXCs from Proxmox onto a GCP’s archive storage!
Below are three common scenarios that may occur:
Scenario A: Single Web Node Failure. If one web server fails but others are operational:
/opt/vaultwarden/vw-data/ from healthy nodesmd5sum /opt/vaultwarden/vw-data/rsa_key.* (compare across nodes)cd /opt/vaultwarden && sudo docker compose up -dScenario B: Database Corruption or Loss
cd /opt/vaultwarden && sudo docker compose downgunzip < /var/backups/vaultwarden/vaultwarden_YYYY-MM-DD_HHMMSS.sql.gz | mysql -u root -p vaultwardenmysql -u root -p -e "SHOW STATUS LIKE 'wsrep_cluster_size';"Scenario C: Complete Site Loss
/opt/vaultwarden/vw-data/ from backup (this includes the RSA keys).Backups are worthless if you cannot restore from them. Schedule quarterly restore tests:
# On a test VM (not production!), verify database backup integrity:*
gunzip -t /var/backups/vaultwarden/vaultwarden_latest.sql.gz && echo "Archive OK"
# Test actual restoration to a temporary database:*
mysql -u root -p -e "CREATE DATABASE vaultwarden_test;"
gunzip < /var/backups/vaultwarden/vaultwarden_latest.sql.gz | mysql -u root -p vaultwarden_test
mysql -u root -p -e "SELECT COUNT(*) FROM vaultwarden_test.users;"
mysql -u root -p -e "DROP DATABASE vaultwarden_test;"
This part is simple, yet for completion, let’s cover it.
# Pull the latest image:
sudo docker pull vaultwarden/server
# Restart the docker container
cd /opt/vaultwarden
sudo docker compose down && sudo docker compose up --force-recreate
# Once it loads and you can see the new version has loaded, press 'd' to detach it
Yet if you are looking for some tips, look at my previous article on how to deploy a DB Galera cluster that touch on how you can use tools like ufw firewall, fail2ban for securing your SSH access.
So what else could we consider? You could consider NOT having a resolvable public DNS name for your Vaultwarden instance and instead, have it available only internally on your LAN. Then, if you have a VPN client deployed (such as a WireGuard plugin in your OPNSense instance), you could make it available only via that VPN connection. Security by obscurity is still a thing!
On your phone and other mobile devices, you can then install the VPN client to reach your Vaultwarden server. Alternatively, if you do not often update your passwords, simply let it sync whenever you are on your LAN - when outside, your mobile device will work off its local cache, preserving access to all the passwords up to the time of your last sync.
While this guide is already rather comprehensive, there are quite a few other things that could go wrong (mostly related to the infrastructure) that could affect your Vaultwarden’s uptime. Examples include:
Let me know in the comments below in case you have come across any or if you would like me to expand on how to recover from these before you encounter them yourself 😇
This concludes our guide. I hope you enjoyed it.