Files
playbooks/yamtrack
2026-10-18 21:35:53 +05:30
..
2026-10-18 21:35:53 +05:30
2026-10-18 21:35:53 +05:30
2026-10-18 21:35:53 +05:30
2026-10-18 21:35:53 +05:30

Yamtrack (rootless Podman + Quadlet)

Runs Yamtrack on emily as a rootless Podman container managed by a systemd user unit (Quadlet), behind the shared rootless Caddy, with its cache and Celery broker in the shared rootless Valkey. Replaces the docker_container deployment on monoco.

The database stays SQLite, inside the app's own data directory (~/podman-data/yamtrack/data/db.sqlite3). That is deliberate, not an oversight — see The database stays SQLite.

Everything else about the old container is reproduced rather than redesigned: the same image tag, the same four environment variables, the same upstream (yamtrack:8000), the same network name (yamtrack_net) and the same SECRET.

Ordering

Both shared services are already migrated and running on emily:

  1. caddy/playbook.yml — the reverse proxy, which owns the Caddyfile this site appends to, supplies the caddy.service handlers, and is the one play that applies the rootless-Podman prerequisites.
  2. valkey/playbook.yml — the shared cache and Celery broker. Yamtrack hard-requires Redis: its cache backend is django_redis with no fallback, Celery worker and beat need a broker, and several metadata providers talk to Redis directly. The old container was given redis://valkey:6379 and that is exactly what this playbook sets.
  3. This playbook — run it after the file copy below, not before: the entrypoint migrates the schema on first start, so an empty ~/podman-data/yamtrack/data must not be what the app wakes up to.

postgres/playbook.yml is not part of this deployment: there is no yamtrack database in the restored cluster, because the app never used Postgres.

This playbook attaches yamtrack_net to Valkey. Until it has, valkey does not resolve on that network, so running this playbook is what makes the app reachable — there is no separate step.

Layout

yamtrack/
├── playbook.yml
├── vars/default.yml
└── roles/
    ├── valkey/      # attaches this app's network to the shared cache (+ its handler)
    ├── yamtrack/    # data dir, the 0600 env file, the Quadlet unit, start + verify
    └── caddy/       # appends this site's block to the shared Caddyfile

Quadlet units and state:

On the host What
~/.config/containers/systemd/yamtrack.container the generated unit (Ansible-templated)
~/.config/containers/systemd/yamtrack.network the app network, written by caddy/roles/register
~/.config/containers/systemd/caddy.container.d/50-yamtrack.conf attaches Caddy to that network
~/.config/containers/systemd/valkey.container.d/50-yamtrack.conf attaches Valkey to it
~/.config/containers/systemd/valkey.container.d/10-network-aliases.conf the shared redis alias
~/podman-data/yamtrack/data/ the image's /yamtrack/db: db.sqlite3 (+ -wal/-shm)
~/podman-data/yamtrack/yamtrack.env SECRET, TZ, URLS, REDIS_URL, PUID/PGID, mode 0600, read by systemd

