Vaultwarden Setup: Self-Host Your Password Manager with Docker, Securely (2026)

Vaultwarden Setup: Self-Host Your Password Manager with Docker, Securely (2026)

A Vaultwarden setup with Docker takes less than half an hour. Vaultwarden is a lean server written in Rust that re-implements the Bitwarden API. You run it yourself and still use the regular Bitwarden apps on your phone, desktop and browser. Your passwords live on your own server instead of with a vendor, and it costs you nothing beyond the server you may already have.

The catch: most guides stop after docker run, and that is exactly where the interesting questions begin. So on 31 October 2026 we set up a fresh Vaultwarden 1.37.4 (the current release, published 5 October 2026) in a throwaway container on our own server and deliberately tried to break it. Four findings up front:

1. Out of the box, anyone who knows your address can register. A made-up account was created and logged in after 0.57 seconds.

2. The built-in login rate limit kicks in after 10 failed attempts. With a forged X-Real-IP header we still got past it 30 times out of 30, both directly and behind Caddy with a default config.

3. If you paste the secure Argon2 admin token into compose.yaml without escaping, you end up with a mangled plain-text password. Vaultwarden rejects your real password, and the mangled string opens the admin panel.

4. A config.json in the data folder overrides your environment variables. Despite SIGNUPS_ALLOWED=false we could register again.

Each of these takes one line to fix. The full, tested configuration is below.

A glowing vault inside a small server, encrypted keys orbiting around it

What is Vaultwarden, and how is it different from Bitwarden?

Bitwarden is an open-source password manager with apps for every platform. You can self-host the official Bitwarden server too, but it is a dozen or so containers running .NET and Microsoft SQL Server and needs memory to match. Vaultwarden is an independent re-implementation of the same API in Rust: one container, SQLite by default, done.

Bitwarden (official, self-hosted)Vaultwarden
DeveloperBitwarden Inc.Community project (dani-garcia/vaultwarden)
LanguageC# / .NETRust
Containersmany (API, Identity, Web, DB, …)one
DatabaseMS SQL ServerSQLite, optionally MySQL/MariaDB or PostgreSQL
Idle RAMseveral GB recommended7.9 MB in our measurement
Organizations, Sends, emergency accessyes, partly paidyes, no licence
Official supportyesno, community
ClientsBitwarden appsthe same Bitwarden apps

The 7.9 MB is not a typo. After startup, our fresh container used 7.898 MiB of memory according to docker stats, and it went from start to answering /alive in 0.6 seconds. The image itself is much larger at 402 MB, mostly because of the bundled web vault (web vault 2026.7.0 in 1.37.4). Vaultwarden runs happily on the smallest VPS, a Raspberry Pi, or next to plenty of other services. For a sense of what servers need in general, see our measurements in How much RAM does a server need?.

One thing to keep in mind: Vaultwarden is not a Bitwarden Inc. product. Report bugs to the Vaultwarden project, not to Bitwarden. Vaultwarden says so itself in a big box in its startup log.

Is Vaultwarden secure? What the server actually sees

The most important property has nothing to do with Vaultwarden. It comes from the Bitwarden protocol: encryption happens on your device, not on the server. Your master password never leaves the client. The app derives a key from your master password and email address with PBKDF2 (default: 600,000 iterations). That key wraps a random vault key, which in turn encrypts every single item. The server only ever receives a derived hash for login and the finished ciphertext.

We didn’t take this on faith. With a small Python script we rebuilt the client, created an account and stored an item “Online-Banking” with username max.muster and password Geheim-Passwort-123. Then we searched the server’s SQLite file, including the WAL file:

grep -c "Geheim-Passwort-123\|max.muster\|Online-Banking\|bank.example" data/db.sqlite3*
# data/db.sqlite3:0
# data/db.sqlite3-shm:0
# data/db.sqlite3-wal:0

Zero matches. The ciphers table only contains strings like this:

2.jwsJMnMtTGhfDHviPF0gAw==|M8sUtYaq6eGcvDAyPaeHiQ=|...

