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

+78 -4
View File
@@ -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_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 }}
}