Skip to main content
  1. articles/

Self-Host Vaultwarden

Dennis Frati
Author
Dennis Frati
Sysadmin - Coder - Self-taught guy from Italy ๐Ÿ‡ฎ๐Ÿ‡น
Table of Contents

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 files

Create 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/.env
After you set up nginx navigate to https://IP_ADDRESS:4080/admin and enter the password you used to generate the Argon2 hash.

Create 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/subgid

Start 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 types http://. 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_upgrade and Connection "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/vaultwarden

Start 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
EOF
sudo 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 -e

Add 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 -delete

Test 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 MASQUERADE
Iptables 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.