# Can I self-host 1Password?

**YES** — it's called Vaultwarden. ONE EVENING setup · ~1.5 hours to running · 512 MB RAM minimum · $5.99/mo you stop paying ($71.88/yr on the Families plan).

Vaultwarden authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/1password/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## docker-compose.yml

```yaml
# 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"
```

## Caddyfile

```text
# 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.sh

```bash
#!/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/

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/1password/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