The 2. means AES-256-CBC with HMAC-SHA256, followed by IV, ciphertext and MAC. Even someone who steals your whole database only has this. They then have to guess your master password offline, and every guess costs 600,000 PBKDF2 iterations. On our server a single derivation took 125 milliseconds. Specialised hardware is much faster, but against a long master password that doesn’t buy much.

A laptop sealing a golden key into an opaque capsule; the server only stores the sealed capsule

What the server does know: your email address, when you log in, from which IP, how many items you have and which devices are connected. And there is one gap no protocol closes. Whoever takes over your server can serve you a tampered web vault that records your master password as you type it. The mobile and desktop apps aren’t affected because their code comes from the app store. That gives you a simple rule: use the apps or the browser extension day to day, and the web vault only when you have to.

So is Vaultwarden secure? The cryptography is the same as Bitwarden’s. The security of your server, however, is now your job. That is what the rest of this article is about.

Prerequisites for your Vaultwarden setup

  • A Linux server with Docker and Docker Compose. A small VPS is enough. If you don’t have one yet: Linux server setup and What is Docker?.
  • A (sub)domain such as vault.yourdomain.com, with an A record pointing to the server.
  • HTTPS. This is not optional. The web vault relies on the browser’s crypto API, which only exists in a secure context, meaning HTTPS or localhost. What HTTPS actually does is explained in What is HTTPS?.
  • A reverse proxy that fetches the certificate. We use Caddy because it handles certificates automatically. Nginx works just as well, see Caddy vs Nginx.

If you’d rather not expose Vaultwarden to the internet at all, that is a very good idea. More on that below.

Step 1: Start Vaultwarden with Docker Compose

Create a folder such as /opt/vaultwarden with a compose.yaml inside. This is the version we ended up testing:

services:
  vaultwarden:
    image: vaultwarden/server:1.37.4
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://vault.yourdomain.com"
      SIGNUPS_ALLOWED: "false"
      ADMIN_TOKEN: '$$argon2id$$v=19$$m=65540,t=3,p=4$$...'   # explained in step 4
    volumes:
      - ./data:/data
    ports:
      - "127.0.0.1:8080:80"

Four details that matter more than they look:

  1. A pinned version instead of latest. That way you decide when an update happens and can take a backup first.
  2. 127.0.0.1:8080:80 instead of 8080:80. Without the IP, Vaultwarden is reachable straight from the internet, bypassing your reverse proxy. Docker inserts its port mappings around the firewall, so a ufw rule won’t save you here. Background in UFW firewall setup.
  3. DOMAIN must be the exact address you use to reach Vaultwarden, including https://. Otherwise links in emails and some login flows break.
  4. One port is enough. Vaultwarden used to need a second port, 3012, for live sync. Since version 1.29, WebSockets run over the same port. We checked: /notifications/hub answers on port 80 (with a 401 without a valid token, as it should). Many older guides still show the second port.

Then:

docker compose up -d
docker compose logs -f

The log should say Rocket has launched from http://0.0.0.0:80. For more on writing these files, see our Docker Compose example.

Step 2: HTTPS with Caddy, and the hidden trap in the login rate limit

The obvious Caddy config is three lines:

vault.yourdomain.com {
    reverse_proxy 127.0.0.1:8080
}

Caddy fetches the certificate itself and HTTPS works. In our test, though, this exact config had a problem, and it involves the login rate limit.

How the rate limit works

Vaultwarden has a built-in brake against password guessing. By default it allows a burst of 10 attempts, then one more per 60 seconds. We measured it: 30 wrong logins in a row gave 10× “wrong password” and 20× HTTP 429 “Too many login requests”. Seven seconds later it was still locked. So far so good.

But how does Vaultwarden know which IP an attempt comes from? By default it reads the HTTP header X-Real-IP. Normally the reverse proxy sets it. The problem is that an attacker can send it too.

The test: 30 attempts, 30 let through

We sent 30 wrong logins, each with a different made-up X-Real-IP header (10.0.0.0, 10.0.0.1, …):

