Run Nextcloud as a rootless Podman Quadlet

Replaces the docker_container playbook, which bind-mounted /var/lib/nextcloud at
/var/www/html on monoco and ran cron.php from root's crontab. Nextcloud now runs
on emily behind the shared Caddy, with its database in the shared Postgres and
its cache in the shared Valkey, each attached the way every other service
attaches to them.

/var/www/html is the unusual part: it holds the code as well as the state --
version.php, core/, lib/ and apps/ alongside config/config.php, data/ and
custom_apps/ -- and the entrypoint's version check reads version.php from the
mount. That is why the migration copies the whole tree rather than only the three
irreplaceable directories (docs/backup-plan.md 2.1 lists the rest as regenerable,
and they are, but only from an image that is the same version or newer). It is
also what makes an image bump an in-place upgrade: a newer image rsyncs its code
in and runs `occ upgrade` on first start, and an older one refuses to start at
all, so the version on both hosts is worth checking before the copy.

UserNS=keep-id:uid=33,gid=33. 33 is www-data, the uid the server actually writes
as -- `docker exec nextcloud id -u` reports 0, because the entrypoint drops
privileges after starting (docs/podman-compat-audit.md 3.2). 33 is also what the
data is owned by on monoco, so the copy needs no chown pass: extracting as the
connection user is already the ownership the container expects. One file is
created by container-root and never chowned, the entrypoint's
nextcloud-init-sync.lock, so the role pre-creates it -- the entrypoint truncates
that file rather than recreating it, so its owner then never changes, and the
webroot ends up with no subuid-owned file in it.

The Valkey attach is not optional. config.php carries memcache.locking pointed at
Redis, written by the image from REDIS_HOST, and the same variable rewrites PHP's
session-handler config on every start -- so an unresolvable `redis` does not just
make things slower, it fails uploads at write time. Hence the `redis` alias
rather than the container name, and hence roles/valkey flushing handlers before
the app starts, the same as roles/postgres.

cron.php moves from root's crontab to a systemd user timer, because root cannot
`podman exec` into another user's rootless containers and there is no container
user to own a crontab (docs/podman-migration.md 8b). Unlike the Quadlet units
this pair is plain and is genuinely enabled, which is what brings it back after a
reboot; journalctl --user -u nextcloud-cron shows every run, which the crontab
never did.

Readiness is three gates, because no one of them is sufficient: is-active (which
Restart=always satisfies even while crash-looping), `occ status` reporting
installed, and status.php served over loopback by Apache rather than by the PHP
CLI. The second is followed by a wait for maintenance mode to clear rather than
an assert: maintenance mode is on for the whole of a first-start `occ upgrade`,
so asserting it off would fail a healthy deploy halfway through. The case that
never clears is a config.php copied with `maintenance => true`, which
docs/backup-plan.md 3.4 tells an operator to set before a consistent dump, and
which is invisible from outside because status.php is exempt from maintenance
mode -- so the play names it and prints the command that clears it.

Register and attach ordering is the same as the other service playbooks: register
runs in pre_tasks, because the unit references nextcloud.network and Quadlet
fails unit generation when that file does not exist, and both attaches flush
handlers before the app starts, which is also what pulls Caddy onto the network.

The Caddy snippet is unchanged, HSTS header included; only caddyfile_path moves,
from /var/lib/caddy/conf/Caddyfile to ~/podman-data/caddy/conf/Caddyfile. The
Docker-era roles/network is deleted rather than ported: it existed to attach an
already-created container to a network with the `redis` alias, and
podman_network has no connected:/appends: equivalent
(docs/podman-compat-audit.md 3.3). Nothing is published host-side; Caddy reaches
nextcloud:80 over nextcloud_net.

The rootless-Podman prerequisites are not applied here: they are the shared task
file at caddy/roles/podman, and a host that has run caddy/playbook.yml has them.
valkey/playbook.yml has to have been run first, or the first Restart Valkey
handler fails against a unit that does not exist yet.
This commit is contained in:
tanshu committed 2026-10-16 08:44:14 +05:30
1 parent 3587bc9001
commit 43ea43e88b
16 files changed
+1307 -88

No files matched your search

+101 -17
View File
@@ -1,28 +1,112 @@
---
container_name: nextcloud
container_image: nextcloud:latest
docker_network: nextcloud_net
postgres_container: postgres
caddy_container: caddy
redis_container: valkey
# ---------------------------------------------------------------------------
# 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: Nextcloud listens on
# 80 inside the container and 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"
# ---------------------------------------------------------------------------
# Nextcloud
# ---------------------------------------------------------------------------
host: cloud.hopsngrains.com
caddyfile_path: /var/lib/caddy/conf/Caddyfile
container_name: nextcloud
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. The Docker-era playbook said `nextcloud:latest`.
image: docker.io/library/nextcloud:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: nextcloud.network,
# nextcloud_net).
network_name: "{{ container_name }}"
# The image's VOLUME and Apache document root — and, unlike the other services
# here, it holds the *code* as well as the state: config.php, data/,
# custom_apps/ and every app's PHP files live under it. The Docker-era playbook
# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that
# shape on purpose. See readme.md, "What is state, and what is not".
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
webroot: /var/www/html
# The uid the *server* writes as: the image's entrypoint starts as container-root,
# rsyncs the code in with --chown www-data:www-data, and Apache then drops to
# www-data. `docker exec nextcloud id -u` reports 0, which is misleading
# (docs/podman-compat-audit.md §3.2).
app_uid: 33
app_gid: 33
# The port Apache listens on inside the container, and the one Caddy proxies to.
container_port: 80
# POSTGRES_* and REDIS_HOST are read by the entrypoint on every start — REDIS_HOST
# in particular rewrites PHP's session handler config — and POSTGRES_PASSWORD is in
# there, so it goes in this 0600 file rather than in the world-readable unit.
env_file: "{{ host_directory }}/nextcloud.env"
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
# handler that applies them.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The credentials and database name the Docker-era playbook embedded in its
# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB,
# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree.
pg_user: postgres
pg_password: '123456'
pg_database: nextcloud
# `db` is the host config.php actually connects to — the Docker-era container set
# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared
# Postgres unit.
db_host: db
# The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and
# the alias it is reached by. REDIS_HOST is not cosmetic: config.php's
# `memcache.locking` is Redis, so an unresolvable `redis` breaks uploads rather
# than merely slowing them down.
valkey_container: valkey
valkey_unit: "{{ valkey_container }}.service"
redis_host: redis
# 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"
# ---------------------------------------------------------------------------
# Background jobs
# ---------------------------------------------------------------------------
# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in
# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root
# cannot `podman exec` into another user's rootless containers, so it becomes a
# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)).
cron_unit: "{{ container_name }}-cron"
cron_schedule: "*:0/5"
user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user"
# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and
# Nextcloud has always asked for HSTS. There is no request-body limit to carry
# over — Caddy streams bodies and imposes none by default, whereas the dead nginx
# front end needed client_max_body_size spelled out.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:80
reverse_proxy {{ container_name }}:{{ container_port }}
header {
Strict-Transport-Security "max-age=15552000; includeSubDomains; preload"
}
}
host_directory: nextcloud
db_name: "nextcloud"
db_user: "postgres"
db_pass: "123456"
db_host: "db"