Self-host Vaultwarden on a VPS: the secure setup

Self-host Vaultwarden on a VPS: the secure setup

Vaultwarden runs your passwords, so the tutorial is the security, not the install. Anyone can start the container. The parts that decide whether you get owned are the admin token, whether open signups are still on, whether the reverse proxy passes the WebSocket upgrade, whether fail2ban is watching the login endpoint, and whether the backup you took is one you have ever restored.

This walks the full path on Ubuntu 24.04 LTS with Vaultwarden 1.37.3 (released 13 September 2026), the current stable at the time of writing. Every command is here. Where a step commonly breaks, the failure and its cause are next to it.

What is Vaultwarden?

Vaultwarden is a password server that speaks the Bitwarden client protocol. It is a from-scratch reimplementation in Rust of the Bitwarden server API, so the official Bitwarden apps, browser extensions and CLI all talk to it unchanged. You point your clients at your own domain instead of bitwarden.com and keep the whole vault on hardware you control.

People self-host it because it is tiny. The upstream Bitwarden stack wants several containers and a couple of gigabytes of RAM; Vaultwarden is a single binary with SQLite and idles in tens of megabytes. It replaces a paid Bitwarden organisation while giving you the premium features (2FA, attachments, Sends) for the cost of a small VPS.

What you need

The honest minimum, with SQLite and a handful of users:

  1. RAM: 512 MB is plenty. It idles well under 100 MB; the memory floor is your OS, not Vaultwarden.
  2. vCPU: 1 shared vCPU. Login and sync are bursty and short.
  3. Disk: 10 GB covers the OS, the image, the database and years of attachments for a small team.
  4. OS: Ubuntu 24.04 LTS, root or a sudo user.
  5. A domain you can add an A/AAAA record to, e.g. vault.example.com.

This is a genuine smallest-plan workload. It fits comfortably on the entry UK VPS in Coventry, which gives you a fixed IPv4 and IPv6, always-on DDoS filtering and one-reboot upgrades if your team grows later. Do not size up for this. A password vault for a household or a small company will never touch the ceiling of the cheapest plan.

Bitwarden client nginx TLS :443 certbot Vaultwarden container :80 via 127.0.0.1:8080 SQLite db.sqlite3 https http wss /notifications/hub (Upgrade) The request path. Only 443 is public; the container listens on loopback and is never reached directly.

Point the domain at the server

Create the DNS record before anything else, because the TLS certificate later depends on it resolving. Use your VPS's real addresses.

vault.example.com. A 203.0.113.10
vault.example.com. AAAA 2001:db8:42::10

Confirm it has propagated to where you are working from:

$ dig +short vault.example.com
203.0.113.10

An empty answer means the record has not propagated yet. Wait; do not skip ahead to certbot, which will fail its HTTP-01 challenge if the name does not resolve to this box.

Update the system and install Docker

Vaultwarden ships an official Docker image; the project does not publish a standalone binary, so the container is the supported path. We manage it with systemd rather than docker compose so restarts, logging and boot order behave like any other service.

$ sudo apt update && sudo apt -y upgrade
$ sudo apt -y install ca-certificates curl sqlite3
$ curl -fsSL https://get.docker.com | sudo sh
$ docker --version
Docker version 27.5.1, build 9f9e405

Create the directory layout

Keep the persistent data and the environment file together under /srv.

$ sudo mkdir -p /srv/vaultwarden/data
$ sudo chmod 750 /srv/vaultwarden

The data directory is the whole state of the server: db.sqlite3 plus its -wal and -shm companions, rsa_key.* (the token-signing keys), attachments/, sends/, config.json and icon_cache/. Lose the rsa keys and every client is logged out; lose the database and the vault is gone.

Generate the admin token

The admin page is disabled unless ADMIN_TOKEN is set. Never put a plaintext token there. Vaultwarden hashes one for you with Argon2, so a leak of the config file does not leak the token:

$ docker run --rm -it vaultwarden/server:1.37.3 /vaultwarden hash
Generate an Argon2id PHC string using the 'bitwarden' preset:

Password:
Confirm Password:

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG'

Copy that whole single-quoted string. The single quotes matter: the PHC string contains $ signs that the shell and Docker will otherwise try to expand.

Configure Vaultwarden

Write the environment file. Note SIGNUPS_ALLOWED=true for now, so you can create the first account. We close it in a moment.

$ sudo tee /srv/vaultwarden/vaultwarden.env >/dev/null <<'EOF'
DOMAIN=https://vault.example.com
SIGNUPS_ALLOWED=true
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG'
ENABLE_WEBSOCKET=true
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=warn
EOF
$ sudo chmod 600 /srv/vaultwarden/vaultwarden.env

Since v1.31.0 the WebSocket path is served on the main HTTP port; the old separate port 3012 was removed. You do not open any extra port, but the reverse proxy still has to forward the upgrade. That comes next.

Write the systemd unit

We bind the container to loopback only, so nothing reaches Vaultwarden except through nginx.

$ sudo tee /etc/systemd/system/vaultwarden.service >/dev/null <<'EOF'
[Unit]
Description=Vaultwarden password server
After=docker.service
Requires=docker.service

[Service]
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f vaultwarden
ExecStart=/usr/bin/docker run --rm --name vaultwarden \
-p 127.0.0.1:8080:80 \
--env-file /srv/vaultwarden/vaultwarden.env \
-v /srv/vaultwarden/data:/data \
vaultwarden/server:1.37.3
ExecStop=/usr/bin/docker stop vaultwarden

[Install]
WantedBy=multi-user.target
EOF

Pin the tag. Using :latest means a restart can silently jump a major version and change the config format under you.

Start and verify it works

$ sudo systemctl daemon-reload
$ sudo systemctl enable --now vaultwarden
$ systemctl is-active vaultwarden
active

Now hit the health endpoint on loopback. /alive returns the server time with a 200:

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/alive
200

A connection refused here means the container did not come up. Check journalctl -u vaultwarden -n 50. The usual cause is a stray $ in the admin token because the here-doc was not quoted, which makes Vaultwarden refuse to parse the config.

Put nginx in front

