Files
playbooks/bitwarden/readme.md
T
tanshu 9bf2cbd7a9 Run Bitwarden as a rootless Podman Quadlet
Replaces the docker_container role on monoco with a Quadlet systemd user unit on
emily, and moves state from /var/lib/bitwarden to ~/podman-data/bitwarden.
Postgres is authoritative for the vault and already moved with the cluster
restore, so only the file data comes across -- and the stale db.sqlite3 that has
sat beside it for years is deliberately excluded from that copy. The role warns
if it finds the file in the data directory rather than failing, because with
DATABASE_URL set it is inert but looks exactly like a restore source.

The image runs as container-root and writes /data as root, so
UserNS=keep-id:uid=0,gid=0 maps that onto the connecting user and leaves nothing
owned by a subuid.

Both integrations go through the shared includes rather than local copies --
caddy/roles/register for the network and the Caddy drop-in, postgres/roles/attach
for the database -- so this app writes the same drop-in bytes as every other
service and a new service never means editing Caddy or Postgres. That is also
what lets the Docker-era roles/network go: it existed to attach an
already-created container to a network (docker_network's connected:/appends:),
which podman_network has no equivalent for. The dead files/nginx.conf.j2 goes
with it -- Caddy handles the WebSocket upgrade and imposes no request-body limit,
so its 525M client_max_body_size needs no replacement.

Three ordering details are load-bearing:

- The register include runs in pre_tasks, before the unit is started. Quadlet
  fails unit *generation*, not merely startup, when a referenced .network file
  does not exist yet, so starting first reports "unit not found".
- roles/postgres flushes handlers immediately after attaching. The restart that
  joins Postgres to this network is a handler, so by default it runs at the end
  of the play, after the app has already started and failed to resolve the
  database.
- The play waits on the image's own health check (podman healthcheck run) rather
  than on systemctl is-active. Vaultwarden runs its Diesel migrations before
  Rocket answers, and Restart=always means even a crash-looping container is
  reported active.

DATABASE_URL and DOMAIN move into a 0600 environment file read through
EnvironmentFile=, since the Quadlet unit is world-readable and DATABASE_URL
carries the password. The credentials are unchanged, so the restored database
connects with no reset. readme.md carries the monoco -> emily file copy command
by command.
2026-10-16 07:20:52 +05:30

375 lines
19 KiB
Markdown

# Vaultwarden / Bitwarden (rootless Podman + Quadlet)
Runs [Vaultwarden](https://github.com/dani-garcia/vaultwarden) — the
Bitwarden-compatible server this repo has always called `bitwarden` — on `emily`
as a **rootless Podman** container managed by a **systemd user unit** (Quadlet),
behind the shared [rootless Caddy](../caddy/readme.md), with the vault in the
shared [rootless Postgres](../postgres/readme.md). Replaces the
`docker_container` deployment on `monoco`.
The name is split on purpose: the directory, container, unit, Podman network and
Postgres database are all `bitwarden`, as they have been since the Docker-era
playbook, and only the image is `vaultwarden/server`. Renaming it would touch the
Caddyfile upstream, the database and every client's saved server URL, for no
gain.
**Postgres is authoritative for the vault.** `/data` holds everything else —
attachments, Sends, the `rsa_key*` signing pair, the admin config, the icon
cache — and the `db.sqlite3` that the image would use by default is **stale on
the old host and deliberately not migrated**
([docs/backup-plan.md §3.3](../docs/backup-plan.md)). The playbook warns if it
finds that file in the data directory.
## Ordering
Both shared services are already migrated and running on `emily`:
1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns
the Caddyfile this site appends to, supplies the `caddy.service` handlers, and
is the one play that applies the rootless-Podman prerequisites.
2. [postgres/playbook.yml](../postgres/playbook.yml) — deployed, and its cluster
brought across from monoco. **The `bitwarden` database is already restored**
(10 MB, one of the 12 databases in
[postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md)).
3. This playbook — **run it after the file copy below**, not before:
the app is the thing that writes to `~/podman-data/bitwarden`, and its first
start generates `rsa_key*` if the copy has not landed yet.
Vaultwarden's environment file points `DATABASE_URL` at `postgres`. Until this
playbook has attached `bitwarden_net` to Postgres, that name does not resolve on
this network, so running this playbook is what makes the app reachable — there is
no separate step.
## Layout
```
bitwarden/
├── playbook.yml
├── vars/default.yml
└── roles/
├── postgres/ # attaches this app's network to Postgres (+ its handler)
├── bitwarden/ # data dir, the 0600 env file, the Quadlet unit, start + verify
└── caddy/ # appends this site's block to the shared Caddyfile
```
Quadlet units and state:
| On the host | What |
|---|---|
| `~/.config/containers/systemd/bitwarden.container` | the generated unit (Ansible-templated) |
| `~/.config/containers/systemd/bitwarden.network` | the app network, written by `caddy/roles/register` |
| `~/.config/containers/systemd/caddy.container.d/50-bitwarden.conf` | attaches Caddy to that network |
| `~/.config/containers/systemd/postgres.container.d/50-bitwarden.conf` | attaches Postgres to it |
| `~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf` | the shared `db` alias |
| `~/podman-data/bitwarden/data/` | the image's `/data`: `attachments/`, `sends/`, `rsa_key*`, `config.json`, `icon_cache/` |
| `~/podman-data/bitwarden/bitwarden.env` | `DATABASE_URL` + `DOMAIN`, mode `0600`, read by systemd |
## What is state, and what is not
Per the upstream wiki's
[Backing up your data](https://github.com/dani-garcia/vaultwarden/wiki/Backing-up-your-vault),
and with Postgres as the database, `data/` holds:
| Path | Keep? | Why |
|---|---|---|
| `attachments/` | **required** | the only important data not in database tables |
| `sends/` | copy it | Send attachments; ephemeral by design, but copying keeps existing Sends working |
| `rsa_key.pem`, `rsa_key.der`, `rsa_key.pub.*` | copy it | signs the JWTs of logged-in users. Regenerating the pair logs everyone out and invalidates open invitation tokens |
| `config.json` | copy it | admin-page configuration; only exists if the admin page was enabled |
| `icon_cache/` | optional | favicon cache, refetched on demand |
| `db.sqlite3`, `-wal`, `-shm` | **no** | stale. Postgres has been authoritative for years; restoring this would silently lose months of vault data |
## How it registers with Caddy
Caddy is the base unit and knows nothing about its upstreams. This playbook calls
the shared include in
[caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the
network unit **and** the drop-in that pulls it into Caddy:
```yaml
- name: Register Bitwarden's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
```
It runs in `pre_tasks`, **before** the app unit is started, because
`bitwarden.container` references `bitwarden.network`: Quadlet fails *unit
generation* — so `systemctl --user start` reports "unit not found" — when a
referenced `.network` file does not exist yet. The Postgres attach below depends
on it for the same reason.
`network_name` is `bitwarden`, so the *file* is `bitwarden.network` and the
*Podman network* is `bitwarden_net` (`NetworkName=` in the network unit pins it,
keeping the Docker-era name).
The Docker-era `roles/network` is **deleted rather than ported**. It existed to
attach an already-created container to a network — `docker_network`'s
`connected:`/`appends:` — which `podman_network` has no equivalent for
([docs/podman-compat-audit.md §3.3](../docs/podman-compat-audit.md)). Nothing has
to be connected after the fact now: the app's own unit names its `Network=`, and
Caddy and Postgres learn about the network from the drop-ins written in the same
run.
The site block itself is appended to the shared Caddyfile by
[roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the
other services do — only `caddyfile_path` changed, from
`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile`. Caddy
handles the WebSocket upgrade the Docker-era nginx front end had to configure by
hand, and it imposes no request-body limit, so the `client_max_body_size 525M` in
the deleted `files/nginx.conf.j2` has no equivalent and needs none.
## How it attaches to Postgres
Same idea at the other end: Postgres knows nothing about its clients, and this
playbook uses the shared include in
[postgres/roles/attach](../postgres/roles/attach/tasks/main.yaml) rather than
writing those drop-ins itself. That writes two files, both mergeable into
`postgres.container` by Quadlet:
| File | Content |
|---|---|
| `postgres.container.d/50-bitwarden.conf` | `Network=bitwarden.network` |
| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` |
`DATABASE_URL` uses the host `postgres`, which is what the Docker-era `db_url`
used and what `ContainerName=postgres` provides; the shared `db` alias resolves
too, and the play checks both.
[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for two
reasons:
- it defines the `Restart Postgres` handler the include notifies; and
- it **flushes handlers** immediately afterwards. On a first attach the drop-in
is new, so Postgres has to be *recreated* to join `bitwarden_net` — and that
restart is a handler, which by default runs at the end of the play, after the
`bitwarden` role has already started the app. The app would then come up
before `postgres` resolves, fail its own health check, and fail the play on a
machine where nothing is wrong. Flushing applies the drop-in first; on later
runs nothing is pending and it is a no-op.
Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new
drop-in recreates Postgres, which is a few seconds of downtime for **every**
attached app, not just this one.
## Settings
| Variable | Value / purpose |
|---|---|
| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand |
| `podman_data_dir` | `~/podman-data` |
| `quadlet_dir` | `~/.config/containers/systemd` |
| `host` | `vault.tanshu.com` — the site block Caddy serves |
| `container_name` | `bitwarden`, and the Quadlet unit / container name |
| `image` | `docker.io/vaultwarden/server:latest` — fully qualified on purpose |
| `network_name` | `bitwarden` → `bitwarden.network` / `bitwarden_net` |
| `data_dir` | `~/podman-data/bitwarden/data` → the image's `/data` |
| `container_port` | `80`, the port inside the container that Caddy proxies to (`ROCKET_PORT`) |
| `env_file` | `~/podman-data/bitwarden/bitwarden.env`, mode `0600` |
| `postgres_container` / `postgres_unit` | The shared database unit this app attaches to |
| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database name — `bitwarden` |
| `database_url` / `domain` | What the environment file contains |
| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile |
The three `podman_*` / `quadlet_dir` vars are **duplicated** from
[caddy/vars/default.yml](../caddy/vars/default.yml), as in every service
playbook. That is a deliberate trade: a play cannot include the Caddy vars file
wholesale, because it also sets `host_directory` (to the *Caddy* data directory)
and every service defines that for itself. Keep them in sync. Caddy's fourth,
`podman_unprivileged_port_start`, belongs to the podman tasks this play does not
run — Vaultwarden publishes nothing host-side.
Like Caddy, `podman_user` is derived from the connection and the play carries no
`become`. Because the podman tasks are not run here, there is **no guard against
connecting as `root`**; the prerequisites, including that check, live in
`caddy/playbook.yml`.
`UserNS=keep-id:uid=0,gid=0` — Vaultwarden's image runs as container-root and
writes `/data` as root, with no entrypoint step that chowns anything
([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). Mapping
container root onto `podman_user` keeps `~/podman-data/bitwarden` owned by
`tanshu` and leaves no subuid-owned files to trip over backups.
## Running
```command
ansible-playbook -u tanshu bitwarden/playbook.yml
```
The play targets `emily` directly, so the inventory must contain it — the one in
`prepare-server/` does, and its `ansible.cfg` names it.
This play applies **only** this app's units and data. Its `pre_tasks` run Caddy's
`register` include and nothing else. The rootless-Podman prerequisites —
installing podman, `loginctl enable-linger`, the low-port sysctl and the Quadlet
directory — are the shared task file at
[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and
are **not** run here. A host that has already run `caddy/playbook.yml` has them.
`systemctl --user enable bitwarden` is neither needed nor possible: the unit is
*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself.
## Migrating the file data from monoco
The database already moved — `bitwarden` is one of the 12 databases restored in
[postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). **This is
the app's own files only**, and the playbook does not move them because they are
on the old host while the playbook runs on the new one. Run this from the machine
that holds the `monoco` and `emily` SSH aliases.
The whole tree is about **4 MB** ([docs/backup-plan.md §2.1](../docs/backup-plan.md)),
so this is quick — the state that matters is the 10 MB Postgres database, and that
is done. Copy it anyway: `attachments/` is the part of the vault that is not in
any database.
```bash
# 1. Look before you copy: size, contents, and whether it is readable without
# sudo. `ls -ln` shows numeric owners, which is what matters here (the old
# container ran as root, so these files are uid 0).
ssh monoco 'du -sh /var/lib/bitwarden/data; ls -ln /var/lib/bitwarden/data'
ssh monoco 'find /var/lib/bitwarden/data -type f ! -readable' # empty = no sudo needed
# 2. Stop the old container so nothing writes while you copy. Leave it stopped:
# its database is on emily now, and two live copies would diverge.
ssh monoco 'docker stop bitwarden'
# 3. Create the destination (this is the mount target of the image's /data).
ssh emily 'mkdir -p ~/podman-data/bitwarden/data'
# 4. Copy. tar streams it across the workstation; rsync cannot copy
# remote-to-remote in one command.
#
# `./db.sqlite3*` is excluded — main file, -wal and -shm. It is stale on
# monoco (see the table above), and with Postgres configured it would be an
# inert file that looks exactly like a restore source.
#
# `set -o pipefail` matters here: without it the pipeline reports only the
# *receiving* tar's status, so a read error or a dropped stream on the source
# side exits 0 and leaves a silently short copy.
set -o pipefail
ssh monoco 'tar --exclude="./db.sqlite3*" -C /var/lib/bitwarden/data -cf - .' \
| ssh emily 'tar --no-same-owner -C ~/podman-data/bitwarden/data -xf -'
# If step 1 listed unreadable files, read them as root instead:
# ssh monoco 'sudo tar --exclude="./db.sqlite3*" -C /var/lib/bitwarden/data -cf - .' \
# | ssh emily 'tar --no-same-owner -C ~/podman-data/bitwarden/data -xf -'
# 5. Verify by content, over the whole tree, with the same exclusion on the
# source side. The paths are relative on both sides, so the digests are
# comparable.
ssh monoco 'cd /var/lib/bitwarden/data && find . -type f ! -name "db.sqlite3*" -print0 | sort -z | xargs -0 -r sha256sum | sha256sum'
ssh emily 'cd ~/podman-data/bitwarden/data && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum'
# identical digests = a byte-faithful copy
# 6. Nothing may end up owned by root or by a subuid.
ssh emily 'find ~/podman-data/bitwarden ! -user tanshu' # empty = correct
# 7. Deploy — after the copy, so Vaultwarden finds the real data on first start
# rather than generating a fresh rsa_key pair.
cd prepare-server && ansible-playbook -u tanshu ../bitwarden/playbook.yml
```
Ownership needs no `chown` pass: the container runs as uid 0 in-container, which
`keep-id` maps to `tanshu`, so files extracted as `tanshu` are already right.
That is also why `--no-same-owner` is explicit on the receiving side — the source
is root-owned, and restoring that ownership would be wrong here.
If the link is slow or drops, do it in two hops instead: resumable, and it shows
progress.
```bash
rsync -aP --exclude='db.sqlite3*' monoco:/var/lib/bitwarden/data/ ~/bitwarden-copy/
rsync -aP ~/bitwarden-copy/ emily:~/podman-data/bitwarden/data/
```
Rolling back means rolling back both halves: `ssh monoco 'docker start bitwarden'`
is only meaningful while monoco's own Postgres 17 cluster is still intact, and it
would then be writing to a database that emily's copy has already moved past.
## Verification
```bash
systemctl --user status bitwarden
journalctl --user -u bitwarden -f
podman logs bitwarden
# What the playbook checks for you:
podman healthcheck run bitwarden # the image's own probe
podman exec bitwarden getent hosts postgres
podman exec bitwarden getent hosts db
# The database really is the migrated one. Run the same query on both hosts and
# compare — equal counts mean the vault came across.
ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d bitwarden -tAc "select count(*) from users"'
ssh monoco 'docker exec -e PGPASSWORD=123456 postgres psql -U postgres -d bitwarden -tAc "select count(*) from users"'
# The data directory is the copied one, and has no SQLite file in it.
ssh emily 'ls -ln ~/podman-data/bitwarden/data'
ssh emily 'test ! -e ~/podman-data/bitwarden/data/db.sqlite3 && echo "no stale SQLite"'
# The site, end to end.
curl -sSI https://vault.tanshu.com
```
Then log in **in a browser** and confirm an existing item decrypts — that is the
check that the Postgres database and the client-facing crypto are both intact,
and a `curl` cannot make it. The play waits on the image's own health check
(`/healthcheck.sh` → `/alive`), which is stronger than `is-active` but still only
says the process answers.
Also exercise a **write** through the app — upload a file attachment — and confirm
on the host that the new file under
`~/podman-data/bitwarden/data/attachments` is owned by `tanshu`. That is the check
that catches a wrong `keep-id`; a read-only smoke test will not.
Then reboot the host and confirm the unit returns unattended — without
`loginctl enable-linger tanshu` it will not, and the failure is silent.
## Notes and gotchas
- **The stale SQLite file.** If `~/podman-data/bitwarden/data/db.sqlite3` exists,
the play prints a warning on every run rather than failing, because the file is
inert while `DATABASE_URL` is set. It is not a backup. Delete it once the
Postgres database has been verified.
- **Signups are open.** The Docker-era container set only `DATABASE_URL` and
`DOMAIN`, so `SIGNUPS_ALLOWED` is still the image default (`true`), the admin
panel is off (no `ADMIN_TOKEN`), and SMTP is unconfigured. This playbook keeps
that behaviour deliberately — tightening it is a decision, not a migration
step.
- **Client IPs.** Vaultwarden uses the client address for its login
brute-force protection, and whether the original address survives the rootless
port forward depends on the forwarder. See
[caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it.
- **`latest` floats.** The old playbook used the unqualified
`vaultwarden/server:latest`; this one fully qualifies the same tag, because
rootless Podman has no TTY to answer a short-name prompt when the unit starts.
Neither pins a digest or a version, so an image bump is a normal `podman pull`
away and may run new Diesel migrations on first start.
- **Registering restarts all of Caddy**, and attaching restarts Postgres for
everyone — a brief restart of every hosted site, and a few seconds of downtime
for every attached app. See the trade-offs in
[caddy/readme.md](../caddy/readme.md) and
[postgres/readme.md](../postgres/readme.md).
- **If the Caddyfile does not exist yet**, this playbook creates it containing
only the site block. That is a valid Caddyfile; the Caddy playbook's seeded
global-options block is then not added, because its seed only fires on a
missing or zero-length file. Deploy Caddy first on a fresh host.
- **DNS.** `vault.tanshu.com` must resolve to `emily` before Caddy can obtain a
certificate for it.
- **The backup scripts still assume Docker.** `~/bin/bitwarden-backup.sh` uses
`--link`, which Podman does not have; it needs the `podman exec` form from
[postgres/readme.md](../postgres/readme.md), "Backups". `bitwarden-upload.sh`
is worse than broken — it uploads a `production.sqlite3` that does not exist,
and the file it would upload is not the authoritative store anyway. Also,
root's crontab cannot `podman exec` into another user's rootless containers, so
anything scheduled belongs in a **systemd user timer** next to the Quadlet
units.
- **Do not restart the container on monoco.** It would write to monoco's old
cluster, which is being kept intact purely as a rollback.
## Not done here
- Rotating the secrets that are committed in plaintext — `pg_password` is the
fleet's shared `123456`, and it lives in `bitwarden.env` at mode `0600` rather
than in the world-readable unit file.
- Closing signups, enabling the admin page, or configuring SMTP (see above).
- Replacing the two broken backup scripts with a systemd user timer.