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
With syncing, monitoring and hardening in place, the last part covers migrating existing Bitwarden data in and locking down backups and restoration.