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.
This commit is contained in:
tanshu committed 2026-10-16 07:20:52 +05:30
1 parent 4cd9308ae3
commit 9bf2cbd7a9
13 files changed
+787 -140

No files matched your search

-42
View File
@@ -1,42 +0,0 @@
# The `upstream` directives ensure that you have a http/1.1 connection
# This enables the keepalive option and better performance
#
# Define the server IP and ports here.
upstream vaultwarden-default {
zone vaultwarden-default 64k;
server localhost:8080;
keepalive 2;
}
# Needed to support websocket connections
# See: https://nginx.org/en/docs/http/websocket.html
# Instead of "close" as stated in the above link we send an empty value.
# Else all keepalive connections will not work.
map $http_upgrade $connection_upgrade {
default upgrade;
'' "";
}
server {
listen 80;
server_name {{ http_host }};
# Allow large attachments
client_max_body_size 525M;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://vaultwarden-default;
}
}
+46 -6
View File
@@ -1,13 +1,53 @@
#################################################
# Bitwarden Playbook
#################################################
--- ---
- hosts: monoco # Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet
become: true # unit, behind the shared Caddy unit (caddy/playbook.yml) and with its data in
# the shared Postgres unit (postgres/playbook.yml).
#
# Connects as the container user and runs everything as that user: the Quadlet
# units belong to it, so the connection's PAM session is what makes
# `systemctl --user` work without any environment juggling.
#
# gather_facts is required: podman_user and the home directory come from the
# user_id / user_dir facts.
#
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
# a host that has already run caddy/playbook.yml has them:
#
# - name: Rootless Podman prerequisites
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
#
# The variables this play needs are duplicated at the top of vars/default.yml, so
# it stands on its own.
#
# Run with: ansible-playbook -u tanshu bitwarden/playbook.yml
- name: Deploy Vaultwarden as a rootless Podman Quadlet
hosts: emily
gather_facts: true
vars_files: vars_files:
- vars/default.yml - vars/default.yml
pre_tasks:
# This must happen before the app unit is started, not after: the generated
# unit references {{ network_name }}.network, and Quadlet fails *unit
# generation* (so `systemctl --user start` reports "unit not found") when a
# referenced .network file does not exist yet. The same include writes the
# Caddy-side drop-in that attaches Caddy to this network, and the Postgres
# attach below needs the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
- name: Register Bitwarden's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles: roles:
- network # Attaches this app's network to the shared Postgres unit, which is what
# makes DATABASE_URL's host resolve, and owns the "Restart Postgres" handler
# that postgres/roles/attach notifies. Runs first so the database is
# reachable before the app starts.
- postgres
# The app: data directory, the 0600 environment file, the Quadlet unit,
# start + verify.
- bitwarden - bitwarden
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy - caddy
+362 -34
View File
@@ -1,46 +1,374 @@
# Docker on Ubuntu 18.04 # Vaultwarden / Bitwarden (rootless Podman + Quadlet)
This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on Runs [Vaultwarden](https://github.com/dani-garcia/vaultwarden) — the
[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). Bitwarden-compatible server this repo has always called `bitwarden` — on `emily`
A number of containers will be created with the options specified in the `vars/default.yml` variable file. 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 ## Settings
- `create_containers`: number of containers to create. | Variable | Value / purpose |
- `default_container_name`: default name for new containers. |---|---|
- `default_container_image`: default image for new containers. | `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand |
- `default_container_command`: default command to run on new containers. | `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.
## Running this Playbook 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`.
Quick Steps: `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.
### 1. Obtain the playbook ## Running
```shell
git clone https://github.com/do-community/ansible-playbooks.git
cd ansible-playbooks/docker_ubuntu1804
```
### 2. Customize Options
```shell
nano vars/default.yml
```
```yml
#vars/default.yml
---
create_containers: 4
default_container_name: docker
default_container_image: ubuntu
default_container_command: sleep 1d
```
### 3. Run the Playbook
```command ```command
ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml ansible-playbook -u tanshu bitwarden/playbook.yml
``` ```
For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). 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.
@@ -0,0 +1,20 @@
---
# All handlers run as the connection user ({{ podman_user }}), so systemctl
# --user works without setting XDG_RUNTIME_DIR.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
# A changed environment file or unit file changes the generated unit, so
# Vaultwarden is recreated. The vault itself is in Postgres and the attachments
# are on a bind mount, so a recreate loses nothing — but it is a few seconds of
# downtime for the one site.
- name: Restart Bitwarden
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Bitwarden
+133 -22
View File
@@ -1,27 +1,138 @@
--- ---
# Tasks for Bitwarden role # Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet
- name: Pull Bitwarden Vault Warden image # unit.
docker_image: #
name: "{{ image_name }}" # Everything here runs as {{ podman_user }} — the connection user. No become, no
source: pull # XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
force_source: yes # user manager is reachable through the standard environment.
- name: Ensure Bitwarden Directory exists # /data is the image's VOLUME, and it holds everything Vaultwarden owns that is
file: # not in Postgres: attachments/, sends/, the rsa_key signing pair, the admin
path: "/var/lib/{{ host_directory }}" # config and the icon cache. Postgres is authoritative for the vault itself
# (docs/backup-plan.md §3.3).
- name: Ensure the Vaultwarden data directory exists
ansible.builtin.file:
path: "{{ data_dir }}"
state: directory state: directory
mode: 0755 owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: Create bitwarden container # The app's secrets and database coordinates are consumed by systemd
docker_container: # (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
name: "{{ docker_container }}" # written before the unit starts. Mode 0600: DATABASE_URL carries the Postgres
image: "{{ image_name }}" # password.
- name: Write the environment file
ansible.builtin.template:
src: bitwarden.env.j2
dest: "{{ env_file }}"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0600'
notify: Restart Bitwarden
- name: Install the Vaultwarden Quadlet unit
ansible.builtin.template:
src: bitwarden.container.j2
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify:
- Reload systemd user units
- Restart Bitwarden
- name: Start Vaultwarden
block:
- name: Start Vaultwarden
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: started state: started
restart_policy: "unless-stopped" daemon_reload: true
env: rescue:
DATABASE_URL: "{{ db_url }}" # systemd reports "control process exited with error code" and nothing
DOMAIN: "https://{{ host }}" # else, which is useless for the usual failures (an unattached network, an
volumes: # unreachable database, a bad volume). Pull the real reason out.
- "/var/lib/{{ host_directory }}/data:/data/" - name: Collect Vaultwarden diagnostics
networks: ansible.builtin.command: "{{ item }}"
- name: "{{ docker_network }}" loop:
- "systemctl --user status {{ unit_name }} --no-pager"
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
- "podman logs --tail 40 {{ container_name }}"
register: bitwarden_diag
changed_when: false
failed_when: false
- name: Show Vaultwarden diagnostics
ansible.builtin.debug:
msg: "{{ bitwarden_diag.results | map(attribute='stdout') | list }}"
- name: Fail
ansible.builtin.fail:
msg: "Vaultwarden did not start — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
- name: Verify Vaultwarden is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: bitwarden_active
changed_when: false
failed_when: bitwarden_active.stdout != 'active'
# `is-active` says the container is running; it does not say the app can serve.
# Vaultwarden runs its Diesel migrations against Postgres before Rocket starts
# answering, and `Restart=always` means even a crash-looping container is
# reported active.
#
# `podman healthcheck run` executes the image's *own* HEALTHCHECK — which is
# /healthcheck.sh curling http://localhost:80/alive — so this depends on nothing
# the play has to know about: no curl, wget or python3 inside the image, and no
# hardcoded path that a later image tag could move.
- name: Wait for Vaultwarden to pass its own health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: bitwarden_health
until: bitwarden_health.rc == 0
retries: 24
delay: 5
changed_when: false
# The database is the whole vault, and it arrives over the network that
# roles/postgres attached Postgres to. Check the name the environment file
# actually asks for, plus the shared `db` alias.
- name: Confirm Vaultwarden can resolve its database
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ item }}
loop:
- "{{ postgres_container }}"
- db
register: bitwarden_db_lookup
changed_when: false
failed_when: false
- name: Report database resolution
ansible.builtin.debug:
msg: >-
{{ item.item }} resolves as
{{ item.stdout | default('(no answer)') }}
loop: "{{ bitwarden_db_lookup.results }}"
- name: Check for a stale SQLite database in the data directory
ansible.builtin.stat:
path: "{{ data_dir }}/db.sqlite3"
register: bitwarden_stale_sqlite
# SQLite in /data is the image's own default, and the Docker-era deployment left
# a stale db.sqlite3 (with -wal/-shm sidecars) behind long after DATABASE_URL had
# been pointed at Postgres. This deployment ignores the file entirely, which is
# exactly why it is dangerous: it looks like a restore source, and restoring it
# would silently lose months of vault data (docs/backup-plan.md §3.3). Warn
# rather than fail, because the file is inert while DATABASE_URL is set.
- name: Warn about the stale SQLite database
ansible.builtin.debug:
msg: >-
{{ data_dir }}/db.sqlite3 exists, but Postgres is the authoritative store
for this deployment and Vaultwarden is not reading it. It is not a backup
and must not be restored. Delete it once the Postgres database has been
verified — see readme.md, "Migrating the file data".
when: bitwarden_stale_sqlite.stat.exists
@@ -0,0 +1,45 @@
# Vaultwarden (Bitwarden-compatible server) — rootless Podman Quadlet unit.
# Managed by Ansible (bitwarden/roles/bitwarden). Do not edit by hand.
#
# This unit joins {{ network_name }}.network, which is created by
# caddy/roles/register (the same include that attaches Caddy to it). The
# postgres container is joined to that network by postgres/roles/attach, which
# is what makes the host in DATABASE_URL resolve.
[Unit]
Description=Vaultwarden (Bitwarden-compatible server)
Documentation=https://github.com/dani-garcia/vaultwarden/wiki
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ container_name }}
Image={{ image }}
# Vaultwarden's image runs as container-root and writes /data as root, with no
# entrypoint step that chowns it (see docs/podman-compat-audit.md §3.2).
# Mapping container root onto {{ podman_user }} keeps {{ data_dir }} owned by
# {{ podman_user }} on the host, so plain tar/rsync backups keep working and no
# subuid-owned files appear.
UserNS=keep-id:uid=0,gid=0
# DATABASE_URL and DOMAIN live here rather than as Environment= lines above:
# this unit is world-readable in ~/.config, and DATABASE_URL carries the
# Postgres password. Read by systemd, not by Ansible, so the file has to exist
# before this unit starts.
EnvironmentFile={{ env_file }}
Network={{ network_name }}.network
# The image's VOLUME. Everything Vaultwarden owns outside Postgres — attachments,
# sends, the rsa_key signing pair, the admin config — lives here.
Volume={{ data_dir }}:/data
[Service]
Restart=always
# The first start runs Diesel migrations against Postgres before Rocket begins
# answering on port {{ container_port }}.
TimeoutStartSec=120
[Install]
WantedBy=default.target
@@ -0,0 +1,19 @@
# Environment for {{ container_name }}.container.
# Managed by Ansible (bitwarden/roles/bitwarden). Do not edit by hand.
#
# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable,
# and DATABASE_URL carries the Postgres password. Note that `podman inspect`
# still prints the resulting environment to anyone who can run podman as this
# user.
#
# Postgres is authoritative for the vault. The SQLite file the image would use
# by default ({{ data_dir }}/db.sqlite3) is stale on the old host and is
# deliberately not migrated — see readme.md, "Migrating the file data".
DATABASE_URL={{ database_url }}
DOMAIN={{ domain }}
# Deliberately not set, so this deployment behaves exactly as the Docker-era
# container did: SIGNUPS_ALLOWED is still the image default (true), the admin
# panel is off (no ADMIN_TOKEN), and SMTP is unconfigured. Tightening any of
# those is a behaviour change, not a migration step — see readme.md,
# "Notes and gotchas".
+28 -2
View File
@@ -1,5 +1,31 @@
--- ---
# Handlers for Caddy role # Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
# there. These handlers run as the connection user, so `systemctl --user` and
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
# Notified by this role when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site.
#
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
# not {{ caddyfile_path }}, which is the host side of it.
- name: Reload Caddy configuration - name: Reload Caddy configuration
ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy" listen: "Reload Caddy"
# Notified by caddy/roles/register when this service's network unit, or the
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
# of `docker network connect`, so the container has to be recreated — a brief
# restart of every hosted site, not just this one. See caddy/readme.md,
# "Trade-off worth knowing".
#
# Defined after the reload above so that when both are notified the cheap
# config reload happens first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
scope: user
state: restarted
daemon_reload: true
listen: Restart Caddy
+15 -15
View File
@@ -1,22 +1,22 @@
--- ---
- name: Read current Caddyfile # Append this site's block to the shared Caddyfile.
ansible.builtin.slurp: #
path: "{{ caddyfile_path }}" # The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
register: caddyfile_raw # change as soon as the file is written; the handler only asks Caddy to re-read
# it. That is a reload, not a restart, so the other hosted sites are untouched.
#
# blockinfile is idempotent on its own via the marker, so there is no need to
# slurp the file and compare first — which also matters because slurp *fails*
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
# not run.
- name: Decode Caddyfile content - name: "Add the {{ host }} site block to the Caddyfile"
set_fact:
caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}"
- name: Check if snippet already exists
set_fact:
snippet_present: "{{ snippet_block in caddyfile_content }}"
- name: Add snippet if missing
ansible.builtin.blockinfile: ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}" path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
block: "{{ snippet_block }}" block: "{{ snippet_block }}"
create: yes create: yes
when: not snippet_present owner: "{{ podman_user }}"
notify: "Reload Caddy" group: "{{ podman_user }}"
mode: '0644'
notify: Reload Caddy
-9
View File
@@ -1,9 +0,0 @@
---
- name: "Ensure {{ docker_container }} Docker network exists"
docker_network:
name: "{{ docker_network }}"
state: present
connected:
- "{{ caddy_container }}"
- "{{ postgres_container }}"
appends: yes
@@ -0,0 +1,17 @@
---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, so `systemctl --user`
# works without setting XDG_RUNTIME_DIR.
#
# Required by postgres/roles/attach, which notifies it whenever this app's
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
# recreated to join the network — Quadlet has no live equivalent of
# `podman network connect`. That is a few seconds of downtime for every attached
# app, not just this one. See postgres/readme.md, "The trade-off".
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Postgres
+26
View File
@@ -0,0 +1,26 @@
---
# Attach this app's network to the shared Postgres unit.
#
# Postgres knows nothing about its clients, exactly like Caddy: a service joins
# by dropping 50-bitwarden.conf and the shared 10-network-aliases.conf next to
# postgres.container. The canonical way to do that is the shared include below —
# it writes the same bytes every service writes, asserts that bitwarden.network
# already exists, and notifies the "Restart Postgres" handler defined in this
# role.
#
# This runs in the roles phase, which is after the register include in
# playbook.yml's pre_tasks has created bitwarden.network.
- name: Attach Bitwarden to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# On a first attach both drop-ins are 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 DATABASE_URL's host resolves, fail its own
# health check, and fail the play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-ins are unchanged, nothing is pending, and this is a no-op.
- name: Apply the Postgres network change before starting Vaultwarden
ansible.builtin.meta: flush_handlers
+76 -10
View File
@@ -1,21 +1,87 @@
--- ---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
# file wholesale, because it also sets `host_directory` — to the Caddy data
# directory — and every playbook defines that for itself. Keep these three in
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
# belongs to the podman tasks, which this play does not run: Vaultwarden
# publishes nothing host-side.)
#
# podman_user is derived from the connection: this play carries no become, so
# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts
# (set in playbook.yml).
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Vaultwarden (the Bitwarden-compatible server)
# ---------------------------------------------------------------------------
# The service keeps the Docker-era name everywhere — directory, container, unit,
# Podman network and Postgres database are all `bitwarden`, and the Caddyfile
# upstream is `bitwarden:80`. Only the image is `vaultwarden/server`. Renaming
# it would touch the Caddyfile, the Postgres database and every client's saved
# server URL, for no gain.
host: vault.tanshu.com host: vault.tanshu.com
docker_container: bitwarden container_name: bitwarden
image_name: vaultwarden/server:latest unit_name: "{{ container_name }}.service"
host_directory: bitwarden # Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
image: docker.io/vaultwarden/server:latest
docker_network: "{{ docker_container }}_net" # The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: bitwarden.network,
# bitwarden_net).
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image's VOLUME is /data and ROCKET_PORT is 80, so this is both the bind
# mount target and the port Caddy proxies to.
data_dir: "{{ host_directory }}/data"
container_port: 80
# DATABASE_URL carries the Postgres password, and the Quadlet unit is
# world-readable in ~/.config, so it goes in this 0600 file instead and is read
# by systemd through EnvironmentFile=.
env_file: "{{ host_directory }}/bitwarden.env"
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
# handler that applies them.
postgres_container: postgres postgres_container: postgres
caddy_container: caddy postgres_unit: "{{ postgres_container }}.service"
caddyfile_path: /var/lib/caddy/conf/Caddyfile # The credentials the Docker-era playbook embedded in its db_url, kept as-is so
# the already-restored `bitwarden` database and the app's own connection string
# agree. Postgres is authoritative for this service; the SQLite file the image
# would use by default is stale and deliberately not migrated (see readme.md).
pg_user: postgres
pg_password: '123456'
pg_database: bitwarden
# Hostname, not an IP, and the same `postgres` name the old db_url used:
# postgres/roles/attach joins the postgres container to bitwarden_net, which is
# what makes this resolve. (`db`, the shared NetworkAlias, resolves too.)
database_url: "postgresql://{{ pg_user }}:{{ pg_password }}@{{ postgres_container }}:5432/{{ pg_database }}"
# Vaultwarden validates that DOMAIN carries a scheme and serves its web vault
# and API links from it. The old container set exactly this.
domain: "https://{{ host }}"
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# No request-body limit: Caddy streams request bodies and imposes none by
# default, so the 525M `client_max_body_size` the dead nginx front end carried
# has no equivalent — and needs none.
snippet_block: | snippet_block: |
{{ host }} { {{ host }} {
reverse_proxy {{ docker_container }}:80 reverse_proxy {{ container_name }}:{{ container_port }}
} }
db_url: postgresql://postgres:123456@{{ postgres_container }}:5432/bitwarden