Run TriliumNext Notes as a rootless Podman Quadlet

Replaces the 2022 nginx + docker_container playbook with a Quadlet systemd user
unit on emily, and moves state from /var/lib/trillium to ~/podman-data/trilium.
There is no database to attach: the state is one SQLite file (document.db, with
its WAL sidecars), config.ini, session_secret.txt and Trilium's own backup/
directory, so unlike bitwarden/ and tandoor/ this play has no postgres role and
nothing to restore from the shared cluster.

The old playbook was wrong about what monoco actually runs, in three ways that
each break the deployment on their own. monoco has been on TriliumNext since
June 2025 (`triliumnext/notes:latest`, v0.95.0), not the retired zadam/trilium
the playbook pulls, and its document.db carries the schema migrations that came
with the change; the data directory is /home/node/trilium-data, not
/root/trilium-data; and the container sits on trillium_net behind Caddy, with
nothing published on the host. All three were read off the live host.

UserNS=keep-id:uid=1000,gid=1000, because the entrypoint starts as container-root
and drops to the image's node user, and uid 1000 is also what owns the whole
Docker-era tree -- so the copied data arrives already in the right shape and
needs no chown pass. The unit defines no HealthCmd=: unlike audiobookshelf, this
image ships its own HEALTHCHECK, and the play runs it with podman healthcheck
run.

The mount assertion is a mount check and not a file check, which is where this
play has to differ from audiobookshelf's. Asserting that document.db appeared on
the host works there because the app creates it; here the migration puts it on
the host before the app has ever run, so the file proves nothing. It asserts
against podman inspect instead, and checks Source as well as Destination -- a
Destination-only check would be satisfied by an anonymous volume mounted where
the bind mount should be, which is exactly the silent case. Bind mount,
anonymous volume, no mount and a payload with no Mounts key were each run
against the expression before this commit; only the first passes.

The site is unchanged; the service name is not. The live container is
`trillium`, with an extra l, and that exists only in the hand-made docker run
that created it. Everything on emily is `trilium`, following the app and this
directory, and the only place the misspelling survives is the copy source path
/var/lib/trillium that the readme documents.

The readme carries the monoco -> emily copy command by command: why the source
needs sudo (log/, tmp/ and backup/ are 0700 and backup/ is state), why the copy
cannot be hot (document.db is live SQLite in WAL mode), and why the digest
comparison has to happen before the first start, since Trilium can migrate the
document as it opens it. Of the 106 MB, 91 MB is regenerable logs, and the
no-sudo variant that skips them says out loud what it drops.

The integration goes through the shared include rather than a local copy --
caddy/roles/register for the network and the Caddy drop-in -- as in the other
service playbooks, and it runs in pre_tasks because Quadlet fails unit
generation, not merely startup, when a referenced .network file does not exist
yet. The dead files/nginx.conf.j2 goes with the nginx front end: Caddy handles
the WebSocket upgrade the live editor needs and imposes no request-body limit,
so there is nothing to carry over.
This commit is contained in:
tanshu committed 2026-10-16 09:24:38 +05:30
1 parent 8136c2ddaf
commit 09c964f34b
9 files changed
+851 -98

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
server {
listen 80;
server_name {{ http_host }};
location / {
proxy_pass http://localhost:8888;
}
}
+47 -49
View File
@@ -1,54 +1,52 @@
#################################################
# DO Community Playbooks: Docker
#################################################
--- ---
- hosts: all # TriliumNext Notes as a rootless Podman Quadlet unit, behind the shared Caddy
become: true # 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_files:
- vars/default.yml - vars/default.yml
tasks: pre_tasks:
- name: Pull default Trilium image # This must happen before the app unit is started, not after: the generated
docker_image: # unit references {{ network_name }}.network, and Quadlet fails *unit
name: "{{ container_image }}" # generation* (so `systemctl --user start` reports "unit not found") when a
source: pull # referenced .network file does not exist yet. The same include writes the
# Caddy-side drop-in that attaches Caddy to this network.
# Creates the number of containers defined by the variable create_containers, using values from vars file #
- name: Create Trilium container # network_name is a play var, so the include can see it directly.
docker_container: - name: Register Trilium's network with Caddy
name: "{{ container_name }}" ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
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
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
+414 -33
View File
@@ -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 Runs [TriliumNext Notes](https://github.com/TriliumNext/Notes) on `emily` as a
[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). **rootless Podman** container managed by a **systemd user unit** (Quadlet),
A number of containers will be created with the options specified in the `vars/default.yml` variable file. 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 ## 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` | `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 **There is no environment file here**, unlike `bitwarden/` and `tandoor/`. This
```shell deployment holds no secrets Ansible has to pass in: `config.ini` and
git clone https://github.com/do-community/ansible-playbooks.git `session_secret.txt` live inside the data directory, and the Docker-era container
cd ansible-playbooks/docker_ubuntu1804 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 ## Running
```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 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). 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).
+31
View File
@@ -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
+22
View File
@@ -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
+16
View File
@@ -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
+192
View File
@@ -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
@@ -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
+78 -4
View File
@@ -1,6 +1,80 @@
--- ---
container_name: trilium # ---------------------------------------------------------------------------
container_image: zadam/trilium:latest # Rootless Podman / Quadlet
http_host: "notes.tanshu.com" # ---------------------------------------------------------------------------
http_conf: "notes.tanshu.com.conf" # 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_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_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 }}
}