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:
1 parent
4cd9308ae3
commit
9bf2cbd7a9
13 files changed
+790
-143
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
state: directory
|
# (docs/backup-plan.md §3.3).
|
||||||
mode: 0755
|
- name: Ensure the Vaultwarden data directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ data_dir }}"
|
||||||
|
state: directory
|
||||||
|
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.
|
||||||
state: started
|
- name: Write the environment file
|
||||||
restart_policy: "unless-stopped"
|
ansible.builtin.template:
|
||||||
env:
|
src: bitwarden.env.j2
|
||||||
DATABASE_URL: "{{ db_url }}"
|
dest: "{{ env_file }}"
|
||||||
DOMAIN: "https://{{ host }}"
|
owner: "{{ podman_user }}"
|
||||||
volumes:
|
group: "{{ podman_user }}"
|
||||||
- "/var/lib/{{ host_directory }}/data:/data/"
|
mode: '0600'
|
||||||
networks:
|
notify: Restart Bitwarden
|
||||||
- name: "{{ docker_network }}"
|
|
||||||
|
- 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
|
||||||
|
daemon_reload: true
|
||||||
|
rescue:
|
||||||
|
# systemd reports "control process exited with error code" and nothing
|
||||||
|
# else, which is useless for the usual failures (an unattached network, an
|
||||||
|
# unreachable database, a bad volume). Pull the real reason out.
|
||||||
|
- name: Collect Vaultwarden diagnostics
|
||||||
|
ansible.builtin.command: "{{ item }}"
|
||||||
|
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".
|
||||||
@@ -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: >-
|
||||||
listen: "Reload Caddy"
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
changed_when: true
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|
||||||
Reference in new issue
Block a user