Two Docker-era pieces are deleted rather than ported: roles/network, which existed to attach an already-created container to a network (docker_network's connected:/appends:, which podman_network has no equivalent for — docs/podman-compat-audit.md §3.3), and files/nginx.conf.j2, which was a copy-pasted Vaultwarden nginx template that this playbook never used. Nothing has to be connected after the fact now: the app's own unit names its Network=, and Caddy and Valkey learn about the network from drop-ins written in the same run.

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, which writes the network unit and the drop-in that pulls it into Caddy:

- name: Register Yamtrack'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 yamtrack.container references yamtrack.network: Quadlet fails unit generation — so systemctl --user start reports "unit not found" — when a referenced .network file does not exist yet. The Valkey attach below depends on it for the same reason.

network_name is yamtrack, so the file is yamtrack.network and the Podman network is yamtrack_net (NetworkName= in the network unit pins it, keeping the Docker-era name).

The site block itself is appended to the shared Caddyfile by roles/caddy with blockinfile — the same bytes the Docker-era Caddyfile on monoco already carries for this host:

track.tanshu.com {
    reverse_proxy yamtrack:8000
}

8000 is the port the image's own nginx listens on inside the container (it proxies to gunicorn); the image EXPOSEs exactly that, and vars/default.yml's container_port is where it is written down. The old roles/caddy slurped and compared the Caddyfile before editing it, which fails on a host where Caddy has not run yet; blockinfile is idempotent on its own marker, so that whole dance is gone.

How it attaches to Valkey

Same idea at the other end: Valkey knows nothing about its clients, and this playbook uses the shared include in valkey/roles/attach rather than writing those drop-ins itself. That writes two files, both mergeable into valkey.container by Quadlet:

File Content
valkey.container.d/50-yamtrack.conf Network=yamtrack.network
valkey.container.d/10-network-aliases.conf NetworkAlias=redis

REDIS_URL uses the host valkey, which is ContainerName=valkey and is what the Docker-era container was given. The redis alias in the shared file is for the other cache clients (Nextcloud writes REDIS_HOST=redis into its config.php), not for this app; the play checks that valkey resolves.

roles/valkey wraps that include for two reasons:

  • it defines the Restart Valkey handler the include notifies; and
  • it flushes handlers immediately afterwards. On a first attach the drop-in is new, so Valkey has to be recreated to join yamtrack_net — and that restart is a handler, which by default runs at the end of the play, after the yamtrack role has already started the app. The app would then come up before valkey resolves, fail its own health check, and fail the play on a machine where nothing is wrong. Flushing applies the drop-in first; on later runs nothing is pending and it is a no-op.

Unlike Postgres, the cost here is not downtime for a database: Valkey is a cache, so every attached app simply starts with a cold cache and the first requests after the restart are slower. See valkey/readme.md, "The trade-off". Attached means every cache client, not just Yamtrack.

Who runs as whom

UserNS=keep-id:uid=0,gid=0 — container root is mapped onto podman_user, the same choice Caddy, Vaultwarden and Tandoor make. That is what keeps ~/podman-data/yamtrack owned by podman_user rather than by a subuid, so plain tar, cp and rm -rf work there without podman unshare or sudo, and extracting the old data as podman_user is already correct.

uid 0 is required here, not a convenience. --userns=keep-id does two things: it maps one container uid onto the connecting user, and it runs the container as that uid. Yamtrack's entrypoint begins by rewriting /etc/group and /etc/passwd (groupmod/usermod, applying PUID/PGID) — files owned by container-root in the image. With keep-id:uid=1000 the entrypoint runs as abc, cannot lock /etc/group, and the unit dies with:

groupmod: /etc/group.5: Permission denied
groupmod: cannot lock /etc/group; try again later.
yamtrack.service: Main process exited, code=exited, status=10/n/a

before supervisord is ever reached. manage.py migrate has already run and succeeded at that point, because the bind mount is writable as abc — which makes the failure look like a database problem when it is not. Docker on monoco ran the same entrypoint as real root, which is why it never surfaced there.

PUID/PGID are therefore pinned to the same 0 in the environment file. The entrypoint's own default is 1000, and under this mapping container uid 1000 is a subuid on the host, so its chown -R abc:abc db would leave the database owned by e.g. 165536 — unreadable by a plain tar backup. With both pinned to 0, abc resolves to container root, which keep-id maps onto podman_user. The entrypoint still chowns db on every start, so the copy only has to be readable by podman_user.

The one visible side effect is this warning from Celery, once per start, in podman logs yamtrack:

SecurityWarning: You're running the worker with superuser privileges

That is Celery spotting uid 0 inside the container, where uid 0 is an unprivileged user on the host. Expected, and harmless in a rootless unit.

Like Caddy, podman_user is derived from the connection (ansible_facts['user_id']) 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.

The database stays SQLite

Yamtrack supports Postgres (DB_HOST, DB_NAME, DB_USER, DB_PASSWORD, DB_PORT), and this repo now has a shared Postgres unit. It is still not used here, on purpose:

  • The old data is SQLite. 2.3 MB in one db.sqlite3 file, owned by uid 1000 (docs/backup-plan.md §2.1). Moving it to Postgres is a data conversion — dumpdata/loaddata or a sqlite→postgres copy — with its own failure modes, and it buys nothing this deployment needs.
  • There is no yamtrack database in the restored cluster. The twelve databases brought across in postgres/migrate-from-monoco.md do not include one, which is the same fact seen from the other side.
  • A single file is not a worse fit here than a shared server. The app is a one-user media tracker; the whole database is smaller than a single query result in some of the other services.

The consequence to be deliberate about: the entrypoint runs manage.py migrate on every start, so an image bump with latest can migrate the schema, and that migration is not reversible by rolling the image back. The copy in Migrating the file data is the rollback, which is one more reason to take it before the first start and to keep monoco intact until this deployment has been lived with.

Moving to Postgres later is a normal, separate change: point the app at it, convert the data, and add a postgres role here exactly like Nextcloud's or Tandoor's (postgres/roles/attach plus a handler and a flush).

Settings

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 track.tanshu.com — the site block Caddy serves
container_name / unit_name yamtrack, and the Quadlet unit / container name
image ghcr.io/fuzzygrim/yamtrack:latest — fully qualified on purpose
network_name yamtrack → yamtrack.network / yamtrack_net
host_directory ~/podman-data/yamtrack
data_dir ~/podman-data/yamtrack/data → the image's /yamtrack/db
container_port 8000, the port inside the container that Caddy proxies to
env_file ~/podman-data/yamtrack/yamtrack.env, mode 0600
app_uid / app_gid 0 — container root, mapped by keep-id onto podman_user, and the entrypoint's PUID/PGID
yamtrack_tz Asia/Kolkata, the TZ the old container was given
yamtrack_secret The Django SECRET, carried over verbatim
urls https://track.tanshu.com, the old container's URLS (CSRF origins)
valkey_container / valkey_unit The shared cache unit this app attaches to
redis_url redis://valkey:6379, unchanged from the Docker-era container
caddy_name / caddyfile_path The shared Caddy unit and its Caddyfile

The three podman_* / quadlet_dir vars are duplicated from 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 — Yamtrack publishes nothing host-side.

SECRET, TZ and REDIS_URL are written to yamtrack.env at mode 0600 and read by systemd via EnvironmentFile=, not by Ansible. SECRET is the same value monoco uses, so existing sessions and password-reset tokens keep working — rotating it logs every user out. It is committed in plaintext as before; flagged as a follow-up, not changed here.

ALLOWED_HOSTS is deliberately absent: Yamtrack's default is *, which is what the Docker-era container relied on. Setting URLS — as the old playbook did — is what fills in the CSRF trusted origins.

Running

ansible-playbook -u tanshu yamtrack/playbook.yml

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 Yamtrack's own units and data. Its pre_tasks run Caddy's register include and nothing else. The rootless-Podman prerequisites — installing podman, loginctl enable-linger, the low-port sysctl and the Quadlet directory — are the shared task file at caddy/roles/podman/tasks/main.yaml, and are not run here. A host that has already run caddy/playbook.yml has them. Neither is Valkey deployed here: run valkey/playbook.yml first, or the flush in roles/valkey fails on a valkey.service that does not exist.

systemctl --user enable yamtrack is neither needed nor possible: the unit is generated, and Quadlet applies [Install] WantedBy=default.target itself.

Migrating the file data from monoco

There is no database to restore from Postgres for this app: the whole of its state is db.sqlite3, and the playbook does not move it because the file is on the old host while the playbook runs on the new one. Run this from the machine that holds the monoco and emily SSH aliases.

Copy before deploying. The entrypoint migrates the schema on first start, so a first run against an empty directory creates a new, empty database and the copy would then be overwriting a live one.

If a first run has already happened — the unit crash-looped, or a deploy was tried before the copy — that directory now holds a fresh database with every migration applied and no rows. Delete it before copying rather than copying on top of it, so no -wal/-shm from the empty database can mix with the real one:

# Prove it is the empty one before removing it: 0 users, 0 items, 0 episodes.
ssh emily 'podman exec -i yamtrack python3 -' <<'PY'
import sqlite3
c = sqlite3.connect("/yamtrack/db/db.sqlite3")
for t in ("users_user", "app_item", "app_episode"):
    print(t, c.execute("select count(*) from " + t).fetchone()[0])
PY

ssh emily 'systemctl --user stop yamtrack.service; rm -rf ~/podman-data/yamtrack/data'
# 1. Look before you copy: size, contents, and whether it is readable without
#    sudo. `ls -ln` shows numeric owners — the old data is uid 1000, which is
#    `ubuntu` on monoco and the container's `abc` inside it.
ssh monoco 'du -sh /var/lib/yamtrack/data; ls -ln /var/lib/yamtrack/data'
ssh monoco 'find /var/lib/yamtrack/data -type f ! -readable'   # empty = no sudo needed

# 2. Stop the old container so nothing writes while you copy. Leave it stopped:
#    two live copies of one SQLite database would diverge.
#
#    This is also what makes the copy *consistent*. SQLite runs in WAL mode here,
#    and a plain tar of a live database can produce a file that will not open —
#    the main file and the WAL are copied at different instants
#    (docs/backup-plan.md §3.1). A clean stop checkpoints the WAL into
#    db.sqlite3 and removes the sidecars, so the copy is a single frozen file.
ssh monoco 'docker stop yamtrack'

# 3. Create the destination.
ssh emily 'mkdir -p ~/podman-data/yamtrack/data'

# 4. Copy. tar streams it across the workstation; rsync cannot copy
#    remote-to-remote in one command.
#
#    The -wal/-shm sidecars are copied rather than filtered out on purpose: if
#    anything is still in the WAL, it is part of the database. After a clean stop
#    they are usually already gone, and copying "nothing" costs nothing.
#
#    `set -o pipefail` matters here: without it the pipeline reports only the
#    *receiving* tar's status, so a read error or a dropped stream on the source
#    side exits 0 and leaves a silently short copy.
set -o pipefail
ssh monoco 'tar -C /var/lib/yamtrack/data -cf - .' \
  | ssh emily 'tar --no-same-owner -C ~/podman-data/yamtrack/data -xf -'

#    If step 1 listed unreadable files, read them as root instead:
# ssh monoco 'sudo tar -C /var/lib/yamtrack/data -cf - .' \
#   | ssh emily 'tar --no-same-owner -C ~/podman-data/yamtrack/data -xf -'

# 5. Verify by content. Both sides are frozen by now (the old container is
#    stopped and nothing writes on emily yet), so identical digests mean a
#    byte-faithful copy.
ssh monoco 'cd /var/lib/yamtrack/data && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum'
ssh emily  'cd ~/podman-data/yamtrack/data && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum'

# 6. Nothing may end up owned by root or by a subuid.
ssh emily 'find ~/podman-data/yamtrack ! -user tanshu'    # empty = correct

# 7. Deploy.
cd prepare-server && ansible-playbook -u tanshu ../yamtrack/playbook.yml

Ownership needs no chown pass: the app runs as uid 1000 in-container, which keep-id maps to tanshu, so files extracted as tanshu are already right, and the entrypoint's own chown abc:abc db runs on every start anyway. That is also why --no-same-owner is explicit on the receiving side — the source is owned by uid 1000, and restoring that ownership would be wrong here.

If the link is slow or drops, do it in two hops instead: resumable, and it shows progress.

rsync -aP monoco:/var/lib/yamtrack/data/ ~/yamtrack-copy/
rsync -aP ~/yamtrack-copy/ emily:~/podman-data/yamtrack/data/

If stopping the app is unacceptable — for instance while rehearsing this deployment before the DNS move — ask SQLite for a consistent snapshot instead of copying the files while they are hot:

ssh monoco 'docker exec -i yamtrack python3 -' <<'PY'
import sqlite3
sqlite3.connect("/yamtrack/db/db.sqlite3").execute(
    "VACUUM INTO '/yamtrack/db/pre-migration.sqlite3'"
)
print("wrote /yamtrack/db/pre-migration.sqlite3")
PY

# then copy it as the database, and delete the snapshot on monoco afterwards
ssh monoco 'cat /var/lib/yamtrack/data/pre-migration.sqlite3' \
  | ssh emily 'cat > ~/podman-data/yamtrack/data/db.sqlite3'

VACUUM INTO needs SQLite 3.27+; the image's Python 3.12 has far newer. This still has to be followed by stopping the container before DNS moves: it shortens the window, it does not remove the cutover.

Rolling back means rolling back both halves: ssh monoco 'docker start yamtrack' is only meaningful while monoco's data and reverse proxy are intact, and the DNS record has to point back. monoco was never modified — its container was only stopped — so the rollback is starting it again.

Verification

systemctl --user status yamtrack
journalctl --user -u yamtrack -f
podman logs yamtrack

# `podman ps` shows (healthy) once the image's own probe passes.
podman ps --filter name=yamtrack
podman healthcheck run yamtrack

# When it does not pass, the health view names the failing check — cache,
# database, Redis or Celery — instead of just "unhealthy":
podman exec yamtrack wget -qO- http://127.0.0.1:8000/health/

# What the playbook checks for you:
podman exec yamtrack getent hosts valkey

# The file really is the migrated database, untouched by a first-start migrate:
podman exec yamtrack python3 -c "import sqlite3; print(sqlite3.connect('/yamtrack/db/db.sqlite3').execute('pragma integrity_check').fetchone()[0])"

# Row counts compared with monoco — equal numbers mean the library came across.
# (1517 items and 1312 episodes when this was written. Use `docker exec` on
# monoco, `podman exec` on emily.)
ssh monoco 'docker exec yamtrack python3 -c "import sqlite3; print(sqlite3.connect(\"/yamtrack/db/db.sqlite3\").execute(\"select count(*) from app_item\").fetchone()[0])"'
ssh emily  'podman exec yamtrack python3 -c "import sqlite3; print(sqlite3.connect(\"/yamtrack/db/db.sqlite3\").execute(\"select count(*) from app_item\").fetchone()[0])"'

# The site, end to end:
curl -sSI https://track.tanshu.com

Then log in in a browser and confirm the library, an episode's history and one metadata lookup feel right — the health check proves the process, not the data. Back it up with a write: mark an episode watched, then confirm on the host that db.sqlite3 changed and is owned by tanshu:

ssh emily 'ls -ln ~/podman-data/yamtrack/data'

A write landing as root or as a 165536+ subuid is the keep-id check, and a read-only smoke test will not catch it.

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

  • SECRET is committed in plaintext, as it was before. It is the same value monoco used, so sessions survive the move; changing it is a separate, user-visible decision (everyone is logged out, pending password-reset links break).
  • latest floats, and the entrypoint migrates. Neither this playbook nor the old one pins a digest or a version, so an image bump can run new Django migrations against the SQLite file on first start. Rolling the image back does not roll the schema back; the copy taken in Migrating the file data is the rollback. This is not hypothetical: monoco is running an image pulled in April (v0.25.2, 200 applied migrations) while emily pulled a newer latest, so the copied database gets 12 migrations on its first start under the new unit.
  • The entrypoint needs root, so keep-id's uid matters. See Who runs as whom — keep-id:uid=1000 produces a crash loop whose log looks like a database or permission problem and is neither. After that failure the data directory contains a fresh, empty database (0 users, 0 items) with every migration applied, which will happily serve an empty library if the copy has not landed. Check select count(*) from app_item before pointing DNS anywhere.
  • Yamtrack requires Redis. There is no "cache disabled" mode: the cache backend is django_redis and Celery needs the broker. If valkey is down, the site does not degrade — it fails.
  • staticfiles/ is not on the host. Unlike Tandoor, this image bakes its collected static files in, so ~/podman-data/yamtrack/data is the whole of what has to be backed up. The old container's /yamtrack showed staticfiles/ owned by abc; that is image content, not state.
  • Client IPs. Yamtrack's nginx sits behind Caddy, and whether the real client address survives the rootless port forward depends on the forwarder. See caddy/readme.md, "Client IPs", before relying on it.
  • Registering restarts all of Caddy, and attaching restarts Valkey for every cache client — a brief restart of every hosted site, and a cold cache everywhere. See the trade-offs in caddy/readme.md and valkey/readme.md.
  • If the Caddyfile does not exist yet, this playbook creates it containing only the site block. That is a valid Caddyfile; the Caddy playbook's seeded global-options block is then not added, because its seed only fires on a missing or zero-length file. Deploy Caddy first on a fresh host.
  • DNS. track.tanshu.com currently points at monoco (13.202.6.2) and must be repointed at emily (35.154.14.59) before Caddy can obtain a certificate for it. Until then the site answers from the old host.
  • Do not restart the container on monoco after the cutover. It would keep writing to the old database, and two live copies behind one hostname diverge silently.
  • There is no yamtrack-backup.sh. ~/bin on monoco has scripts for Nextcloud, Gitea, Bitwarden, Tandoor and others, but none for this app, and root's crontab has only the Nextcloud entry — so nothing has ever been scheduled (docs/backup-plan.md §11). Nothing has to be ported here, but the 2.3 MB file that is this service has no backup either: a systemd --user timer doing podman exec yamtrack python3 -c "…" "VACUUM INTO …" next to the Quadlet units is the shape that fits this host, because root cannot podman exec into another user's rootless containers.

Not done here

  • Rotating SECRET and the other secrets that are committed in plaintext.
  • Moving the database to the shared Postgres unit (see The database stays SQLite).
  • Replacing the missing backup with a systemd user timer (see above).