Can I self-host 1Password?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Vaultwarden. It takes one prompt, a 512 MB VPS, and about 90 minutes. That is $5.99 a month you stop paying 1Password — $71.88 a year on the Families plan.
Why people pay for 1Password
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
1Password sells the part a password manager lives or dies on: sync that has never lost anyone's vault, apps on every platform a family actually owns, and recovery when someone forgets the one password that matters. You are paying for an operator who is awake at 3am, not for the cryptography.
| Plan | List price | What it buys |
|---|---|---|
| Individual | $3.99/mo | Billed monthly. $2.99/mo if paid annually, advertised as a first-year rate for new customers. |
| Familiesthe plan this page prices against | $5.99/mo | Billed monthly, covers up to 5 people. $4.49/mo if paid annually, advertised as a first-year rate for new customers. |
| Teams Starter Pack | $24.95/mo | Flat rate for the first 10 members, paid annually. |
| Business | $8.99/mo per seat | Per user per month, paid annually. |
| Enterprise | quote only | Quote only — the page says request a quote. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: high
Replaced by Vaultwarden
One project, named before the prompt, so you know what you are about to install.
A Bitwarden-compatible password server small enough to run on the cheapest VPS you can rent.
The only one on this list that speaks the Bitwarden protocol, so the official Bitwarden apps and browser extensions on every platform your household already uses point at your server without knowing the difference. That client story is the whole reason a self-hosted vault is usable by people who did not choose to self-host.
The swap
You'd run
Vaultwarden
ONE EVENING · ~90 min to running · 512 MB RAM
1Password Families · vendor list price · checked 2026-08-05 · source
Before you start
- RAM floor
- 512 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
One prompt, assuming Prompt Zero is done. It installs Vaultwarden — read it before you paste it, which is the whole reason it is on the page instead of behind a download.
289 lines · 12,186 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Vaultwarden 1.37.1 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` is the hostname whose A record already points here; `<ADMIN_EMAIL>` is
the address their vault account is created with in step 7, written to no file. Vaultwarden
needs 512 MB of RAM available and 10 GB free on /srv, and runs on amd64 and arm64. Measure:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If RAM is under 512 MB or free disk is under 10 GB, print both and stop; do not install and
hope. If `dig +short` prints nothing, stop too: Caddy cannot certify a name that does not
resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/vaultwarden /srv/vaultwarden/data /srv/vaultwarden/backups
ls -la /srv/vaultwarden
```
Assert: `ls -la` shows `data` and `backups` owned by the login user at mode `750`. Nothing is
written outside /srv/vaultwarden, and `data` is the whole vault: SQLite, attachments, RSA keys.
## 3. Secrets
Two secrets, both generated on the server. The first is the admin page passphrase, which the
user keeps; the second is its Argon2 PHC hash, which is what the server stores, produced by
Vaultwarden's own binary. Print neither value, in chat, in your summary, or in any log line.
Replace `<DOMAIN>` with the real hostname as you write the first file:
```bash
umask 077
cat > /srv/vaultwarden/.env <<'EOF'
DOMAIN=https://<DOMAIN>
EOF
PASSPHRASE="$(openssl rand -base64 33)"
HASH="$(printf '%s\n%s\n' "$PASSPHRASE" "$PASSPHRASE" | docker run --rm -i vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8 /vaultwarden hash --preset owasp | grep -o '\$argon2[^ ]*' | tail -n 1)"
ESCAPED="$(printf '%s' "$HASH" | sed 's/[$]/$$/g')"
echo "ADMIN_TOKEN=$ESCAPED" >> /srv/vaultwarden/.env
printf '%s\n' "$PASSPHRASE" > /srv/vaultwarden/admin-passphrase.txt
chmod 600 /srv/vaultwarden/.env /srv/vaultwarden/admin-passphrase.txt
unset PASSPHRASE HASH ESCAPED
umask 022
ls -l /srv/vaultwarden/.env /srv/vaultwarden/admin-passphrase.txt
grep -c '^ADMIN_TOKEN=' /srv/vaultwarden/.env
```
Assert: both files exist at mode `-rw-------` and the grep prints `1`. Doubling every `$` is
what stops compose eating half the hash; step 10 has the whole story. Tell the user the
passphrase is in /srv/vaultwarden/admin-passphrase.txt, to move it into their vault after step 7
and then delete that file, and that you have not read it out anywhere.
## 4. compose.yml
```bash
cat > /srv/vaultwarden/compose.yml <<'EOF'
# Vaultwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose shape ... https://github.com/dani-garcia/vaultwarden/wiki/Using-Docker-Compose
# reverse proxy ... https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples
# admin token ..... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page
# websockets ...... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
#
# One container, and no proxy service: the Caddy that Prompt Zero installed under
# systemd terminates TLS and reaches this one on loopback. SQLite keeps the vault
# and the attachments under /data, so that directory is the whole backup. Since
# 1.31.0 WebSocket traffic rides the main HTTP port. Tag and digest are the 1.37.1
# release read from the Docker Hub registry API on 2026-08-05, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
vaultwarden:
image: vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN}
# Opened for one step during the install, then closed and asserted closed.
SIGNUPS_ALLOWED: "false"
# Argon2 PHC hash made on the server. Every "$" is stored as "$$" in .env.
ADMIN_TOKEN: ${ADMIN_TOKEN}
volumes:
- /srv/vaultwarden/data:/data
ports:
# Loopback only, and 8222 never enters the firewall.
- "127.0.0.1:8222:80"
EOF
cd /srv/vaultwarden && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. If compose reports that `DOMAIN` or `ADMIN_TOKEN` is not set,
step 3 did not finish; go back rather than inventing a value here.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-vaultwarden
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Vaultwarden · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples,
# https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed under
# systemd, with <DOMAIN> replaced by the hostname pointed at this box. 8222 is the
# loopback port compose publishes; it is not open in the firewall. The X-Real-IP
# header_up is upstream's, and Vaultwarden's login rate limiter reads it.
#
# No WebSocket route, and none is missing: 3012 was removed in 1.31.0 and that
# traffic rides the main HTTP port, which reverse_proxy upgrades on its own.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
}
reverse_proxy 127.0.0.1:8222 {
header_up X-Real-IP {remote_host}
}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-vaultwarden, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it with no cron job, and the systemd Caddy is
already holding 80 and 443, so do not start a second one.
## 6. Firewall
Two ports open, both of them Caddy's, and 8222 is not one of them. These are idempotent, so on
a box Prompt Zero configured they change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3, which Caddy offers by default. 8222 stays closed: bound to 127.0.0.1, a rule
for it would cover traffic that cannot arrive, and `sudo ufw delete allow 8222` removes one a
previous run left. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8222.
## 7. Start and verify
```bash
cd /srv/vaultwarden
docker compose pull
docker compose up -d
sleep 15
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
Assert: that prints `200`. `/alive` opens the database to answer, so 200 means container and
vault file are both good. Anything else: stop, run `docker compose logs --tail 30 vaultwarden`
and `sudo journalctl -u caddy -n 30`, and say which earlier step is the likely cause. Usually
DNS: an A record created minutes ago makes Caddy's first certificate attempt fail and retry.
Registration is off in compose.yml, which is right for every day except this one. Turn it on
for exactly one step:
```bash
cd /srv/vaultwarden
sed -i 's/SIGNUPS_ALLOWED: "false"/SIGNUPS_ALLOWED: "true"/' /srv/vaultwarden/compose.yml
docker compose up -d --force-recreate vaultwarden
sleep 10
```
The first screen at https://<DOMAIN> shows a `Log in` heading and a `Create account` link.
STOP: tell the user to open https://<DOMAIN>, create their account using <ADMIN_EMAIL>, and
wait. Do not continue until they confirm they can sign in.
Once they confirm, close registration again:
```bash
cd /srv/vaultwarden
sed -i 's/SIGNUPS_ALLOWED: "true"/SIGNUPS_ALLOWED: "false"/' /srv/vaultwarden/compose.yml
docker compose up -d --force-recreate vaultwarden
sleep 10
grep 'SIGNUPS_ALLOWED' /srv/vaultwarden/compose.yml
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
Assert, all three: the grep prints `SIGNUPS_ALLOWED: "false"`, the curl prints `200`, and the
user reloads and confirms the `Create account` link is gone. All three pass before you report
success, and the third is the one with security meaning. A running container is not success.
## 8. First backup and restore
Take the backup now, before the user puts a single password in, and take it with the app
stopped: a SQLite file copied mid-write is not a backup.
```bash
cd /srv/vaultwarden
docker compose stop
tar -C /srv/vaultwarden -czf /srv/vaultwarden/backups/vaultwarden-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/vaultwarden/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds,
and `data` plus `.env` is the whole install. A backup on the same disk as the data is not a
backup, so run this one from the user's machine, not the server:
```bash
mkdir -p ~/backups/vaultwarden
scp vps:/srv/vaultwarden/backups/*.tar.gz ~/backups/vaultwarden/
```
Now prove the restore, today, while the only thing at risk is an empty vault. A backup nobody
has restored is a guess:
```bash
cd /srv/vaultwarden
docker compose down
sudo rm -rf /srv/vaultwarden/data
tar -C /srv/vaultwarden -xzf /srv/vaultwarden/backups/vaultwarden-$(date +%F).tar.gz
docker compose up -d
sleep 15
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
Assert: `200`. Then tell the user to sign in once with the account from step 7; if that
password is refused, the archive did not hold `data/db.sqlite3` and the tar step is where to
look. Those four commands are the whole disaster plan and they have now been run once. The
certificate is not among them: it belongs to the host Caddy under /var/lib/caddy, so a restore
onto a fresh machine asks Let's Encrypt for a new one.
## 9. Updating later
New versions are listed at https://github.com/dani-garcia/vaultwarden/releases. Take a backup
first. Run `docker pull` on the new tag by hand once: the `Digest: sha256:` line it prints is
what goes after the `@` in the image line of /srv/vaultwarden/compose.yml. Edit both, then:
```bash
cd /srv/vaultwarden
docker compose pull
docker compose up -d
docker compose logs --tail 20 vaultwarden
```
Vaultwarden migrates its own database on start, so read that log before calling it done.
## 10. What will probably go wrong
The admin token, and it fails silently. The hash Vaultwarden's own generator prints is full of
`$`, and docker compose expands `$` inside `.env` values, so a hash stored unescaped reaches
the container with pieces missing. Nothing errors. The container starts, the vault works, and
/admin refuses the correct passphrase forever. I lost twenty minutes to that before thinking to
compare lengths. Step 3 doubles every `$` for that reason; if the user hand-edits it, look
here first.
## 11. Out of scope
- Do not configure SMTP. Password hint mail is not worth a port 25 argument on a fresh VPS.
- Do not switch the database to PostgreSQL. SQLite is why the whole vault is one directory.
- Do not enable Bitwarden push notifications. They need keys issued by Bitwarden, separately.
- Do not add a reverse proxy container, and do not touch any global options block in
/etc/caddy/Caddyfile. This install appends one site block and nothing else.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Vaultwarden 1.37.1 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over
`ssh vps` unless a step says otherwise. Replace `<DOMAIN>` with the hostname whose A record
already points at the box, and `<ADMIN_EMAIL>` with the address you want your vault account
to use.
This one is holding your passwords. Do the backup step. It is step 8 and it is not
optional.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `512` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP address on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it at your DNS
provider, wait a minute, run `dig +short <DOMAIN>` again. Do not go on without it: Caddy
cannot get a certificate for a hostname that does not resolve, and failed attempts count
against a rate limit you cannot see.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/vaultwarden /srv/vaultwarden/data /srv/vaultwarden/backups
ls -la /srv/vaultwarden
```
You should see: `data` and `backups`, both owned by your own username, both at
`drwxr-x---`.
If you do not: `install: cannot change owner` means your user cannot sudo, which is a
Prompt Zero problem rather than this one. Remember what `data` is, because every later step
refers back to it: that directory is your entire vault, one SQLite file with the
attachments and the RSA keys beside it.
## 3. Secrets
Two secrets, and both are made here on the server rather than anywhere near this chat
window. The first is the passphrase for Vaultwarden's admin page, which you keep. The
second is the Argon2 hash of it, which is what the server stores, and Vaultwarden's own
binary is what produces it.
Replace `<DOMAIN>` before you paste, and paste the whole block at once:
```bash
umask 077
cat > /srv/vaultwarden/.env <<'EOF'
DOMAIN=https://<DOMAIN>
EOF
PASSPHRASE="$(openssl rand -base64 33)"
HASH="$(printf '%s\n%s\n' "$PASSPHRASE" "$PASSPHRASE" | docker run --rm -i vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8 /vaultwarden hash --preset owasp | grep -o '\$argon2[^ ]*' | tail -n 1)"
ESCAPED="$(printf '%s' "$HASH" | sed 's/[$]/$$/g')"
echo "ADMIN_TOKEN=$ESCAPED" >> /srv/vaultwarden/.env
printf '%s\n' "$PASSPHRASE" > /srv/vaultwarden/admin-passphrase.txt
chmod 600 /srv/vaultwarden/.env /srv/vaultwarden/admin-passphrase.txt
unset PASSPHRASE HASH ESCAPED
umask 022
ls -l /srv/vaultwarden/.env /srv/vaultwarden/admin-passphrase.txt
grep -c '^ADMIN_TOKEN=' /srv/vaultwarden/.env
```
You should see: two files at `-rw-------`, and `1` from the grep. Your passphrase is in
/srv/vaultwarden/admin-passphrase.txt. Read it once, with
`cat /srv/vaultwarden/admin-passphrase.txt`, put it in your new vault after step 7, then
delete the file.
If you do not: `0` from the grep means the hashing step produced nothing, which almost
always means the image pull failed. Run the `docker run` line on its own, read the error,
and paste the block again. A `.env` containing the literal text `<DOMAIN>` means you pasted
before substituting: delete it and start this step over, nothing else has happened yet.
Do not paste `.env`, the passphrase, the hash, or any command output containing them into
this chat window. Nothing in the rest of this guide needs any of those values, and once one
is in a transcript it is somebody else's copy of the key to your vault.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/vaultwarden/compose.yml <<'EOF'
# Vaultwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose shape ... https://github.com/dani-garcia/vaultwarden/wiki/Using-Docker-Compose
# reverse proxy ... https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples
# admin token ..... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page
# websockets ...... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
#
# One container, and no proxy service: the Caddy that Prompt Zero installed under
# systemd terminates TLS and reaches this one on loopback. SQLite keeps the vault
# and the attachments under /data, so that directory is the whole backup. Since
# 1.31.0 WebSocket traffic rides the main HTTP port. Tag and digest are the 1.37.1
# release read from the Docker Hub registry API on 2026-08-05, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
vaultwarden:
image: vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN}
# Opened for one step during the install, then closed and asserted closed.
SIGNUPS_ALLOWED: "false"
# Argon2 PHC hash made on the server. Every "$" is stored as "$$" in .env.
ADMIN_TOKEN: ${ADMIN_TOKEN}
volumes:
- /srv/vaultwarden/data:/data
ports:
# Loopback only, and 8222 never enters the firewall.
- "127.0.0.1:8222:80"
EOF
cd /srv/vaultwarden && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `variable is not set` for `ADMIN_TOKEN` or `DOMAIN` means step 3 did not
finish, so go back. `services must be a mapping` means the indentation was lost between the
page and your terminal: run `rm /srv/vaultwarden/compose.yml` and paste again in one go.
Notice where that port goes. `127.0.0.1:8222:80` publishes the app on loopback only, so the
Caddy already running on this machine is the one thing that can reach it and there is no
public port for you to forget about later.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Nothing here starts
a second Caddy: the one under systemd is already holding 80 and 443, and it is the one that
gets your certificate. Replace `<DOMAIN>` in the block with your hostname before you paste.
The first line takes a copy, because a syntax error here takes down every other site on the
box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-vaultwarden
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Vaultwarden · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples,
# https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed under
# systemd, with <DOMAIN> replaced by the hostname pointed at this box. 8222 is the
# loopback port compose publishes; it is not open in the firewall. The X-Real-IP
# header_up is upstream's, and Vaultwarden's login rate limiter reads it.
#
# No WebSocket route, and none is missing: 3012 was removed in 1.31.0 and that
# traffic rides the main HTTP port, which reverse_proxy upgrades on its own.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
}
reverse_proxy 127.0.0.1:8222 {
header_up X-Real-IP {remote_host}
}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-vaultwarden /etc/caddy/Caddyfile`,
reload, and paste again, checking that the blank line from the second command really
landed. A complaint about a brace or a tab means the block did not survive the paste. Caddy
asks Let's Encrypt for the certificate on the first request to your hostname and renews it
on its own, so there is nothing to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8222`.
If you do not: a rule for `8222` from an earlier attempt should go, with
`sudo ufw delete allow 8222`. 8222 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would cover traffic that cannot
arrive. 80/tcp is there to redirect to HTTPS and answer the ACME challenge, 443/tcp is the
only way in, and 443/udp is HTTP/3.
## 7. Start and verify
```bash
cd /srv/vaultwarden
docker compose pull
docker compose up -d
sleep 15
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
You should see: `200`. That path opens the database to answer, so a 200 means the container
and the vault file are both good.
If you do not: `000` means the certificate is not there yet, and DNS is the usual reason.
Run `dig +short <DOMAIN>` once more, then `sudo journalctl -u caddy -n 30` to watch the
ACME attempt; a record created a few minutes ago makes the first attempt fail and retry
quietly. `502` means Caddy is up but the app is not, so read
`docker compose logs --tail 30 vaultwarden`.
Registration is off in the compose file, which is right for every day except this one. Turn
it on for exactly one step:
```bash
cd /srv/vaultwarden
sed -i 's/SIGNUPS_ALLOWED: "false"/SIGNUPS_ALLOWED: "true"/' /srv/vaultwarden/compose.yml
docker compose up -d --force-recreate vaultwarden
sleep 10
```
You should see: `Recreated`. Now open https://<DOMAIN> in a browser. The first screen shows
a `Log in` heading and a `Create account` link. Create your account with `<ADMIN_EMAIL>`
and sign in.
If you do not: no `Create account` link means the recreate did not happen. Run
`grep SIGNUPS_ALLOWED /srv/vaultwarden/compose.yml`, confirm it reads `"true"`, and run the
recreate line again.
Now close it again, and prove it is closed:
```bash
cd /srv/vaultwarden
sed -i 's/SIGNUPS_ALLOWED: "true"/SIGNUPS_ALLOWED: "false"/' /srv/vaultwarden/compose.yml
docker compose up -d --force-recreate vaultwarden
sleep 10
grep 'SIGNUPS_ALLOWED' /srv/vaultwarden/compose.yml
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
You should see: `SIGNUPS_ALLOWED: "false"`, then `200`. Then reload https://<DOMAIN> in a
fresh private window and confirm the `Create account` link is gone.
If you do not: a `Create account` link that is still there means the container was restarted
rather than recreated, so run the second line again exactly as written. Do not skip this. A
password server that anybody on the internet can register an account on is a password
server for anybody on the internet, and a container showing as running proves none of this.
## 8. First backup and restore
Do this now, before you put a single password in, so you find out today whether it works.
Stop first: a SQLite file copied mid-write is not a backup.
```bash
cd /srv/vaultwarden
docker compose stop
tar -C /srv/vaultwarden -czf /srv/vaultwarden/backups/vaultwarden-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/vaultwarden/backups/
```
You should see: one `.tar.gz` file, tens of kilobytes on a fresh install. The site is down
for about five seconds while this runs.
If you do not: `tar: data: Cannot open` means the `cd` did not happen. A size of `45` bytes
means tar wrote an empty archive, so check `ls -la /srv/vaultwarden` before you trust it.
A backup on the same disk as your vault is not a backup. Run this one on your own machine,
not the server:
```bash
mkdir -p ~/backups/vaultwarden
scp vps:/srv/vaultwarden/backups/*.tar.gz ~/backups/vaultwarden/
```
You should see: one file copied, and the same file listed by
`ls -lh ~/backups/vaultwarden/`.
If you do not: `Permission denied (publickey)` means you ran it on the server by mistake.
The `vps:` prefix only means something on your own machine.
Now prove the restore, today, while the only thing at risk is an empty vault:
```bash
cd /srv/vaultwarden
docker compose down
sudo rm -rf /srv/vaultwarden/data
tar -C /srv/vaultwarden -xzf /srv/vaultwarden/backups/vaultwarden-$(date +%F).tar.gz
docker compose up -d
sleep 15
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/alive
```
You should see: `200`, and your account still works when you sign in.
If you do not: a login page that rejects your password after a restore means the archive
did not contain `data/db.sqlite3`, so go back to the tar step. Those four commands are the
whole disaster plan, and you have now run them once. One thing the archive does not hold:
the certificate, which belongs to the host Caddy under /var/lib/caddy, so a restore onto a
fresh machine asks Let's Encrypt for a new one.
## 9. Updating later
New versions are listed at https://github.com/dani-garcia/vaultwarden/releases. Take a
backup first. Run `docker pull` on the new tag by hand once: the `Digest: sha256:` line it
prints is what goes after the `@` in the `image:` line of /srv/vaultwarden/compose.yml.
Edit the tag and the digest together, then:
```bash
cd /srv/vaultwarden
docker compose pull
docker compose up -d
docker compose logs --tail 20 vaultwarden
```
You should see: `Recreated`, then startup lines and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Vaultwarden
migrates its own database on start, so read that log rather than assuming. Caddy is patched
by `apt-get upgrade` on its own schedule and is not part of this step.
## 10. What will probably go wrong
The admin token, and it fails silently. The hash Vaultwarden's own generator prints is full
of `$` characters, and docker compose expands `$` inside `.env` values, so a hash stored
unescaped reaches the container with pieces missing. Nothing errors. The container starts,
the vault works, and /admin refuses the correct passphrase forever. I lost twenty minutes
to that before thinking to compare lengths. Step 3 doubles every `$` for that reason; if
you ever hand-edit that line, or move `.env` between machines through an editor that
helpfully unescapes things, look here first.
## 11. Out of scope
- Do not configure SMTP. Password hint mail is not worth a port 25 argument on a fresh VPS.
- Do not switch the database to PostgreSQL. SQLite is why the whole vault is one directory.
- Do not enable Bitwarden push notifications. They need keys issued by Bitwarden,
separately.
- Do not add a reverse proxy container, and do not touch any global options block in
/etc/caddy/Caddyfile. This install appends one site block and nothing else.agent-readable mirror: /self-host/1password.md
The files, if you'd rather do it yourself
The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.
compose.ymlthe services, pinned31 lines
# Vaultwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose shape ... https://github.com/dani-garcia/vaultwarden/wiki/Using-Docker-Compose
# reverse proxy ... https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples
# admin token ..... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page
# websockets ...... https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
#
# One container, and no proxy service: the Caddy that Prompt Zero installed under
# systemd terminates TLS and reaches this one on loopback. SQLite keeps the vault
# and the attachments under /data, so that directory is the whole backup. Since
# 1.31.0 WebSocket traffic rides the main HTTP port. Tag and digest are the 1.37.1
# release read from the Docker Hub registry API on 2026-08-05, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
vaultwarden:
image: vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN}
# Opened for one step during the install, then closed and asserted closed.
SIGNUPS_ALLOWED: "false"
# Argon2 PHC hash made on the server. Every "$" is stored as "$$" in .env.
ADMIN_TOKEN: ${ADMIN_TOKEN}
volumes:
- /srv/vaultwarden/data:/data
ports:
# Loopback only, and 8222 never enters the firewall.
- "127.0.0.1:8222:80"Caddyfilethe hostname and TLS28 lines
# Vaultwarden · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples,
# https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed under
# systemd, with <DOMAIN> replaced by the hostname pointed at this box. 8222 is the
# loopback port compose publishes; it is not open in the firewall. The X-Real-IP
# header_up is upstream's, and Vaultwarden's login rate limiter reads it.
#
# No WebSocket route, and none is missing: 3012 was removed in 1.31.0 and that
# traffic rides the main HTTP port, which reverse_proxy upgrades on its own.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
}
reverse_proxy 127.0.0.1:8222 {
header_up X-Real-IP {remote_host}
}
}install.shthe same install, no agent167 lines
#!/usr/bin/env bash
# Vaultwarden · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=vault.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/dani-garcia/vaultwarden/wiki/Using-Docker-Compose
# https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page
# https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples
# https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications
#
# Two secrets are generated here, on this machine: the admin-page passphrase and
# the Argon2 PHC hash of it that the server stores. Neither is printed.
#
# TLS is not this script's job. The Caddy that Prompt Zero installed under systemd
# terminates it and reaches the container on 127.0.0.1:8222, so this script
# appends one site block to /etc/caddy/Caddyfile and opens no application port.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/vaultwarden}"
IMAGE="vaultwarden/server:1.37.1-alpine@sha256:b094afed4ed5ea353821c6efcedca446f30c6654ba2bc441db6089b0c2b94ac8"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. vault.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
# The DNS record has to exist before Caddy asks for a certificate, or the request
# fails and you burn a Let's Encrypt rate-limit slot learning that.
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/data" "$APP_DIR/backups"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Neither secret has ever existed anywhere else: not in the prompt, not in a chat
# window, not in this repository.
#
# Secret 1: the admin-page passphrase. You keep this one.
# Secret 2: the Argon2 PHC hash of it, which is what the server stores. The
# vaultwarden binary generates it; `hash` prompts twice, so it is fed
# the same value twice on stdin.
if [ ! -f "$APP_DIR/.env" ]; then
ADMIN_PASSPHRASE="$(openssl rand -base64 33)"
hash_passphrase() {
printf '%s\n%s\n' "$1" "$1" \
| docker run --rm -i "$IMAGE" /vaultwarden hash --preset owasp \
| grep -o '\$argon2[^ ]*' \
| tail -n 1
}
ADMIN_TOKEN="$(hash_passphrase "$ADMIN_PASSPHRASE")"
[ -n "$ADMIN_TOKEN" ] || die "could not generate the admin token hash"
# docker compose expands "$" inside .env values, and an Argon2 PHC string is
# full of them. Escaping every "$" as "$$" is the single most common thing
# people get wrong here.
ADMIN_TOKEN_ESCAPED="$(printf '%s' "$ADMIN_TOKEN" | sed 's/[$]/$$/g')"
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
DOMAIN=https://${DOMAIN_HOST}
ADMIN_TOKEN=${ADMIN_TOKEN_ESCAPED}
ENVFILE
printf '%s\n' "$ADMIN_PASSPHRASE" > "$APP_DIR/admin-passphrase.txt"
chmod 600 "$APP_DIR/.env" "$APP_DIR/admin-passphrase.txt"
umask 022
unset ADMIN_PASSPHRASE ADMIN_TOKEN ADMIN_TOKEN_ESCAPED
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-vaultwarden"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and 8222 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8222 stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
docker compose pull
docker compose up -d
# --- 7. Prove it works before claiming it does -------------------------------
#
# /alive opens the database to answer, so a 200 means both the container and the
# vault file are good. A green `docker ps` proves neither.
echo "==> waiting for https://${DOMAIN_HOST}/alive (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/alive" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/alive answered ${code:-nothing}. Check: docker compose logs vaultwarden, and sudo journalctl -u caddy -n 30"
# --- 8. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup.
docker compose stop
tar -C "$APP_DIR" -czf "$APP_DIR/backups/vaultwarden-$(date +%Y%m%d-%H%M%S).tar.gz" data .env
docker compose start
ls -lh "$APP_DIR/backups/"
cat <<-DONE
Vaultwarden is running at https://${DOMAIN_HOST}/
1. Registration is CLOSED, which is the state this install ships in, so
open it only for as long as it takes you to register:
cd $APP_DIR
sed -i 's/SIGNUPS_ALLOWED: "false"/SIGNUPS_ALLOWED: "true"/' compose.yml
docker compose up -d --force-recreate vaultwarden
2. Open https://${DOMAIN_HOST}/ and create your account. The first screen
shows a "Log in" heading and a "Create account" link.
3. Close it again, then reload the page and confirm the "Create account"
link is gone. That check is the one with security meaning:
sed -i 's/SIGNUPS_ALLOWED: "true"/SIGNUPS_ALLOWED: "false"/' compose.yml
docker compose up -d --force-recreate vaultwarden
4. Point the official Bitwarden apps and browser extensions at
https://${DOMAIN_HOST} using their self-hosted server setting.
5. The admin page passphrase is in $APP_DIR/admin-passphrase.txt. Put it in
your new vault, then delete that file.
6. First backup written to $APP_DIR/backups. It is on the same disk as the
data, which is not a backup. Copy it off the box tonight with
scp vps:$APP_DIR/backups/*.tar.gz ~/backups/vaultwarden/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel 1Password.
- You own the backups. The whole vault is one SQLite file plus attachments under /srv/vaultwarden/data — if that directory is not copied off the box, a dead disk is a dead vault, and nobody is going to email you about it.
- You own the uptime. When the server is down you cannot reach new passwords from a browser you have not used before. The Bitwarden apps keep a local copy, so a short outage is an inconvenience rather than a lockout — but it is your inconvenience now.
- Vaultwarden is an independent reimplementation of the Bitwarden server API. It is not built or supported by Bitwarden, and upstream client updates occasionally land before Vaultwarden has caught up.
- Turn signups off after you create your account. A password server that anyone on the internet can register on is a password server for anyone on the internet.
- This one is not optional the way the others are: keep the recovery path for your own account somewhere that is not on this server.
Where this came from
“There is a PHC generator built-in into Vaultwarden”
- Vaultwarden's own compose example runs a single container that serves on port 80 and keeps every byte of state in /data. source
- Upstream's Caddy example reverse-proxies to Vaultwarden on a loopback port and forwards the real client IP with header_up X-Real-IP. source
- Support for WebSocket traffic on port 3012 was removed in 1.31.0 and integrated on the main HTTP port, so a reverse proxy needs no second route. source
- The admin page token is an Argon2 PHC string produced by the vaultwarden binary itself, not a password you invent. source
- Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host 1Password?
Not 1Password itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Vaultwarden. A Bitwarden-compatible password server small enough to run on the cheapest VPS you can rent. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces 1Password?
Vaultwarden. A Bitwarden-compatible password server small enough to run on the cheapest VPS you can rent. The only one on this list that speaks the Bitwarden protocol, so the official Bitwarden apps and browser extensions on every platform your household already uses point at your server without knowing the difference. That client story is the whole reason a self-hosted vault is usable by people who did not choose to self-host. Vaultwarden is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to 1Password?
512 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Vaultwarden itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: 1Password Families, $5.99/mo — $71.88 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Vaultwarden install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.