VAULTWARDEN INSTALLATION #
A complete guide to self-hosting Vaultwarden โ a lightweight, open-source password manager compatible with Bitwarden clients โ using Docker rootless for better security isolation. This setup includes a dedicated system user, Nginx reverse proxy with SSL, Argon2 hashed admin token, firewall hardening with UFW and portainer agent for checking container status.
Vaultwarden
Why self-host a password manager? #
Cloud-based solutions like 1Password or Bitwarden’s hosted service store your credentials on third-party servers โ servers you don’t control. By self-hosting Vaultwarden, your encrypted vault stays entirely on your own hardware, accessible only through your local network or VPN. You get full control over your data, no subscription fees, and the peace of mind that your passwords never leave your infrastructure.
Prerequisites: #
- systemd
- nginx
- docker-rootless
- portainer
- openssl
- ufw
- argon2
- wireguard (Optional)
Create a dedicated user and enable linger #
By default, systemd kills all user processes on logout. Enabling linger keeps the user’s services running in the background even without an active session โ essential for Docker rootless containers to stay up.
After you enable linger you have to watch content of /run/user/USER_ID, this checks that the user’s runtime directory exists, which confirms that linger is active and systemd has started the user session. This directory contains essential runtime resources like the D-Bus socket and the Docker rootless socket. If it doesn’t exist, Docker rootless won’t be able to start.
# add user
sudo useradd -r -m -s /usr/sbin/nologin -d /home/vaultwarden vaultwarden
# enable linger
sudo loginctl enable-linger vaultwarden
# check
ls /run/user/$(id -u vaultwarden)Create directories #
Directory data will contain everything.
Directory portainer-agent will contain agent.
sudo -u vaultwarden mkdir -p /home/vaultwarden/vaultwarden/data
sudo -u vaultwarden mkdir -p /home/vaultwarden/vaultwarden/portainer-agent
# Final stucture
sudo tree -a /home/vaultwarden/vaultwarden/
/home/vaultwarden/vaultwarden
โโโ data
โย ย โโโ db.sqlite3
โย ย โโโ db.sqlite3-shm
โย ย โโโ db.sqlite3-wal
โย ย โโโ rsa_key.pem
โย ย โโโ tmp
โโโ docker-compose.yml
โโโ .env
โโโ portainer-agent
โโโ docker-compose.yml
4 directories, 7 filesCreate token and password for admin panel #
Create /home/vaultwarden/vaultwarden/.env and ADMIN_TOKEN.
Using Argon2, you can hash the admin password so it’s never stored in plain text.
Argon2 flags explained
argon2: the hashing tool"$(openssl rand -base64 32)": generates a random 32-byte salt encoded in base64-e: output the hash in encoded format (PHC string)-id: use the Argon2id variant (combines Argon2i and Argon2d for better security)-k 65540: memory cost in KiB (~64MB of RAM used during hashing)-t 3: time cost (3 iterations)-p 4: parallelism (4 threads)
# token creation
ADMIN_TOKEN=$(echo -n "your_admin_password" | argon2 "$(openssl rand -base64 32)" -e -id -k 65540 -t 3 -p 4)
printf "ADMIN_TOKEN='%s'\n" "$ADMIN_TOKEN" | sudo -u vaultwarden tee /home/vaultwarden/vaultwarden/.env
# permissions settings
sudo chmod 600 /home/vaultwarden/vaultwarden/.envCreate docker compose with these settings #
Put SIGNUPS_ALLOWED=false after registration otherwise anyone who reaches your instance can register.
Put DOMAIN to /home/vaultwarden/vaultwarden/.env file after ADMIN_TOKEN.
# docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "5080:80"
volumes:
- ./data:/data
env_file:
- .env
environment:
- SIGNUPS_ALLOWED=true
- LOG_LEVEL=warn
- SENDS_ALLOWED=true
- EMERGENCY_ACCESS_ALLOWED=true
- WEB_VAULT_ENABLED=true
- SHOW_PASSWORD_HINT=false
- INVITATIONS_ALLOWED=false
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Configure SUBUID and SUBGID #
Docker rootless uses user namespaces to map container UIDs/GIDs to unprivileged ranges on the host. The /etc/subuid and /etc/subgid files define which UID/GID ranges each user is allowed to use. Without these entries, the container can’t create isolated users internally and will fail to start.
# write to file /etc/subuid and /etc/subgid
sudo usermod --add-subuids 200000-265535 --add-subgids 200000-265535 vaultwarden
# check if everything works
grep vaultwarden /etc/subuid /etc/subgidStart docker service #
Start docker and check if it runs.
# start
sudo -u vaultwarden XDG_RUNTIME_DIR=/run/user/$(id -u vaultwarden) systemctl start docker
# check
sudo -u vaultwarden XDG_RUNTIME_DIR=/run/user/$(id -u vaultwarden) systemctl status docker Start docker compose #
# launch docker as user vaultwarden and recreate
sudo -u vaultwarden -H /bin/bash -lc '\''
export DOCKER_HOST=unix:///run/user/$(id -u vaultwarden)/docker.sock
cd /home/vaultwarden/vaultwarden/
docker compose up -d --force-recreate'Generate certificates #
Generate auto-signed certificates for encrypted communication with openssl command.
OpenSSL flags explained
-x509: generate a self-signed certificate instead of a certificate signing request-nodes: don’t encrypt the private key with a passphrase-days 3650: certificate validity (10 years)-newkey rsa:2048: create a new 2048-bit RSA private key-keyout: path for the private key file-out: path for the certificate file-subj "/CN=...": set the Common Name without interactive prompts-addext "subjectAltName=...": add SANs (IP addresses and DNS names) so clients accept the certificate when connecting by IP or hostname
sudo mkdir -p /etc/nginx/ssl
# gen certificate
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/vaultwarden.key \
-out /etc/nginx/ssl/vaultwarden.crt \
-subj "/CN=__DOMAIN_NAME__" \
-addext "subjectAltName=IP:__IP_ADDRESS__,DNS:__DOMAIN_NAME__"Configure nginx reverse proxy #
Create file /etc/nginx/sites-available/vaultwarden and make symlink to /etc/nginx/sites-enabled/ dir.
This lets you disable a site by simply removing the symlink, without deleting the actual configuration file.
nginx config explained
Default server block:
listen 4080 ssl default_server: catches all requests that don’t match any configured server name. Acts as a fallback.return 444: special nginx code that closes the connection immediately without sending any response. Drops unknown or malicious requests silently.
Main server block:
listen 4080 ssl: listens on port 4080 with SSL enabled.server_name: defines which hostnames this server block responds to.
TLS hardening:
ssl_protocols TLSv1.2 TLSv1.3: only accepts TLS 1.2 and 1.3. Older versions (1.0, 1.1) have known vulnerabilities and are deprecated.ssl_ciphers HIGH:!aNULL:!MD5:!RC4: uses only strong ciphers. Excludes those without authentication (aNULL), and broken algorithms (MD5,RC4).ssl_prefer_server_ciphers on: the server chooses the cipher, not the client. Prevents a compromised client from forcing a weak cipher.ssl_session_cache shared:SSL:10m: shared TLS session cache across nginx workers (10 MB โ 40,000 sessions). Avoids renegotiating TLS on every request.ssl_session_timeout 10m: cached TLS sessions expire after 10 minutes.
Security headers:
X-Content-Type-Options "nosniff": prevents the browser from guessing the MIME type of a file. Without this, a browser could interpret a text file as HTML and execute malicious JavaScript.X-Frame-Options "SAMEORIGIN": the site can only be loaded in an iframe by itself. Blocks clickjacking attacks.X-XSS-Protection "0": disables the legacy browser XSS filter, which could actually introduce new vulnerabilities. Modern browsers don’t need it.Referrer-Policy "strict-origin-when-cross-origin": when navigating to an external site, the browser sends only the origin (e.g.https://yourdomain.com), not the full URL path. Prevents leaking sensitive information.Strict-Transport-Security "max-age=31536000; includeSubDomains": HSTS โ tells the browser to only connect via HTTPS for the next 365 days, even if the user typeshttp://. Prevents downgrade attacks.always: ensures the header is sent on all responses, including error pages (403, 500, etc.).
Other directives:
client_max_body_size 525M: maximum allowed size for request body. Needed for Vaultwarden file attachments.proxy_pass: forwards requests to the Vaultwarden container on port 5080.proxy_set_header Host $host: preserves the original hostname.proxy_set_header X-Real-IP $remote_addr: passes the real client IP to the backend (otherwise Vaultwarden would always see 127.0.0.1).proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for: passes the full proxy chain.proxy_set_header X-Forwarded-Proto $scheme: tells the backend whether the original connection was HTTP or HTTPS.proxy_set_header Upgrade $http_upgradeandConnection "upgrade": required for WebSocket connections. Vaultwarden uses WebSocket for real-time sync notifications between clients.
# write config
sudo tee /etc/nginx/sites-available/vaultwarden <<'EOF'
server {
listen 4080 ssl default_server;
ssl_certificate /etc/nginx/ssl/vaultwarden.crt;
ssl_certificate_key /etc/nginx/ssl/vaultwarden.key;
return 444;
}
server {
listen 4080 ssl;
server_name __IP_ADDRESS__ __DOMAIN_NAME__;
ssl_certificate /etc/nginx/ssl/vaultwarden.crt;
ssl_certificate_key /etc/nginx/ssl/vaultwarden.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5:!RC4;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 525M;
location / {
proxy_pass http://127.0.0.1:5080;
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;
}
location /notifications/hub {
proxy_pass http://127.0.0.1:5080;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
# make symlink
sudo ln -s /etc/nginx/sites-available/vaultwarden /etc/nginx/sites-enabled/vaultwardenStart nginx service #
Run nginx and see if it’s running.
# check syntax
sudo nginx -t
# check if it's already running
systemctl status nginx
# if it's running reload
sudo systemctl reload nginx
# if it's not running start nginx
sudo systemctl enable --now nginx
# see if nginx is running
sudo systemctl status nginx
# check possible errors
sudo journalctl -u nginx -f Enable ufw Connection #
If you are in lan run this command.
sudo ufw allow in on __LAN_INTERFACE__ from __LAN_ADDRESS__/24 to any port 4080 proto tcp comment "Vaultarden from lan"Install certificate on client #
Copy *.crt file and install on your client (IPhone/Mac/Android)
sudo cp /etc/nginx/ssl/vaultwarden.crt /tmp/
cd /tmp && python3 -m http.server 8080 On client go to http://__IP_SERVER__:8080/vaultwarden.crt and your client will download certificate.
Install using settings of your client.
Application settings #
Install Bitwarden on your client, open it and add https://__IP_ADDRESS__:4080.
After insert mail and password you insert on server.
Disable registration #
Change SIGNUPS_ALLOWED=true to SIGNUPS_ALLOWED=false on docker-compose.yml and relaunch container with command.
Install agent #
Create docker-compose.yml with a port is not used on your system.
# check what port to map to agent if you just use other agent
sudo ss -tulpn | grep -E ':9[0-9]{3}'
# select port
AGENT_PORT="__AVAILABLE_PORT__"
# content of docker-compose.yml
VAULT_UID=$(id -u vaultwarden)
sudo -u vaultwarden tee /home/vaultwarden/portainer-agent/docker-compose.yml <<EOF
services:
agent:
image: portainer/agent:latest
container_name: portainer-agent
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "${AGENT_PORT}:9001"
volumes:
- /run/user/${VAULT_UID}/docker.sock:/var/run/docker.sock
- /home/vaultwarden/.local/share/docker/volumes:/var/lib/docker/volumes
EOFsudo ufw allow from 127.0.0.1 to any port __AVAILABLE_PORT__ Launch portainer agent.
sudo -u vaultwarden -H /bin/bash -lc '\''
export DOCKER_HOST=unix:///run/user/$(id -u vaultwarden)/docker.sock
cd /home/vaultwarden/vaultwarden/portainer-agent/
docker compose up -d --force-recreate'Go to the Portainer dashboard, navigate to Environments โ Add environment, select Docker Standalone โ Agent, and enter your server IP with the agent port (e.g. IP_ADDRESS:AVAILABLE_PORT) as the Environment URL.
Backup #
The ./data directory contains the SQLite database with all your vault entries. Schedule a regular backup to avoid data loss.
# create backup directory
sudo -u vaultwarden mkdir -p /home/vaultwarden/backups
# edit vaultwarden crontab
sudo crontab -u vaultwarden -eAdd these lines to schedule a daily backup at 3 AM and auto-delete backups older than 30 days.
# daily backup at 3 AM
0 3 * * * tar czf /home/vaultwarden/backups/vaultwarden-$(date +\%Y\%m\%d).tar.gz -C /home/vaultwarden/vaultwarden data/
# delete backups older than 30 days
0 4 * * * find /home/vaultwarden/backups -name \"*.tar.gz\" -mtime +30 -deleteTest backup restore #
After setting up the cron backup, periodically verify that the database is not corrupted using the sqlite3 command.
# test restore (run manually to verify backups work)
sudo -u vaultwarden mkdir -p /tmp/vaultwarden-restore-test
sudo -u vaultwarden tar xzf /home/vaultwarden/backups/vaultwarden-$(date +%Y%m%d).tar.gz \
-C /tmp/vaultwarden-restore-test
# check that the SQLite database is valid
sqlite3 /tmp/vaultwarden-restore-test/data/db.sqlite3 "PRAGMA integrity_check;"
# cleanup
rm -rf /tmp/vaultwarden-restore-test
Se integrity_check ritorna ok, il backup รจ valido e ripristinabile.Access Vaultwarden from outside your network with WireGuard #
Since the Bitwarden client only accepts a single server URL, you need a way to reach your Vaultwarden instance both from your local network and from outside. If you already have a WireGuard VPN set up, this is straightforward.
Server side โ enable IP forwarding and masquerading.
Your WireGuard clients need to reach the LAN subnet where Vaultwarden is running. Enable IP forwarding and configure NAT masquerading through iptables.
Edit or create file /etc/sysctl.d/99-sysctl.conf.
# allow ip forwarding
sudo tee -a /etc/sysctl.d/99-sysctl.conf <<'EOF'
net.ipv4.ip_forward=1
EOF
# nat masquerading
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o <LAN_INTERFACE> -j MASQUERADEIptables command explained
How masquerading works with WireGuard When a WireGuard client (e.g. 10.0.0.2) wants to reach a device on your LAN (e.g. 192.168.1.x), the packet arrives at the server through the tunnel with a source IP of 10.0.0.2. The problem is that LAN devices have no route back to 10.0.0.0/24, so they don't know where to send the reply. The rule iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE solves this by rewriting the source IP of outgoing packets from the WireGuard subnet to the server's LAN IP before they leave the eth0 interface. The LAN device sees the packet as coming from the server itself, sends the reply back to the server, and the server forwards it back through the tunnel to the VPN client. In short: masquerading makes the server act as a translator between the VPN subnet and the LAN, allowing two networks that don't know about each other to communicate.Client side โ route LAN traffic through the tunnel.
On your WireGuard client (phone, laptop, etc.), edit the tunnel configuration and add your LAN subnet to the allowed IPs of the peer:
AllowedIPs = VPN_ADDRESS/24, LAN_ADDRESS/24
Replace LAN_ADDRESS/24 with your actual LAN subnet.
This tells the client to route both VPN and LAN traffic through the WireGuard tunnel when connected.
Set your Vaultwarden server URL to your server’s LAN IP:
https://LAN_IP:4080
From home (LAN): the client reaches the server directly โ no VPN needed.
From outside: connect to WireGuard first โ traffic to your LAN is routed through the tunnel and masqueraded by the server.
One URL, works everywhere.
Conclusion #
You now have a fully self-hosted password manager running in a security-hardened environment โ Docker rootless, dedicated user, SSL encryption, Argon2 hashed admin token, and firewall rules. Your passwords never leave your network and you have full control over your data.