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.
375 lines
19 KiB
Markdown
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.
|