SetupHeader forged?Result for 30 attempts
Directly to Vaultwardenno10× rejected, 20× rate-limited
Directly to Vaultwardenyes30× rejected, 0× rate-limited
Behind Caddy, default configno10× rejected, 20× rate-limited
Behind Caddy, default configyes30× rejected, 0× rate-limited
Behind Caddy with header_upyes10× rejected, 20× rate-limited

Caddy passes the client’s X-Real-IP through unchanged. Vaultwarden believes it, so every attempt seems to come from a new IP. The Vaultwarden log dutifully records the made-up addresses:

Username or password is incorrect. Try again. IP: 10.0.0.29. Username: fremder@example.com.

That means the rate limit is defeated, and if you point fail2ban at this log, the best it can do is ban imaginary addresses.

There is a flip side too. Without a forged header, Vaultwarden behind Docker attributed every request to the same address in our test: the Docker gateway 172.17.0.1. The limit then applies to everyone at once. We tried it: after twelve wrong attempts by an “attacker”, the real user’s correct password was rejected with 429 as well. One troublemaker locks everyone out.

The fix: one line

vault.yourdomain.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up X-Real-IP {remote_host}
    }
}

header_up overwrites the header with the address Caddy actually sees. With this line, the limit kicked in after ten attempts again even with a forged header, and the log showed the real address. In Nginx the equivalent is:

proxy_set_header X-Real-IP $remote_addr;

If a CDN such as Cloudflare sits in front of your proxy, {remote_host} is the CDN’s address. In that case you have to tell the proxy which header to trust from which addresses (in Caddy, via trusted_proxies). How reverse proxies deal with headers like this in general is covered in What is a reverse proxy?.

A shield in front of a server rejecting a forged badge

Step 3: Create your account, then close registration immediately

Now open https://vault.yourdomain.com and create your account. Registration has to be open for that. Our compose file above already says SIGNUPS_ALLOWED: "false", so for the very first start either set it to "true" briefly or invite yourself through the admin panel (which needs working SMTP).

Why this matters so much

Without this variable, registration is open. We tried it on a fresh container. Our script registered as fremder@example.com with 600,000 PBKDF2 iterations and a freshly generated RSA key, then logged in. Total time: 0.57 seconds, response registerFinish, then login with HTTP 200. No captcha and no email confirmation: without mail configured, Vaultwarden hands the “verification” token straight back in the registration response.

A stranger can’t read your passwords that way, they’re encrypted. But they can use your server as free storage: items, attachments and Sends (encrypted share links for files) at your expense, on your domain and under your IP. Nobody wants that.

With SIGNUPS_ALLOWED=false, the same attempt got:

400 Registration not allowed or user already exists

Existing accounts kept logging in normally (HTTP 200).

You can add other people later by inviting them through the admin panel. Or you allow only your own domain with SIGNUPS_DOMAINS_WHITELIST=yourdomain.com.

An open vault door with a stranger's silhouette walking in

Step 4: Enable the admin panel safely (and the dollar-sign trap)

Without ADMIN_TOKEN, the admin panel at /admin is disabled. Vaultwarden just shows: “admin panel is disabled, please configure the ‘ADMIN_TOKEN’ variable to enable it”. That is a very sensible default. You need the panel for invitations, disabling users, diagnostics and changing settings. If you don’t need it, leave it off.

If you do want it, don’t use a plain-text password. Vaultwarden can generate an Argon2id hash for you:

docker exec -it vaultwarden /vaultwarden hash

The -it is mandatory. Without a terminal, the command crashed for us with No such device or address, because it prompts for the password interactively. With a terminal, generation took 131 milliseconds (the “bitwarden” preset: 64 MiB memory, 3 iterations, 4 threads; alternatively --preset owasp with 19 MiB). You get something like:

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$NZxGeP...$ZLj9BE...'

The trap: Docker Compose eats the dollar signs

