diff --git a/trilium/files/nginx.conf.j2 b/trilium/files/nginx.conf.j2 deleted file mode 100644 index 9bd047d..0000000 --- a/trilium/files/nginx.conf.j2 +++ /dev/null @@ -1,12 +0,0 @@ -server { - - listen 80; - server_name {{ http_host }}; - - location / { - - proxy_pass http://localhost:8888; - } - -} - diff --git a/trilium/playbook.yml b/trilium/playbook.yml index 69ae46a..46d220f 100755 --- a/trilium/playbook.yml +++ b/trilium/playbook.yml @@ -1,54 +1,52 @@ -################################################# -# DO Community Playbooks: Docker -################################################# --- -- hosts: all - become: true +# TriliumNext Notes as a rootless Podman Quadlet unit, behind the shared Caddy +# unit (see caddy/playbook.yml). +# +# Unlike bitwarden/ and tandoor/, this app has no database to attach: its state +# is one SQLite file (document.db, with its WAL sidecars) plus config.ini, +# session_secret.txt and an app-managed backup/ directory, all in a single +# directory — so there is no postgres role here and nothing to restore from the +# shared cluster. +# +# 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. (Applying the commented include above would also need +# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.) +# +# Run with: ansible-playbook -u tanshu trilium/playbook.yml +- name: Deploy TriliumNext Notes as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - vars/default.yml - tasks: - - name: Pull default Trilium image - docker_image: - name: "{{ container_image }}" - source: pull - - # Creates the number of containers defined by the variable create_containers, using values from vars file - - name: Create Trilium container - docker_container: - name: "{{ container_name }}" - image: "{{ container_image }}" - state: started - restart_policy: "unless-stopped" - published_ports: - - 127.0.0.1:8888:8080 - volumes: - - /var/lib/trilium:/root/trilium-data - - - name: Check if Nginx conf file exists - stat: path="/etc/nginx/sites-available/{{ http_conf }}" - register: status - - - name: No need to reload Nginx - debug: msg= {{ "No need to reload Nginx as sites-available entries have already been created" }} - - - name: Set Nginx conf file - when: status.stat.exists == false - template: - src: "files/nginx.conf.j2" - dest: "/etc/nginx/sites-available/{{ http_conf }}" - - - name: Enable new site - when: status.stat.exists == false - file: - src: "/etc/nginx/sites-available/{{ http_conf }}" - dest: "/etc/nginx/sites-enabled/{{ http_conf }}" - state: link - notify: Reload Nginx - - handlers: - - name: Reload Nginx - service: - name: nginx - state: reloaded + 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. + # + # network_name is a play var, so the include can see it directly. + - name: Register Trilium's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + roles: + # The app: the data directory, the Quadlet unit, start + verify. + - trilium + # Appends this site's block to the shared Caddyfile and reloads Caddy. + - caddy diff --git a/trilium/readme.md b/trilium/readme.md index f6d7ecc..37d61e4 100644 --- a/trilium/readme.md +++ b/trilium/readme.md @@ -1,46 +1,427 @@ -# Docker on Ubuntu 18.04 +# TriliumNext Notes (rootless Podman + Quadlet) -This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on -[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). -A number of containers will be created with the options specified in the `vars/default.yml` variable file. +Runs [TriliumNext Notes](https://github.com/TriliumNext/Notes) on `emily` as a +**rootless Podman** container managed by a **systemd user unit** (Quadlet), +behind the shared [rootless Caddy](../caddy/readme.md). Replaces the container +running on `monoco`, which `docker inspect` reports as: + +```command +docker run -d \ + --name trillium \ + --network trillium_net \ + --volume /var/lib/trillium:/home/node/trilium-data \ + --restart=unless-stopped \ + triliumnext/notes:latest +``` + +with no published ports: Caddy is on the same network and proxies to +`trillium:8080`. + +Unlike [bitwarden/](../bitwarden/readme.md) and [tandoor/](../tandoor/readme.md), +**there is no database to attach.** Trilium's state is one SQLite file +(`document.db`, with its WAL sidecars), `config.ini`, `session_secret.txt`, and +an app-managed `backup/` directory — so this play has no `postgres` role and +nothing to restore from the shared cluster. + +## What the old playbook gets wrong + +`trilium/playbook.yml` was last touched in 2022 and **is not what runs on +monoco.** Three differences shape everything below, and all three were read off +the live host rather than the playbook: + +| On monoco (measured) | In the old playbook | +|---|---| +| `triliumnext/notes:latest` | `zadam/trilium:latest` — the retired original. Its `document.db` predates TriliumNext's schema migrations | +| `/var/lib/trillium` → `/home/node/trilium-data` | `/var/lib/trilium` → `/root/trilium-data` | +| `trillium_net`, Caddy proxies to `trillium:8080` | nginx on the host, proxying to `127.0.0.1:8888` | + +So this playbook pins the **TriliumNext** image against a data directory at +`/home/node/trilium-data` — pulling `zadam/trilium` against the migrated +database would not work. + +### Naming: `trilium`, not `trillium` + +The live container on monoco is `trillium`, with an extra `l` — a typo that +exists only in the hand-made `docker run` that created it. This directory, the +Docker-era playbook, and the app itself all say `trilium`, and **everything on +emily is `trilium`**: directory, container, unit, Podman network, Caddyfile +upstream. + +The site `notes.tanshu.com` is unchanged, so nothing client-facing moves. The +misspelling survives in exactly one place, deliberately: the **source path** +`/var/lib/trillium` in the copy commands below, which is what the old host +actually has. + +The image's data directory is `/home/node/trilium-data`, and the image ships its +own `HEALTHCHECK` (`docker_healthcheck.cjs`), which the Docker-era container +reported healthy with. The unit therefore defines no `HealthCmd=` — unlike +[audiobookshelf/](../audiobookshelf/readme.md), whose image has none. + +## Ordering + +Run [caddy/playbook.yml](../caddy/playbook.yml) first. It is the reverse proxy, +owns the Caddyfile this site appends to, supplies the `caddy.service` handlers, +and is the one play that applies the rootless-Podman prerequisites (installing +podman, `loginctl enable-linger`, the low-port sysctl, the Quadlet directory). + +There is no second prerequisite to wait for — no cluster to bring across, since +this app has no database. + +This playbook does **not** move the data. Copy it first (see +["Migrating the data"](#migrating-the-data-from-monoco)) so the first start opens +the real `document.db`, rather than creating an empty document beside it. + +## Layout + +``` +trilium/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── trilium/ # the data directory, 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/trilium.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/trilium.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-trilium.conf` | attaches Caddy to that network | +| `~/podman-data/trilium/document.db` | the document, with `-wal` / `-shm` beside it | +| `~/podman-data/trilium/config.ini` | the app's settings — `port`, `trustedReverseProxy`, `noBackup` | +| `~/podman-data/trilium/session_secret.txt` | the session signing secret | +| `~/podman-data/trilium/log/` | one log file per day, 91 MB of the 106 MB tree | +| `~/podman-data/trilium/backup/` | Trilium's own zipped `document.db` backups | +| `~/podman-data/trilium/tmp/` | scratch, empty at rest | + +## What is state, and what is not + +Per [docs/backup-plan.md §2.1](../docs/backup-plan.md), the tree is **106 MB** +and almost all of it is dispensable: + +| Path | Size | Keep? | Why | +|---|---|---|---| +| `document.db` | 3.8 MB | **required** | every note, revision, attribute and attachment reference | +| `config.ini` | 3 KB | **required** | the instance's settings; `port=8080` is why the unit proxies to 8080 | +| `session_secret.txt` | 64 B | **required** | the session signing secret | +| `backup/` | 12 MB | worth keeping | Trilium's own zipped `document.db` backups (`noBackup=false`) | +| `log/` | 91 MB | **no** | daily app logs. Regenerable, and 86% of the tree | +| `tmp/` | 0 | no | scratch | + +So the real state is **~4 MB**, and the migration carries all of it. + +**A live `document.db` must not be `tar`-ed.** It is SQLite in WAL mode, so the +main file and its `-wal` sidecar are copied at different instants and the result +can be torn — a database that will not open +([docs/backup-plan.md §3.1](../docs/backup-plan.md)). Stop the container, as +[§7.3](../docs/backup-plan.md) lists this among the stop-copy-start SQLite +services, or use Trilium's own **Settings → Backup**. + +## 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 Trilium'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 +`trilium.container` references `trilium.network`: Quadlet fails *unit +generation* — so `systemctl --user start` reports "unit not found" — when a +referenced `.network` file does not exist yet. + +`network_name` is `trilium`, so the *file* is `trilium.network` and the *Podman +network* is `trilium_net` (`NetworkName=` in the network unit pins it). + +[roles/caddy](roles/caddy/tasks/main.yaml) appends the site block to the shared +Caddyfile with `blockinfile`, exactly as the other services do. Caddy handles the +WebSocket upgrade the live-updating editor needs, and imposes no request-body +limit, so there is nothing like the old nginx front end to configure. + +**Nothing is published on the host.** The container listens on 8080 and Caddy +reaches it as `trilium:8080` over `trilium_net`, so this play needs none of the +port-floor work Caddy itself needs. ## Settings -- `create_containers`: number of containers to create. -- `default_container_name`: default name for new containers. -- `default_container_image`: default image for new containers. -- `default_container_command`: default command to run on new containers. +| Variable | Value / purpose | +|---|---| +| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | +| `podman_data_dir` | `~/podman-data` | +| `quadlet_dir` | `~/.config/containers/systemd` | +| `host` | `notes.tanshu.com` — the site block Caddy serves | +| `container_name` | `trilium`, and the Quadlet unit / container name | +| `image` | `docker.io/triliumnext/notes:latest` — fully qualified, and the TriliumNext image | +| `network_name` | `trilium` → `trilium.network` / `trilium_net` | +| `data_dir` | `~/podman-data/trilium` — the one and only mount source | +| `container_data_dir` | `/home/node/trilium-data` — the image's data directory | +| `log_dir` / `tmp_dir` / `backup_dir` | The three app-managed subdirectories, created at 0700 | +| `container_port` | `8080`, matching `config.ini`'s `port` | +| `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. -## 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=1000,gid=1000` — the entrypoint starts as container-root and +drops to the image's `node` user, which is uid 1000, and uid 1000 is what owns +every file in the Docker-era tree on monoco +([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). Mapping +container uid 1000 onto `podman_user` keeps `~/podman-data/trilium` owned by +`tanshu` and leaves no subuid-owned files to trip over backups. -### 1. Obtain the playbook -```shell -git clone https://github.com/do-community/ansible-playbooks.git -cd ansible-playbooks/docker_ubuntu1804 -``` +**There is no environment file here**, unlike `bitwarden/` and `tandoor/`. This +deployment holds no secrets Ansible has to pass in: `config.ini` and +`session_secret.txt` live inside the data directory, and the Docker-era container +set no environment at all. That is also why no `TZ` is set — the container runs +on UTC, exactly as it does today. Add `Environment=TZ=Asia/Kolkata` to the +template if you want local-time log filenames. -### 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 +## Running ```command -ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml +ansible-playbook -u tanshu trilium/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). \ No newline at end of file +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 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 trilium` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +## Migrating the data from monoco + +Everything lives in **`/var/lib/trillium`** — note the extra `l`; see +["Naming"](#naming-trilium-not-trillium) — owned by **uid 1000**, which is the +`ubuntu` account on monoco and the uid the app writes as inside the container. It +moves to `~/podman-data/trilium`, and the whole tree is 106 MB, of which 91 MB is +regenerable logs. + +Two properties of the tree decide how the copy has to be done: + +- **`document.db` is live SQLite in WAL mode**, with `-wal` and `-shm` beside it + ([docs/backup-plan.md §3.1](../docs/backup-plan.md)). **Stop the container + first**; a hot copy can be torn. +- **`log/`, `tmp/` and `backup/` are mode 0700 and owned by uid 1000**, not by + the `tanshu` account you SSH as. A copy made as `tanshu` cannot read them at + all — `backup/` included, and that one is state. + +`emily` cannot SSH to `monoco`, nor `monoco` to `emily` (neither resolves the +other's name), so the copy streams through the machine that holds both aliases — +and `rsync` cannot copy remote-to-remote in a single command anyway. + +```bash +# 1. Look before you copy: size, contents, numeric ownership. `ls -ln` shows +# uid:gid, which is what matters here — the tree is uid 1000, not root. +ssh monoco 'du -sh /var/lib/trillium; ls -ln /var/lib/trillium' + +# 2. Stop the old container so nothing writes while you copy, and leave it +# stopped: monoco's tree becomes a stale snapshot the moment emily serves. +ssh monoco 'docker stop trillium' + +# 3. Create the destination — this is the mount target, /home/node/trilium-data. +ssh emily 'mkdir -p ~/podman-data/trilium' + +# 4. Copy. `sudo` on the source side is not optional here: log/, tmp/ and +# backup/ are 0700 and unreadable as tanshu, and backup/ is state. +# +# `set -o pipefail` matters: 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. +# +# `--no-same-owner` is explicit because the source files are uid 1000 +# (`ubuntu`) on monoco. keep-id maps container uid 1000 onto tanshu, so files +# that arrive owned by tanshu are already correct — restoring the old +# ownership would be wrong. +set -o pipefail +ssh monoco 'sudo tar -C /var/lib/trillium -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/trilium -xf -' + +# 5. Verify by content, over the whole tree on both sides. The paths are relative +# on both sides, so the digests are comparable. `sudo` again on the source: +# the 0700 directories are part of what is being compared. +# +# Do this *before* the deploy below — Trilium may run schema migrations +# against document.db on first start, and the two trees stop being comparable +# at that point. +ssh monoco 'sudo sh -c "cd /var/lib/trillium && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum"' +ssh emily 'cd ~/podman-data/trilium && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +# identical digests = a byte-faithful copy + +# 6. Nothing may end up owned by a subuid, or by root. +ssh emily 'find ~/podman-data/trilium ! -user tanshu' # empty = correct + +# 7. Deploy — after the copy, so the first start opens the real document rather +# than creating an empty one beside it. +cd prepare-server && ansible-playbook -u tanshu ../trilium/playbook.yml +``` + +Ownership needs no `chown` pass on emily: the container writes as uid 1000, which +`keep-id` maps onto `tanshu`, and the extraction already produced `tanshu`-owned +files. That is also why `--no-same-owner` is explicit on the receiving side. + +If you would rather not use `sudo`, or the 91 MB of logs are not worth carrying, +exclude the three app-managed directories — the role recreates them at the right +mode, and the play reads `log/` back as evidence afterwards: + +```bash +set -o pipefail +ssh monoco 'tar --exclude=./log --exclude=./tmp --exclude=./backup -C /var/lib/trillium -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/trilium -xf -' +``` + +That drops the 91 MB of regenerable logs **and** the 12 MB of `backup/` — +Trilium's own zipped `document.db` backups. Nothing in it is unique against the +migration itself, but that is worth saying deliberately rather than by accident. +If you use this variant, apply the same three exclusions to the `find` in step 5. + +If the link is slow or drops, copy in two hops instead: resumable, and it shows +progress. The first hop needs `--rsync-path` because of the 0700 directories. + +```bash +rsync -aP --rsync-path="sudo rsync" monoco:/var/lib/trillium/ ~/trilium-copy/ +rsync -aP ~/trilium-copy/ emily:~/podman-data/trilium/ +``` + +Rolling back means rolling back on monoco: `ssh monoco 'docker start trillium'` +is meaningful only while monoco's tree is still the one you copied. Once emily +has served and written to `document.db`, the two have diverged — restore monoco's +copy rather than starting it in place. + +## Verification + +```bash +systemctl --user status trilium +journalctl --user -u trilium -f +podman logs trilium + +# What the playbook checks for you: +podman healthcheck run trilium # the image's own probe + +# The unit's mount is real and not the container's writable layer: +podman inspect trilium --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' + +# The health the unit reports on its own timer, not just once: +podman inspect trilium --format '{{.State.Health.Status}}' + +# The document came across: a migrated document.db is a few megabytes, not the +# kilobytes of one the app creates on an empty install. +ssh emily 'ls -ln ~/podman-data/trilium/document.db' +ssh emily 'command -v sqlite3 >/dev/null && sqlite3 ~/podman-data/trilium/document.db "pragma integrity_check"' + +# The site, end to end. +curl -sSI https://notes.tanshu.com +``` + +Then open `https://notes.tanshu.com` **in a browser**, log in, and open a note. +A `curl` cannot prove the migrated document is *readable*; only the app can. + +Also exercise a **write** — edit a note — and confirm on the host that what the +app wrote is owned by `tanshu`: + +```bash +ssh emily 'ls -ln ~/podman-data/trilium/document.db* ~/podman-data/trilium/log/' +``` + +An edit touches `document.db-wal` and appends to today's log; both must be owned +by `tanshu`. That is the check that catches a wrong `keep-id`; a read-only smoke +test will not. The play reports the same thing from the other direction, by +looking for a `trilium-*.log` touched since the unit started. + +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 + +- **Do not deploy the old image.** The retired `zadam/trilium` reads + `/root/trilium-data` and predates TriliumNext's schema migrations; pointed at + this `document.db` it is at best a failure to start. The unit pins + `docker.io/triliumnext/notes:latest`. +- **The image's own health check is used.** There is no `HealthCmd=` in the unit + and there does not need to be: the image ships one + (`exec gosu node node /usr/src/app/docker_healthcheck.cjs`), which is why the + Docker-era container showed `healthy`. `is-active` alone cannot tell a serving + server from a crash-looping one under `Restart=always`, which is why the play + runs the healthcheck rather than trusting it. +- **The mount check is a mount check, not a file check.** Audiobookshelf can + assert its database appeared on the host, because the app creates it. Here the + migration puts `document.db` on the host *before* the app runs, so its presence + proves nothing — the play asserts against `podman inspect` instead. That is the + one failure that is otherwise silent: with a missing `Volume=` line the app + runs perfectly and loses the document on the next recreate. +- **`latest` floats.** The image measured for this playbook is **v0.95.0** + (`org.opencontainers.image.version`), built 2025-06-15, revision `b9ae2265`. + Pin a digest if reproducible deploys matter — + `podman image inspect docker.io/triliumnext/notes:latest --format '{{.Digest}}'` + — since an upgrade can run schema migrations on `document.db` on first start. + Take Trilium's own backup (or stop-and-copy) before upgrading. +- **`trustedReverseProxy=false` in `config.ini`.** Trilium does not trust + `X-Forwarded-For`, so its own logs see the proxy rather than the client. That + is the existing behaviour, carried across unchanged; set it to `true` (or to + the Caddy network's CIDR) in `config.ini` if you want real client addresses + there. It is app state, not something this playbook sets. +- **The copy needs `sudo` on monoco** because `log/`, `tmp/` and `backup/` are + 0700, and `backup/` is the one of the three that is state. `tanshu` cannot read + them, and a copy that silently skips them looks like it worked. +- **Verify before deploying, not after.** The digest comparison in step 5 stops + being meaningful once the app has opened `document.db`, because it may migrate + it. +- **No `TZ`.** The Docker-era container set no environment at all, so the app + runs on UTC — that is where the log filenames' dates come from. Trilium renders + dates in the browser, so this is cosmetic, but it is a deliberate carry-over + rather than an omission. +- **Registering restarts all of Caddy.** Writing the network unit and the drop-in + changes Caddy's generated unit, and Quadlet has no live equivalent of + `docker network connect`, so Caddy is recreated — a brief restart of *every* + hosted site, not just this one. See + [caddy/readme.md](../caddy/readme.md), "Trade-off worth knowing". +- **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.** `notes.tanshu.com` must resolve to `emily` before Caddy can obtain a + certificate for it. The certificates and ACME account key from monoco's + `/var/lib/caddy/data` are **not** part of this migration — Caddy's own playbook + and readme cover that. +- **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. Trilium's own `backup/` mechanism runs *inside* the app + (`noBackup=false` in `config.ini`), so it keeps working without cron. +- **Never back up a hot `document.db`.** Same WAL problem as the migration: stop + the container, or let the app's own backup do it. +- **Do not restart the container on monoco.** Its tree is a stale snapshot of a + document emily has moved past. + +## Not done here + +- Pinning the image by digest or version tag, or wiring up + `AutoUpdate=registry` with `podman auto-update`. +- A backup script or systemd user timer for `document.db` — the app's own + **Settings → Backup** into `backup/` is the mechanism, and the 12 MB it has + produced so far comes across with the copy. +- Setting `TZ`, or turning on `trustedReverseProxy`, both of which are runtime + settings inside `config.ini` rather than Ansible variables. +- Renaming `trillium` to `trilium` on monoco, or updating the docs that call this + service `trillium`: `docs/podman-migration.md` (§4.1, §4.2, §4.3, §5.5) and + `docs/podman-compat-audit.md` (§3.2, §4). diff --git a/trilium/roles/caddy/handlers/main.yaml b/trilium/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..5fe87df --- /dev/null +++ b/trilium/roles/caddy/handlers/main.yaml @@ -0,0 +1,31 @@ +--- +# 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 + ansible.builtin.command: >- + 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 diff --git a/trilium/roles/caddy/tasks/main.yaml b/trilium/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..ffbe0d8 --- /dev/null +++ b/trilium/roles/caddy/tasks/main.yaml @@ -0,0 +1,22 @@ +--- +# Append this site's block to the shared Caddyfile. +# +# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the +# 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: "Add the {{ host }} site block to the Caddyfile" + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" + block: "{{ snippet_block }}" + create: yes + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/trilium/roles/trilium/handlers/main.yaml b/trilium/roles/trilium/handlers/main.yaml new file mode 100644 index 0000000..1f5dec1 --- /dev/null +++ b/trilium/roles/trilium/handlers/main.yaml @@ -0,0 +1,16 @@ +--- +# 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 + +- name: Restart Trilium + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Trilium diff --git a/trilium/roles/trilium/tasks/main.yaml b/trilium/roles/trilium/tasks/main.yaml new file mode 100644 index 0000000..bd1eb5a --- /dev/null +++ b/trilium/roles/trilium/tasks/main.yaml @@ -0,0 +1,192 @@ +--- +# TriliumNext Notes as a rootless Podman Quadlet unit. +# +# Everything here runs as {{ podman_user }} — the connection user. No become, no +# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's +# user manager is reachable through the standard environment. + +# The image's data directory is one tree: document.db with its WAL sidecars, +# config.ini, session_secret.txt, and the three subdirectories below. It is +# created here as the copy target as much as for a fresh install. +- name: Ensure the Trilium data directory exists + ansible.builtin.file: + path: "{{ data_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +# Trilium creates these itself on first start, at 0700 — which is exactly how +# they are on monoco. Creating them here means a fresh host and a migrated one +# look the same, and that the destinations named in readme.md exist before the +# copy. log/ is where the app writes its daily log — which the play reads back +# later as evidence that the container is writing to the host — and backup/ is +# where its own zipped document.db backups land. +- name: Ensure the app-managed subdirectories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0700' + loop: + - "{{ log_dir }}" + - "{{ tmp_dir }}" + - "{{ backup_dir }}" + +# Checked before the unit starts, because this is the one thing the play cannot +# tell after the fact: with no document.db the app serves a brand new, empty +# document, which is right for a fresh install and wrong for a migration whose +# copy has not run yet. Once the app has started it creates the file itself and +# the evidence is gone. +- name: Check whether a document has been copied in + ansible.builtin.stat: + path: "{{ data_dir }}/document.db" + register: trilium_document_db + +- name: Note that this will start with a fresh document + ansible.builtin.debug: + msg: >- + {{ data_dir }}/document.db does not exist, so Trilium will create an empty + document on first start. That is correct for a fresh install — and it is + what happens if the copy from monoco has not run yet. See readme.md, + "Migrating the data from monoco". + when: not trilium_document_db.stat.exists + +- name: Install the Trilium Quadlet unit + ansible.builtin.template: + src: trilium.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Trilium + +- name: Start Trilium + block: + - name: Start Trilium + 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 + # unwritable data directory, a bad volume). Pull the real reason out. + - name: Collect Trilium 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: trilium_diag + changed_when: false + failed_when: false + + - name: Show Trilium diagnostics + ansible.builtin.debug: + msg: "{{ trilium_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Trilium 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 Trilium is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: trilium_active + changed_when: false + failed_when: trilium_active.stdout != 'active' + +# `is-active` says the container is running; it does not say the app can serve. +# `Restart=always` means even a crash-looping container is reported active. +# +# Unlike audiobookshelf, this image ships its own HEALTHCHECK +# (`exec gosu node node /usr/src/app/docker_healthcheck.cjs`, which the +# Docker-era container reported healthy with), so there is no HealthCmd= in the +# unit to run here — `podman healthcheck run` executes the image's own probe and +# this depends on no path the play has to know about. +- name: Wait for Trilium to pass its own health check + ansible.builtin.command: "podman healthcheck run {{ container_name }}" + register: trilium_health + until: trilium_health.rc == 0 + retries: 24 + delay: 5 + changed_when: false + +# The check that catches a missing or mistyped Volume= line, which is the one +# failure here that is silent: the app would run perfectly well with document.db +# in the container's writable layer — or in an anonymous volume, if the image +# declares one — and lose it on the next recreate. +# +# It has to be a mount check and not a file check. Audiobookshelf can assert that +# its database appeared on the host, because the app creates it; here the +# migration puts document.db on the host *before* the app ever runs, so its +# presence proves nothing about whether the container can see it. +# +# Both halves matter: the destination alone would still be satisfied by an +# anonymous volume mounted where the bind mount should be, which is exactly the +# silent case. `Source` is the path Quadlet passed to podman, verbatim. +- name: Read the running container's configuration back + ansible.builtin.command: "podman inspect {{ container_name }}" + register: trilium_inspect + changed_when: false + +- name: Fail if the data directory is not bind-mounted into the container + ansible.builtin.assert: + that: + - >- + (trilium_inspect.stdout | from_json)[0].Mounts | default([]) + | selectattr('Source', 'defined') + | selectattr('Source', 'eq', data_dir) + | selectattr('Destination', 'eq', container_data_dir) + | list | length == 1 + fail_msg: >- + {{ data_dir }} is not mounted at {{ container_data_dir }} in the running + container. Whatever the app is writing is inside the container's writable + layer, or in an anonymous volume, and will be lost when it is recreated — + check the Volume= line in the generated unit + ({{ quadlet_dir }}/{{ container_name }}.container). + +# Evidence rather than a gate: with keep-id:uid=1000 mapped onto +# {{ podman_user }}, what the app writes lands in {{ data_dir }} owned by +# {{ podman_user }} — and the app writes a daily log the moment it starts. A log +# touched in the last few minutes is that, visible from the host. It is reported +# rather than asserted because a slow start can legitimately leave nothing here +# yet; the mount assertion above is the hard check. +- name: Look for a log written since the unit started + ansible.builtin.find: + paths: "{{ log_dir }}" + patterns: 'trilium-*.log' + age: -5m + file_type: file + register: trilium_recent_logs + +- name: Report whether the app is writing to the host data directory + ansible.builtin.debug: + msg: >- + {{ trilium_recent_logs.matched }} log file(s) in {{ log_dir }} touched in + the last 5 minutes. With container uid 1000 mapped onto {{ podman_user }}, + a log appearing there is the app writing into {{ data_dir }} on the host; + nothing yet, on a unit that is serving, means checking the Volume= and + UserNS= lines in the generated unit. + +- name: Check the document the container is reading + ansible.builtin.stat: + path: "{{ data_dir }}/document.db" + register: trilium_document_db_after + +# A migrated document.db is a few megabytes; the empty one the app creates on a +# fresh install is kilobytes. Worth printing, because it is the difference +# between "the notes came across" and "the copy never ran". +- name: Report the document on the host + ansible.builtin.debug: + msg: >- + {{ data_dir }}/document.db is + {{ trilium_document_db_after.stat.pw_name | default('?') }}:{{ trilium_document_db_after.stat.gr_name | default('?') }}, + {{ trilium_document_db_after.stat.size | default(0) }} bytes + when: trilium_document_db_after.stat.exists diff --git a/trilium/roles/trilium/templates/trilium.container.j2 b/trilium/roles/trilium/templates/trilium.container.j2 new file mode 100644 index 0000000..15d669c --- /dev/null +++ b/trilium/roles/trilium/templates/trilium.container.j2 @@ -0,0 +1,51 @@ +# TriliumNext Notes — rootless Podman Quadlet unit. +# Managed by Ansible (trilium/roles/trilium). 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). Caddy +# reaches the app by container name, so nothing is published on the host. + +[Unit] +Description=TriliumNext Notes +Documentation=https://github.com/TriliumNext/Notes +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# The entrypoint starts as container-root and drops to the image's `node` user, +# and `node` is uid 1000 — which is also the uid that owns every file in the +# Docker-era data tree on monoco (docs/podman-compat-audit.md §3.2). Mapping +# container uid 1000 onto {{ podman_user }} keeps {{ data_dir }} owned by +# {{ podman_user }} on the host, so tar/rsync backups keep working and no +# subuid-owned files appear. +UserNS=keep-id:uid=1000,gid=1000 + +Network={{ network_name }}.network + +# The image's data directory. This line is the only thing keeping state out of +# the container's writable layer — drop it and the app runs perfectly while +# losing document.db on the next recreate. The role asserts the mount is real +# after the health check passes. +Volume={{ data_dir }}:{{ container_data_dir }} + +# No EnvironmentFile= and no Environment= lines. The Docker-era container set no +# environment at all: this deployment's only settings live in config.ini and +# session_secret.txt, both inside the data directory above. Add +# `Environment=TZ=Asia/Kolkata` here if you want local-time log filenames; until +# then the container runs on UTC, exactly as it does on monoco. +# +# No HealthCmd= either, unlike audiobookshelf: this image ships its own +# HEALTHCHECK (docker_healthcheck.cjs), so `podman healthcheck run` executes the +# image's own probe and the unit only has to not get in the way. + +[Service] +Restart=always +# First start opens document.db and may run schema migrations before the server +# listens. +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/trilium/vars/default.yml b/trilium/vars/default.yml index 9341de4..b341305 100644 --- a/trilium/vars/default.yml +++ b/trilium/vars/default.yml @@ -1,6 +1,80 @@ --- -container_name: trilium -container_image: zadam/trilium:latest -http_host: "notes.tanshu.com" -http_conf: "notes.tanshu.com.conf" +# --------------------------------------------------------------------------- +# 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: Trilium 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" +# --------------------------------------------------------------------------- +# TriliumNext Notes +# --------------------------------------------------------------------------- +# The site is unchanged from the Docker era. The *service* name is not: this +# directory and the Docker-era playbook call it `trilium`, while the live +# container on monoco — which nobody deployed from here — is `trillium`, with +# the extra `l`. Everything on emily is `trilium`, following the app's real name +# and this playbook's own history; the only place the misspelling survives is +# the source path /var/lib/trillium in the copy commands in readme.md. +host: notes.tanshu.com + +container_name: trilium +unit_name: "{{ container_name }}.service" + +# 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. +# +# This is the *TriliumNext* image, not the retired `zadam/trilium` the old +# playbook pulled: monoco has been running TriliumNext since June 2025, and its +# document.db carries the schema migrations that came with it. Pulling the old +# image against that database would not work. +image: docker.io/triliumnext/notes:latest + +# The Quadlet file is .network. caddy/roles/register pins the +# Podman network name to _net (so: trilium.network, +# trilium_net). +network_name: "{{ container_name }}" + +# The image's data directory, and the whole of this service's state. Unlike +# audiobookshelf/ there is one mount target rather than four, because Trilium +# keeps document.db, config.ini and its own subdirectories together. +data_dir: "{{ podman_data_dir }}/{{ container_name }}" +container_data_dir: /home/node/trilium-data + +# Subdirectories the app creates and owns itself, at mode 0700 — that is exactly +# what they are on monoco. Created by the role so a fresh host and a migrated one +# look the same, and so the copy target in readme.md exists: +# +# log/ one file per day, 91 MB of the 106 MB tree on monoco. Regenerable. +# tmp/ scratch, empty at rest. +# backup/ Trilium's own zipped document.db backups, 12 MB. Worth keeping. +log_dir: "{{ data_dir }}/log" +tmp_dir: "{{ data_dir }}/tmp" +backup_dir: "{{ data_dir }}/backup" + +# The port the app listens on inside the container, from config.ini's +# `port = 8080`, which the Docker-era container used unchanged. Caddy proxies to +# it; nothing is published on the host. +container_port: 8080 + +# 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" + +# Caddy handles the WebSocket upgrade Trilium's live-updating editor needs, and +# imposes no request-body limit, so there is nothing to carry over from the old +# nginx front end. +snippet_block: | + {{ host }} { + reverse_proxy {{ container_name }}:{{ container_port }} + }