$ sudo apt -y install nginx
$ sudo tee /etc/nginx/sites-available/vaultwarden >/dev/null <<'EOF'
server {
listen 80;
server_name vault.example.com;
client_max_body_size 128M;

location / {
proxy_pass http://127.0.0.1:8080;
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;

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
}
EOF
$ sudo ln -s /etc/nginx/sites-available/vaultwarden /etc/nginx/sites-enabled/
$ sudo nginx -t && sudo systemctl reload nginx

The last three proxy_set_header lines are the WebSocket-critical ones. X-Forwarded-For also matters beyond routing: it is the IP fail2ban will ban, so if you forget it every failed login looks like it came from 127.0.0.1 and the jail bans your own proxy.

Issue the TLS certificate

$ sudo apt -y install certbot python3-certbot-nginx
$ sudo certbot --nginx -d vault.example.com --redirect --agree-tos -m you@example.com --no-eff-email

Certbot rewrites the nginx block to listen on 443, installs the certificate and adds the 80→443 redirect. Confirm the public endpoint answers over HTTPS:

$ curl -s -o /dev/null -w '%{http_code}\n' https://vault.example.com/alive
200

First login, then close the door

Open https://vault.example.com, click Create account, and register the one account you actually want. Do it now, while signups are open.

Immediately disable open registration so nobody else can create an account on your server:

$ sudo sed -i 's/^SIGNUPS_ALLOWED=true/SIGNUPS_ALLOWED=false/' /srv/vaultwarden/vaultwarden.env
$ sudo systemctl restart vaultwarden

To add trusted users later, invite them from the admin page instead of reopening signups. Set INVITATIONS_ALLOWED=true and invite by email; that keeps the door shut to the internet while letting you onboard people one at a time.

Management commands

# status and logs
$ systemctl status vaultwarden
$ journalctl -u vaultwarden -f

# stop / start / restart
$ sudo systemctl stop vaultwarden
$ sudo systemctl start vaultwarden

# upgrade: bump the pinned tag in the unit, then
$ sudo systemctl daemon-reload && sudo systemctl restart vaultwarden
$ docker image prune -f

Before an upgrade, read the release notes for the version you are jumping to. Config keys occasionally get renamed between minor releases, and a removed key logs a warning rather than stopping the server, so it is easy to miss.

Production hardening

Firewall

Only SSH and the web ports should be reachable. The container is already on loopback, so nothing exposes 8080.

$ sudo ufw allow OpenSSH
$ sudo ufw allow 'Nginx Full'
$ sudo ufw --force enable

fail2ban on the login endpoint

Vaultwarden logs failed logins to the file we set as LOG_FILE. Two filters cover the two attack surfaces: the vault login and the admin page.

$ sudo apt -y install fail2ban
$ sudo tee /etc/fail2ban/filter.d/vaultwarden.conf >/dev/null <<'EOF'
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
EOF
$ sudo tee /etc/fail2ban/filter.d/vaultwarden-admin.conf >/dev/null <<'EOF'
[Definition]
failregex = ^.*Invalid admin token\. IP: <ADDR>.*$
EOF
$ sudo tee /etc/fail2ban/jail.d/vaultwarden.conf >/dev/null <<'EOF'
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /srv/vaultwarden/data/vaultwarden.log
maxretry = 5
bantime = 3600

[vaultwarden-admin]
enabled = true
port = 80,443
filter = vaultwarden-admin
logpath = /srv/vaultwarden/data/vaultwarden.log
maxretry = 3
bantime = 14400
EOF
$ sudo systemctl restart fail2ban
$ sudo fail2ban-client status vaultwarden

The IP in those log lines is whatever nginx passed in X-Forwarded-For. If your bans are all hitting 127.0.0.1, the proxy header is missing, not fail2ban.

Restrict the admin page

Even Argon2-hashed, the admin page is a brute-force target and a phishing surface. If you administer from a fixed address, lock /admin to it in nginx:

location /admin {
allow 198.51.100.5;
deny all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

If you have no fixed address, the honest alternative is to leave ADMIN_TOKEN unset most of the time and only set it, restart, do your admin, then unset it again. An always-on admin page on a password server is a liability you are choosing to accept.

A backup you have restored

The database is a live SQLite file with a write-ahead log. Copy db.sqlite3 on its own while a write is in flight and you capture a torn state: the -wal holds committed changes the main file does not have yet, and your "backup" restores to a corrupt or stale vault. Use SQLite's online backup API, which is safe against concurrent writes:

$ sudo tee /usr/local/bin/vw-backup.sh >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail
SRC=/srv/vaultwarden/data
DEST=/srv/backups
STAMP=$(date +%F-%H%M)
mkdir -p "$DEST"
sqlite3 "$SRC/db.sqlite3" ".backup '$DEST/db-$STAMP.sqlite3'"
tar -C "$SRC" -czf "$DEST/files-$STAMP.tar.gz" \
--exclude='db.sqlite3*' --exclude='icon_cache' --exclude='vaultwarden.log' .
find "$DEST" -mtime +14 -delete
EOF
$ sudo chmod +x /usr/local/bin/vw-backup.sh

Vaultwarden 1.37.3 also has a built-in command that does the same consistently: docker exec vaultwarden /vaultwarden backup (available since v1.32.1). Either way, schedule it and then restore it once, because a backup you have never restored is a hypothesis:

$ echo '15 3 * * * root /usr/local/bin/vw-backup.sh' | sudo tee /etc/cron.d/vw-backup

# restore test into a scratch dir
$ mkdir -p /tmp/vw-restore
$ cp /srv/backups/db-2026-09-24-0315.sqlite3 /tmp/vw-restore/db.sqlite3
$ sqlite3 /tmp/vw-restore/db.sqlite3 'PRAGMA integrity_check;'
ok

An ok from integrity_check means the snapshot is a consistent database. Copy the data folder off the box as well; a backup that lives only on the server it protects is not a backup. Karizanta offers scheduled off-site backups as a paid add-on, or you can push the tarballs to any object store from cron.

Where this can bite you

Live sync silently stops. Vaultwarden works fine in a browser with a broken WebSocket, so you only notice that a change on your phone never appears on your laptop. The cause is almost always the proxy dropping the upgrade. Test it directly:

$ curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZQ==' \
https://vault.example.com/notifications/hub
101

A 101 is the handshake succeeding. A 400 or 200 means nginx swallowed the Upgrade and Connection headers, so recheck those three proxy_set_header lines.

The admin page left open to the world. The default posture once you set a token is that anyone who finds /admin gets a login form to hammer. The IP allow-list above, or unsetting the token between sessions, is the fix. Do not rely on the token alone.

A backup taken mid-write. Covered above, and it is the failure that hurts most because you discover it during a restore, when you have no good copy left. If you take only one thing from this guide, use the .backup command, never cp db.sqlite3.

Port 8080 already bound. If another service holds 8080 the container start fails with address already in use. Change the host side of the mapping in the unit (for example 127.0.0.1:8081:80) and match it in proxy_pass.

Where to run this

Vaultwarden is a low-resource, always-on service, which makes it a textbook fit for the cheapest plan rather than an argument for a bigger one. For a password server the deciding factor is usually jurisdiction, because this is the one box on your account holding every credential you own. If your users and legal footprint are in the UK, the UK VPS in Coventry keeps the vault under UK data protection on Karizanta's own hardware. If you want it inside the EU, the Sweden VPS in Stockholm sits in the GleSYS facility; the Netherlands VPS in Naaldwijk is peered into AMS-IX and DE-CIX if low-latency reach across the continent matters more. All three deploy in under 60 seconds, include DDoS filtering and IPv6, and let you add RAM later with one reboot. If your container start fails on a bound port, the walk-through in your container runs but the port is unreachable covers the four places that breaks.

Sources

  1. Vaultwarden Wiki (official) (unknown)
  2. Enabling WebSocket notifications - Vaultwarden Wiki (unknown)
  3. Backing up your vault - Vaultwarden Wiki (unknown)
  4. Releases - dani-garcia/vaultwarden (2026-09)
  5. Vaultwarden 1.37.3 - Freedom.Tech (2026-09)

Frequently asked questions

How much RAM does Vaultwarden need on a VPS?

512 MB is enough for a small team using SQLite. Vaultwarden idles well under 100 MB; the practical floor is your operating system, not the application. It runs comfortably on the smallest VPS plan, so there is no need to size up for it.

Do I still need port 3012 for Vaultwarden WebSocket sync?

No. Since v1.31.0 the separate WebSocket port 3012 was removed and live sync is served on the main HTTP port at /notifications/hub. You do not open an extra port, but your reverse proxy must forward the Upgrade and Connection headers or sync silently fails.

How do I stop other people creating accounts on my Vaultwarden server?

Register your own account first with SIGNUPS_ALLOWED=true, then set it to false and restart. To add trusted users afterwards, set INVITATIONS_ALLOWED=true and invite them by email from the admin page instead of reopening public registration.

What is the safe way to back up the Vaultwarden SQLite database?

Use SQLite's online backup API: sqlite3 db.sqlite3 ".backup 'out.sqlite3'", or the built-in /vaultwarden backup command (since v1.32.1). Copying db.sqlite3 directly while it is being written can capture a torn state, because committed changes may still be in the -wal file.

Is the Vaultwarden admin token safe to store in plain text?

No. Generate an Argon2 hash with 'vaultwarden hash' and store that PHC string as ADMIN_TOKEN, so a leaked config file does not leak the token. Also restrict the /admin page by IP or leave the token unset except when you are actively administering.

Related reading

Tutorials

WordPress on a UK VPS: a speed and security checklist

A command-level checklist for migrating a small agency's WordPress site off shared hosting onto a UK VPS you now run yourself: PHP-FPM sized from RAM, OPcache and Redis, a page cache that never leaks a cart, TLS and HTTP/2, the security layer that carries real weight, and keeping personal data under UK GDPR.

Read more →
Deploy your server ← Back to blog