If you copy this value into compose.yaml as is, Docker Compose treats every $... as a variable. For us, docker compose config printed five warnings (The "argon2id" variable is not set. Defaulting to a blank string.), and instead of the hash the container received:

=19=65540,t=3,p=4/0v3Gldw+9dLkdKE

Vaultwarden no longer recognises that as a hash and treats the string as a plain-text password. We walked through the consequences:

  • The log only shows an inconspicuous [NOTICE] You are using a plain text ADMIN_TOKEN which is insecure.
  • Your real admin password is rejected with 401. You assume you made a typo.
  • The mangled string, on the other hand, opens the admin panel (HTTP 200 plus session cookie).

It isn’t a big security hole, since the string is still long and random. But you no longer have a hashed password, you have a plain-text one nobody told you about. The fix: double every $.

ADMIN_TOKEN: '$$argon2id$$v=19$$m=65540,t=3,p=4$$NZxGeP...$$ZLj9BE...'

With that: no warning, the hash arrives exactly (we compared it inside the container against the original), no plain-text notice in the log, the real password returns 200 and a wrong one 401. Alternatively, put the value in a .env file next to the compose file. Substitution applies there too, so use $$ there as well or wrap the value in single quotes. You can always check with:

docker compose config | grep ADMIN_TOKEN
docker logs vaultwarden 2>&1 | grep -i "plain text"

If the second command prints anything, something is off.

config.json: why the admin panel overrides your compose file

When you save a setting in the admin panel, Vaultwarden creates data/config.json. Values in it take precedence over environment variables. We checked: with SIGNUPS_ALLOWED=false in the compose file but {"signups_allowed": true} in config.json, registering a new account succeeded again with 200. At the same time, docker exec vaultwarden printenv SIGNUPS_ALLOWED still said false.

So if you read your compose file and feel safe, you might be reading the wrong file. Two clean approaches:

  • Either configure everything through environment variables and use the admin panel only for viewing, inviting and disabling users. If a config.json shows up anyway, you know someone saved something there.
  • Or deliberately make the admin panel the source of truth. Then config.json absolutely belongs in your backup.

Step 5: Backup, restore, and the one thing you must never forget

A password manager without a tested backup is a risk with a user interface. The good news: the data folder is tiny. Our test vault with two accounts used 604 KB.

What lives in data/

File/folderContentsBack up?
db.sqlite3 (+ -wal, -shm)accounts, items, folders, organizationsyes, but copy it properly
rsa_key.pemkey Vaultwarden uses to sign login tokensyes
attachments/encrypted file attachmentsyes
sends/encrypted Send filesyes
config.jsonsettings from the admin panelyes, if present
icon_cache/cached website iconsno

Backing up the database properly

Copying a live SQLite database with plain cp is risky. Some changes may sit in the WAL file, and you might catch a half-finished write. Vaultwarden now ships its own command for this:

docker exec vaultwarden /vaultwarden backup
# Backup to 'data/db_20261031_080507.sqlite3' was successful

That took 0.08 seconds for us. sqlite3 … "pragma integrity_check" returned ok, and both accounts and the item were in it. Alternatively, run sqlite3 data/db.sqlite3 ".backup '/path/backup.sqlite3'" from the host.

Schedule it with a cron job (create a cron job) and move the backup off the server afterwards, ideally following the 3-2-1 backup strategy. An example:

# /etc/cron.d/vaultwarden-backup, daily at 03:15
15 3 * * * root docker exec vaultwarden /vaultwarden backup && \
  tar czf /backup/vw-$(date +\%F).tgz -C /opt/vaultwarden/data \
  $(cd /opt/vaultwarden/data && ls -t db_*.sqlite3 | head -1) rsa_key.pem attachments sends \
  && find /opt/vaultwarden/data -name 'db_*.sqlite3' -mtime +7 -delete

Then ship it off the server with restic, rsync or similar. The backup itself is less sensitive than it sounds, since the items in it are encrypted. Still, don’t drop it unprotected into a public cloud: email addresses and metadata are in plain text.

We tested the restore

