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

+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