The playbook is meant to be usable on its own, on any host, the way every other one here is: the first run creates the container, and every run after that converges it. It had picked up a migration shape instead -- a readme that assumed a webroot copied in from monoco, a warning that nagged when config.php was missing, and, worse, readiness gates that only passed for an instance that was already installed. That last one was a real bug for the intended use. On a host with no webroot, the image initialises the code, writes no config.php because it has no admin credentials to install with, and serves the setup wizard -- so `occ status` never reports installed: true, and the play would have spent its whole retry budget and then failed on a first run that was going exactly as designed. What replaces it: - The readiness gate is Apache answering status.php inside the container, which is Nextcloud's own endpoint and reports `installed` either way. It is the one thing `is-active` cannot tell us, and it passes for a wizard that has not been completed. - config.php's presence then decides the report: wait for maintenance mode to clear and print `occ status` for an instance, or say what is left for a human to do for a fresh one -- and succeed. The two optional credentials in vars/default.yml install unattended instead, in which case the same endpoints come back installed and the wizard note never appears. - The app creates its own database. Postgres is a shared unit with one default database and nothing else in this repo knows this app's database name, so a fresh cluster previously reached the installer and failed on "database nextcloud does not exist". It is a lookup and a `createdb`, a no-op afterwards, and it fails with the instruction to run postgres/playbook.yml if the cluster is not there rather than with a bare psql error. - Updates. An update in a Quadlet deployment is "pull, then recreate": the unit names a tag, `podman run` reuses the local image, and nothing fetches a newer one. The role pulls on every run and compares the image ID either side, so an unchanged tag is not a change and a running site is not restarted for nothing. - Handlers are flushed before verification, so a queued recreate -- new image, changed unit, changed environment -- happens before the checks rather than after them, and the checks describe what will keep running. Removed: the entire monoco migration section and its copy commands, the config.php warning that existed only to serve it, the host-specific notes, and the narrative comments about this play's first runs. References to the audit and migration planning docs go with them; the ownership reasoning is stated inline, since it does not depend on where an instance came from. `hosts:` is now the only host-specific thing in the directory. The readme is rewritten around the two paths it now has -- "First run" and "Updates" -- with the deploy order, the session-file trap and the debugging commands kept.
Nextcloud (rootless Podman + Quadlet)
Runs Nextcloud as a rootless Podman container managed by a systemd user unit (Quadlet), behind the shared rootless Caddy, with its database in the shared rootless Postgres and its cache in the shared rootless Valkey.
Standalone and idempotent, and host-agnostic apart from hosts: in
playbook.yml and host in vars/default.yml:
- First run creates the webroot, the environment file, the unit and the background-job timer, starts the container, and leaves a fresh instance ready for its setup wizard — or installs it unattended, if you set the two optional credentials first.
- Every run after that converges the same things, and Quadlet recreates the container when the unit, the environment or the image changed. There is no update step to remember and nothing to copy in by hand.
Nextcloud is the one service here whose webroot is also its code directory: the
image's VOLUME, config/config.php, data/, custom_apps/ and every PHP file
live under one mount. That single fact drives the ownership story and the backup
guidance below — see What is state, and what is not.
Deploy order
Three shared services have to exist on the host first, each of them standalone:
- caddy/playbook.yml — the reverse proxy, which owns the
Caddyfile this site appends to, supplies the
caddy.servicehandlers, and is the one play that applies the rootless-Podman prerequisites. - postgres/playbook.yml — the cluster. This play
attaches to it and creates its own
nextclouddatabase in it, so the cluster can be brand new and empty. - valkey/playbook.yml — the cache. It has to exist
before this playbook runs: the attach include only writes drop-ins next to
valkey.container, so aRestart Valkeyhandler against an undeployed unit fails.
Then this playbook. config.php connects to the hosts db and redis; until it
has attached nextcloud_net to Postgres and to Valkey, neither name resolves on
that network, so running this playbook is what makes the app reachable.
Layout
nextcloud/
├── playbook.yml
├── vars/default.yml
└── roles/
├── postgres/ # the database, and this app's network on the cluster
├── valkey/ # this app's network on the cache
├── nextcloud/ # webroot, env file, Quadlet unit, session config,
│ # background-job timer, start + verify
└── caddy/ # appends this site's block to the shared Caddyfile
Quadlet units and state:
| On the host | What |
|---|---|
~/.config/containers/systemd/nextcloud.container |
the generated unit (Ansible-templated) |
~/.config/containers/systemd/nextcloud.network |
the app network, written by caddy/roles/register |
~/.config/containers/systemd/caddy.container.d/50-nextcloud.conf |
attaches Caddy to that network |
~/.config/containers/systemd/postgres.container.d/50-nextcloud.conf |
attaches Postgres to it |
~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf |
the shared db alias |
~/.config/containers/systemd/valkey.container.d/50-nextcloud.conf |
attaches Valkey to it |
~/.config/containers/systemd/valkey.container.d/10-network-aliases.conf |
the shared redis alias |
~/.config/systemd/user/nextcloud-cron.{service,timer} |
the background-job timer (not Quadlet) |
~/podman-data/nextcloud/ |
the whole /var/www/html: code, config/config.php, data/, custom_apps/ |
~/podman-data/nextcloud/nextcloud.env |
POSTGRES_* + REDIS_HOST, mode 0600, read by systemd |
~/podman-data/nextcloud/php-conf.d/redis-session.ini |
PHP's session handler, mounted into the image |
~/podman-data/nextcloud/nextcloud-init-sync.lock |
the entrypoint's init lock, pre-created so it stays host-owned |
What is state, and what is not
The webroot is about 1.2 GB on a working instance, and most of it is not worth backing up:
| Path | Keep? | Why |
|---|---|---|
data/ |
required | user files, appdata_*, versions, thumbnails, the log |
config/ |
required | config.php — without it the instance is not restorable, and neither is the encryption material |
custom_apps/ |
required | apps installed from the app store |
themes/ |
if themed | only exists when a theme was installed |
version.php, core/, lib/, apps/, 3rdparty/, dist/ |
regenerable | shipped by the image, rsynced into the webroot on start |
The database is separate and is not in this directory at all. A backup that
matters is config/, data/, custom_apps/ and a pg_dump of the
nextcloud database taken together — files without the database restore to an
instance that does not know about its own contents, and the reverse is just as
useless. Nextcloud's own guidance is occ maintenance:mode --on around the pair;
the play waits for maintenance mode to clear on the next run, so a forgotten
--on is reported rather than silently 503ing.
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 Nextcloud'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
nextcloud.container references nextcloud.network: Quadlet fails unit
generation — so systemctl --user start reports "unit not found" — when a
referenced .network file does not exist yet. Both attaches below depend on it
for the same reason.
network_name is nextcloud, so the file is nextcloud.network and the
Podman network is nextcloud_net.
The site block is appended to the shared Caddyfile by
roles/caddy with blockinfile, exactly as the
other services do — plus the HSTS header Nextcloud asks for. There is no
request-body limit to set: Caddy streams request bodies and imposes none by
default.
How it attaches to Postgres
Postgres knows nothing about its clients, and this playbook uses the shared
include in postgres/roles/attach
rather than writing the drop-ins itself. That writes two files, both mergeable
into postgres.container by Quadlet:
| File | Content |
|---|---|
postgres.container.d/50-nextcloud.conf |
Network=nextcloud.network |
postgres.container.d/10-network-aliases.conf |
NetworkAlias=db |
db is the alias config.php connects to; the postgres container name resolves
too, and the play checks both.
roles/postgres wraps that include for three reasons:
- it defines the
Restart Postgreshandler the include notifies; - it flushes handlers immediately afterwards. On a first attach the drop-in is
new, so Postgres has to be recreated to join
nextcloud_net— and that restart is a handler, which by default runs at the end of the play, after thenextcloudrole has already started the app. The app would then come up beforedbresolves, fail its readiness check, and fail the play on a machine where nothing is wrong. The flush also fires theRestart Caddyqueued by the register pre_task, which is what attaches Caddy to the network. On later runs nothing is pending and it is a no-op; - it creates the
nextclouddatabase if the cluster does not have it yet. Postgres is a shared unit that ships one default database, and nothing else in this repo knows what this app's database is called, so without that task a first run on a fresh host would reach the setup wizard only to fail on "database nextcloud does not exist". It is acreatedbguarded by a lookup, so it is a no-op on every run after the first.
Note the cost stated in postgres/readme.md: a new drop-in recreates Postgres, which is a few seconds of downtime for every attached app, not just this one.
How it attaches to Valkey
The same idea at the cache end, via valkey/roles/attach:
| File | Content |
|---|---|
valkey.container.d/50-nextcloud.conf |
Network=nextcloud.network |
valkey.container.d/10-network-aliases.conf |
NetworkAlias=redis |
redis is not a name this playbook chose: the image writes the Redis host into
config.php and into PHP's session config from REDIS_HOST, which is redis
here. Renaming it would mean editing a live instance's configuration for nothing.
It is also not optional. Go through the setup wizard and Nextcloud puts
memcache.locking on Redis; the image's own install does too when the unattended
credentials are set. An unresolvable redis therefore does not merely make things
slower — uploads fail, and they fail at write time rather than at startup. That is
why roles/valkey flushes handlers before the app starts (the same reason as
Postgres) and why REDIS_HOST is in the environment file at all.
The cost of the attach is a recreate of Valkey: no keys are lost (RDB persistence
is on by default, the stop is graceful, and the new container loads the snapshot
back off the bind mount), but for the seconds it is down, PHP sessions and
memcache.locking are unreachable. See valkey/readme.md,
"The trade-off".
Background jobs
Nextcloud expects cron.php every five minutes. It cannot be a crontab entry:
root cannot podman exec into another user's rootless containers, and there is no
container user to own a crontab. The role installs a systemd user pair next to
the Quadlet units instead:
| Unit | What it does |
|---|---|
~/.config/systemd/user/nextcloud-cron.service |
Type=oneshot, podman exec -u www-data nextcloud php /var/www/html/cron.php |
~/.config/systemd/user/nextcloud-cron.timer |
OnCalendar=*:0/5, Persistent=true |
Unlike the Quadlet units these are plain units, so they really are enabled —
that is what brings the timer back after a reboot. journalctl --user -u nextcloud-cron shows every run and every failure.
The background-job mode is left alone. Both this timer and AJAX polling run
jobs, and job locking keeps them from colliding; switching AJAX off is
occ background:cron, a deliberate change rather than something this play
imposes.
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 |
cloud.hopsngrains.com — the hostname Caddy serves, and the trusted domain for an unattended install |
container_name |
nextcloud, and the Quadlet unit / container name |
image |
docker.io/library/nextcloud:latest — fully qualified on purpose |
network_name |
nextcloud → nextcloud.network / nextcloud_net |
host_directory / webroot |
~/podman-data/nextcloud → /var/www/html |
app_uid / app_gid |
33 — www-data, the uid the webroot is owned by |
container_port |
80, the port inside the container that Caddy proxies to |
env_file |
~/podman-data/nextcloud/nextcloud.env, mode 0600 |
php_conf_dir / php_session_ini |
PHP's session config on the host, and the path in the image it is mounted over |
postgres_container / postgres_unit |
The shared database unit this app attaches to |
pg_user / pg_password / pg_database |
Credentials, and the database the play creates and the app uses — nextcloud |
db_host |
db — the alias config.php connects to |
valkey_container / valkey_unit |
The shared cache unit this app attaches to |
redis_host |
redis — the alias config.php and PHP sessions connect to |
nextcloud_admin_user / nextcloud_admin_password |
Empty by default; set both to install unattended |
cron_unit / cron_schedule / user_unit_dir |
The background-job timer |
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 — Nextcloud publishes nothing host-side.
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.
UserNS=keep-id:uid=33,gid=33. 33 is www-data, the uid the webroot is owned
by: the entrypoint rsyncs the code in with --chown www-data:www-data and Apache
runs its workers as that account. keep-id maps that one uid onto the connection
user, so the whole webroot stays owned by tanshu and plain tar and rm -rf
keep working on it.
Two paths the entrypoint writes fall outside that arrangement, and both are
handled explicitly rather than left to chance — each is somewhere a naive
deployment ends up with a file owned by a 165536+ subuid, or with no running
container at all:
/var/www/html/nextcloud-init-sync.lock, which the entrypoint creates with a root-owned shell redirect. The role pre-creates it aspodman_user: the redirect truncates the file rather than recreating it, so its owner never changes afterwards./usr/local/etc/php/conf.d/redis-session.ini, in the image, which is not writable at all. It has its own section below.
POSTGRES_PASSWORD is committed in plaintext, like the other playbooks' shared
123456. It lives in nextcloud.env at mode 0600 rather than in the
world-readable unit file; a real secret store is the next step, and it is a
one-file change here plus the pg_password vars in the app playbooks.
The PHP session file
The webroot is not the only thing the entrypoint writes. With REDIS_HOST set —
and it is, because PHP sessions should survive a redeploy — the entrypoint
generates that session configuration on every start:
/entrypoint.sh:121
} > /usr/local/etc/php/conf.d/redis-session.ini
That directory belongs to the image's root, created by the php base image with no
chown:
RUN set -eux; \
mkdir -p "$PHP_INI_DIR/conf.d"; \
...
so the uid this container runs as has no write bit on it, and the redirect fails:
cannot create /usr/local/etc/php/conf.d/redis-session.ini: Permission denied
The entrypoint exits 2 before Apache is ever reached, Restart=always retries
it, systemd hits its start limit, and the unit ends up failed — with the
whole thing looking like a network or database problem, because that message only
appears in the journal.
The fix is a mount: nextcloud.container binds
~/podman-data/nextcloud/php-conf.d/redis-session.ini over that exact path. A
bind-mounted file replaces the directory entry, so the directory's ownership stops
mattering and the write lands on a file owned by podman_user. The role also
templates the file with the five lines the entrypoint would have written, so a
start where the write is somehow refused still gets working Redis sessions instead
of a crash loop.
Apache's own runtime directories need none of this — the same base image chowns
/var/run/apache2, /var/lock/apache2 and /var/log/apache2 to www-data and
chmods them 1777:
for dir in "$APACHE_LOCK_DIR" "$APACHE_RUN_DIR" "$APACHE_LOG_DIR" \
"$APACHE_RUN_DIR/socks"; do
rm -rvf "$dir"; mkdir -p "$dir"; \
chown "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$dir"; \
chmod 1777 "$dir"; \
done
so the session file is the only write that was ever going to fail.
First run
ansible-playbook -u tanshu nextcloud/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.
On a host with no webroot yet, this is what happens, in order: the webroot and the
PHP session directory are created; the 0600 environment file, the session config
and the Quadlet unit are written; the unit is started; anything the run queued (a
new image, a changed unit or environment) is applied before verification; and
then the play waits for Apache to answer status.php inside the container.
At that point there is a running container and no instance yet — the image
initialises the code, writes no config.php (it has no admin credentials to
install with), and serves the setup wizard. The play says so and succeeds:
The container is running, but no instance is installed in
/home/tanshu/podman-data/nextcloud yet. Open https://cloud.hopsngrains.com and
finish the setup wizard with the database credentials in .../nextcloud.env —
database nextcloud, user postgres, host db. ...
Three things to know about that wizard:
-
DNS first.
hostmust resolve to this host before Caddy can obtain a certificate for it, and before the wizard is reachable at all. -
The database already exists. roles/postgres created it, or verified it, before the container started. Point the wizard at host
db, databasenextcloud, userpostgres, and the password innextcloud.env. -
trusted_domains. The wizard installs withlocalhostas the only trusted domain unless you tell it otherwise, so opening the finished instance onhostcan answer "Access through untrusted domain". Add it before or after:podman exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=cloud.hopsngrains.com
To skip the wizard entirely, set nextcloud_admin_user and
nextcloud_admin_password in vars/default.yml before the first
run. The entrypoint then runs occ maintenance:install itself and sets
trusted_domains from host in the same pass, and the play reports the installed
version instead of the wizard note. Both variables are inert once config.php
exists, so they can stay set (or be removed) afterwards.
Updates
Nothing special is required: re-run the playbook. What happens depends on what changed.
| Changed | What the play does |
|---|---|
| The unit, the environment file or the session config | re-templates it, and Quadlet recreates the container |
| A new image behind the tag | pulls it, notices the ID changed, and recreates the container |
| Nothing | nothing — no restart, no churn |
The image is pulled on every run precisely because a Quadlet unit alone cannot see
a tag move: the unit names nextcloud:latest, podman run reuses whatever image
is local, and nothing fetches a newer one. The role reads the image ID either side
of the pull, so an unchanged tag reports no change and does not restart a running
site. A pull that fails (a host with no route to the registry) is reported and the
run continues with the local image.
Version drift is the one irreversible step. The entrypoint compares
version.php in the webroot against the image. A newer image rsyncs its code in
and runs occ upgrade before Apache answers — and it will only step one major
version at a time, so several majors behind means several runs with an
intermediate image. An image older than the installed version makes the
container exit with "downgrading is not supported". So: pin the tag if you want
no surprises, and take a database dump before a run that is going to upgrade.
occ upgrade sets maintenance mode for its duration, and the play waits for it
to clear rather than asserting it is off — so a long upgrade is tolerated, and an
instance left in maintenance mode (a backup that set --on and never finished) is
reported with the one-line command to clear it. That distinction matters because
status.php is exempt from maintenance mode: a site in that state looks healthy
to a probe while returning 503 to every real request.
Verification
systemctl --user status nextcloud
journalctl --user -u nextcloud -f
podman logs nextcloud
# What the playbook checks for you:
podman exec -u www-data nextcloud php occ status # installed, version, maintenance
podman exec nextcloud getent hosts db # config.php's database host
podman exec nextcloud getent hosts redis # config.php's cache host
podman exec nextcloud getent hosts postgres valkey # the container names
# The background job, and that it is actually scheduled:
systemctl --user list-timers nextcloud-cron.timer
systemctl --user start nextcloud-cron.service # run one by hand
journalctl --user -u nextcloud-cron -n 20
# The database this play created, and its size:
podman exec -e PGPASSWORD=123456 postgres psql -U postgres -l | grep nextcloud
# The site, end to end:
curl -sSI https://cloud.hopsngrains.com
Then open it in a browser and do two things a curl cannot: log in, and
upload a file. Uploading is the check that matters — it exercises the uid-33
mapping (a wrong keep-id shows up as a permissions error), the Redis file
locking in config.php, and PHP sessions, which the image configures from
REDIS_HOST.
After the upload, confirm on the host that the new file is owned by tanshu:
find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu
# empty = the write path lands on the right uid
Then reboot the host and confirm both the container and the timer return
unattended — without loginctl enable-linger tanshu neither will, and the failure
is silent.
Debugging a unit that will not start
A unit that has hit systemd's start limit reports only a state, and Restart=always
means the interesting output is in the journal, not in podman logs (the container
may already be gone — Quadlet passes --rm). The play collects all of this itself
when a start fails; by hand:
systemctl --user status nextcloud.service --no-pager -l | head -25
systemctl --user cat nextcloud.service # what systemd is running
journalctl --user -u nextcloud.service --no-pager -n 60
podman ps -a --filter name=nextcloud
podman logs --tail 40 nextcloud
Read it in this order:
journalctl— the entrypoint's own output.cannot create ...: Permission deniedfor a path in/usr/local/etc/php/conf.dis the session-file problem above; anything aboutdb/redisis the network attach;downgrading is not supportedis version drift.podman ps -a— a container left behind under this name makespodman runrefuse to start.systemctl --user cat— the generated unit, if a drop-in or aNetwork=is in question (the usual failure is a.networkfile that does not exist).
A failed unit needs its start-limit counter cleared before it will try again:
systemctl --user reset-failed nextcloud.service
systemctl --user start nextcloud.service
Notes and gotchas
-
REDIS_HOSTmakes the entrypoint write into the image, which it is not allowed to do./usr/local/etc/php/conf.dbelongs to the image's root, so the crash is aPermission deniedonredis-session.ini, the unit ends upfailedvia systemd's start limit, and nothing about it looks like a permissions problem. The unit mounts a host-owned file over that path; see "The PHP session file". -
activatingis not a healthy state. A crash-looping container spends its timeactivatingbetween restarts, so the play's wait retries only while the state can still change on its own and otherwise hands over to a rescue that has the logs. A bareis-activecheck would report either state as a failure with no explanation. -
A new drop-in restarts Postgres for everyone (a few seconds without a database for every attached app), registering restarts all of Caddy (a brief restart of every hosted site) and attaching restarts Valkey (seconds without sessions or locks, though the keys survive the graceful stop). See the trade-offs in postgres/readme.md, 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.
-
Client IPs. Nextcloud uses the client address for brute-force protection and for the
X-Forwarded-Forit logs, so the reverse proxy has to be trusted — see caddy/readme.md, "Client IPs", which also covers whether the original address survives the rootless port forward at all. -
The webroot holds the code. That is the image's design, not this playbook's: an image bump edits
~/podman-data/nextcloud. It is also whyapps/,core/andlib/are listed as regenerable above — they are, but only from an image that is the same version or newer. -
occmust run aswww-data. It refuses to run as any other user, and underkeep-id:uid=33that is the same user the host sees astanshu. Everypodman execin this file therefore carries-u www-data; the plain form works forgetent, which does not care. -
The backup scripts are not this playbook's business. Whatever dumps the database and archives the webroot has to run as a systemd user timer next to the Quadlet units: root cannot
podman execinto another user's rootless containers. Dump in place rather than through a helper container —--linkdoes not exist in Podman:podman exec -e PGPASSWORD="..." postgres pg_dump -U postgres nextcloud | bzip2 > nextcloud.sql.bz2
Not done here
- Rotating the secrets that are committed in plaintext —
pg_passwordis the fleet's shared123456, and it lives innextcloud.envat mode0600rather than in the world-readable unit file. - Switching the background-job mode to
cron(occ background:cron). - Pinning the image to a digest, so that a
latestbump is always deliberate. - A backup timer for the webroot and the database.