A backup that has never been restored is a hope. So we took an empty folder, copied in only the backup file as db.sqlite3 plus rsa_key.pem, and started a new container on it. Result: login 200, sync 200, the item was there.

Then a second attempt without rsa_key.pem. Vaultwarden simply generates a new key on startup. Login and vault still worked, because the vault data depends on your master password, not on that key. But every existing session was invalid: a previously issued token got 401. In practice, all devices have to log in again. Annoying, but no data loss. So back up rsa_key.pem, and if it’s ever missing, it isn’t the end of the world.

Three copies of a glowing vault cube on different shelves and in a cloud

Step 6: Hardening checklist after your Vaultwarden setup

With the steps above you’re ahead of most installations. These belong on the list too:

  1. Turn on two-step login in the vault (Settings → Security → Two-step login). TOTP with an authenticator app works without extra configuration, and so do passkeys/WebAuthn over HTTPS. Print the recovery code and put it away.
  2. A long master password. Four to six random words beat any P@ssw0rd!. It is the single key to everything, and nobody can reset it, not even you as admin.
  3. Add fail2ban on top of the rate limit. The built-in brake only blocks for minutes. Set LOG_FILE: "/data/vaultwarden.log" and have fail2ban react to the line Username or password is incorrect. Try again. IP: <HOST>. This only works if the header_up fix from step 2 is in place, otherwise fail2ban bans imaginary addresses or your own proxy. Basics in fail2ban setup.
  4. Turn off the admin panel if you don’t need it. Just leave out ADMIN_TOKEN. Or restrict the /admin path in your reverse proxy to your home network or VPN.
  5. Plan updates. Vaultwarden ships regularly, five releases since July alone (1.37.0 on 24 July, 1.37.1 on 29 July, 1.37.2 on 22 August, 1.37.3 on 13 September, 1.37.4 on 5 October 2026). The routine: read the release notes, take a backup, change the version in the compose file, docker compose pull && docker compose up -d. The database is migrated automatically on startup.
  6. Configure SMTP (SMTP_HOST, SMTP_FROM, …) if you want invitations, emergency access or email verification. The core works without mail.

Not on the internet at all: Vaultwarden inside a VPN

The safest login page is one nobody on the internet can find. If all your devices are on a VPN such as Tailscale or WireGuard anyway, just have Vaultwarden listen there. The Bitwarden apps then only sync while the VPN is up. On the road without VPN they keep working from their local encrypted copy and can show passwords, they just sync new items later. Which VPN fits you is covered in Tailscale vs WireGuard. One detail: the web vault needs HTTPS inside the VPN too. Tailscale can issue certificates for your ts.net address.

Video: Vaultwarden with Tailscale

If you’d rather watch the process once: IT Homelab Online (July 2026) shows Vaultwarden with a Tailscale sidecar container, so there are no open ports on the internet at all.

And a variant with a private network: NetBird (August 2026) puts Vaultwarden behind its own reverse proxy with automatic HTTPS and restricts access to the VPN. NetBird is the vendor here, so it’s naturally showing its own product.

Connecting the Bitwarden apps to your server

This is the pleasant part. Every Bitwarden app has a region selector on the login screen. Pick “Self-hosted” and enter your server URL, e.g. https://vault.yourdomain.com. That’s it. It works the same in the Android and iOS apps, the desktop app, the browser extension and the CLI (bw config server https://vault.yourdomain.com).

Moving from another password manager: in the web vault under Tools → Import data, Vaultwarden accepts exports from Bitwarden, KeePass, 1Password, LastPass, Chrome, Firefox and many more. The import file sits unencrypted on your computer. Delete it properly afterwards instead of leaving it in your downloads folder.

What we didn’t test

So you know what you can rely on and what you can’t:

  • MySQL/MariaDB and PostgreSQL as the database: not tried, only the default SQLite. For a household or a small team, SQLite is plenty. When switching pays off is covered in general in SQLite vs PostgreSQL.
  • The real apps: we didn’t connect them, we rebuilt the client in a script. App logins use the same API we tested.
  • SSO (login via an third-party identity provider, included in current versions), organizations and emergency access weren’t covered.
  • Load: not measured. At household scale it isn’t an issue anyway.
  • We reproduced the traps with Vaultwarden 1.37.4 and Caddy 2.11.7. Later versions may behave differently.

FAQ: Common questions about Vaultwarden

Is Vaultwarden free?

Yes. Vaultwarden is open source (AGPL-3.0) and costs nothing. Features that need a paid plan with Bitwarden, such as organizations or attachments, are included. You only pay for the server it runs on.

Is Vaultwarden secure enough for my passwords?

Encryption happens in the Bitwarden apps on your device. Our database didn’t contain a single password in plain text. Server security is on you, though: HTTPS, closed registration, a correct X-Real-IP, updates, backups and two-step login. If you don’t want to maintain that, a hosted password manager is the better choice.

What’s the difference between Vaultwarden and Bitwarden?

Bitwarden is the original from Bitwarden Inc., with its own server and apps. Vaultwarden is an independent server re-implementation in Rust that serves the same apps. It needs only one container and hardly any memory (7.9 MB idle for us), but has no official support.

Can I run Vaultwarden without a domain and without HTTPS?

Only with limits. The web vault needs a secure context, meaning HTTPS or localhost. For a home network you can get a certificate for a real domain via DNS challenge, or reach Vaultwarden through a VPN like Tailscale, which issues certificates itself.

How much RAM and storage does Vaultwarden need?

Very little. Our fresh container used 7.9 MB of RAM and started in 0.6 seconds. The Docker image is 402 MB, and a small household’s data folder is a few hundred kilobytes to a few megabytes, plus any attachments.

What happens if my Vaultwarden server goes down?

The Bitwarden apps keep an encrypted copy of your vault on the device, so you can still look up passwords. New items sync once the server is back. That’s exactly why the backup matters: if the server is gone for good, you restore everything from it.

Can I reset my master password if I forget it?

No, not even as admin. That’s the other side of on-device encryption: without the master password the vault can’t be decrypted. An admin can only delete the account. For families there is emergency access, where a trusted person gets access after a waiting period.

Do I have to enable the admin panel?

No. Without ADMIN_TOKEN it is disabled, and that’s a good state. You need it for invitations, user management and diagnostics. If you enable it, use an Argon2 hash with doubled $$ in the compose file.

Why are my changes in compose.yaml ignored?

Probably because there is a data/config.json. It’s created as soon as you save something in the admin panel, and it takes precedence over environment variables. Check whether the file exists and pick one of the two approaches.

How do I update Vaultwarden?

Take a backup (docker exec vaultwarden /vaultwarden backup), read the release notes, change the version in image: and run docker compose pull && docker compose up -d. The database migrates automatically on startup. If something goes wrong, go back to the old version and the backup.

Do I need fail2ban as well?

The built-in limit slows guessing down for a few minutes. fail2ban blocks the source for longer and at the firewall level. Both together make sense, but only if Vaultwarden sees the real client IP. Otherwise neither helps.

Conclusion: a Vaultwarden setup is worth it, if you don’t forget four lines

Vaultwarden is one of the most rewarding self-hosting projects there is: one container, minimal resources, the excellent Bitwarden apps, and your passwords stay with you. The cryptography does its job: our database didn’t hold a single readable password.

The risks are in the configuration, and most guides skip them. Our measurements showed four places where the default or an obvious mistake leaves the door open:

  1. SIGNUPS_ALLOWED: "false", otherwise anyone can register.
  2. header_up X-Real-IP {remote_host} in the proxy, otherwise the rate limit can be bypassed or locks everyone out.
  3. $$ instead of $ in the admin token, otherwise you have a mangled plain-text password.
  4. An eye on data/config.json, otherwise what’s in your compose file isn’t what applies.

Add a backup you’ve actually restored once, two-step login and regular updates, and you have a password manager that’s every bit as good as a commercial one, and it belongs to you.