Files
playbooks/nextcloud/readme.md
T
tanshu c3e0e4989c Do not create a host file for the entrypoint's PHP session config
The role created ~/podman-data/nextcloud/redis-session.ini and the unit bind-mounted
it over the image path, purely because the entrypoint cannot write there itself.
Removed: the task, the mount, and -- because it is the only thing that triggers the
write -- REDIS_HOST from the environment file. The entrypoint now skips
configure_redis_session entirely and starts.

The Redis attach stays. The valkey role still puts nextcloud_net on the cache
container with the alias `redis`, so an instance whose config.php names that host
reaches it. Writing that configuration is config.php's business, and the readme
gives the three `occ config:system:set` lines for anyone who wants it, including
that this playbook will not do it for them.

The only thing lost is PHP session storage, which falls back to the container's own
filesystem -- a redeploy logs users out. That is the trade for not shipping a file
whose sole purpose is to work around a directory the image owns as root.

The env template and the valkey role comment both say why REDIS_HOST is absent, so
that adding it back is recognisable as the regression it is.
2026-10-16 15:44:33 +05:30

14 KiB

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, creates the database if the cluster does not have it, and starts the container. A fresh webroot then serves the setup wizard — or installs itself unattended, if you set the two optional credentials first.
  • Every run after that converges the same things. Quadlet recreates the container when the unit or the environment changes, so there is no update step to remember.

Deploy order

Three shared services have to exist first, each standalone:

  1. caddy/playbook.yml — the reverse proxy. It owns the Caddyfile this site appends to, supplies the caddy.service handlers, and is the one play that applies the rootless-Podman prerequisites.
  2. postgres/playbook.yml — the cluster. This playbook attaches to it and creates its own nextcloud database in it, so the cluster can be brand new and empty.
  3. valkey/playbook.yml — the cache.

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, unit, cron
    └── caddy/        # the site block in the shared Caddyfile
On the host What
~/.config/containers/systemd/nextcloud.container the generated unit
~/.config/containers/systemd/nextcloud.network the app network
~/.config/containers/systemd/*.container.d/50-nextcloud.conf attaches Caddy, Postgres and Valkey to it
~/.config/containers/systemd/*.container.d/10-network-aliases.conf the shared db and redis aliases
~/podman-data/nextcloud/ the whole /var/www/html
~/podman-data/nextcloud/nextcloud.env POSTGRES_*, mode 0600

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. A backup that matters is config/, data/, custom_apps/ and a pg_dump of the nextcloud database taken together; either half alone restores to an instance that does not know its own contents.

How it wires itself up

Caddy. caddy/roles/register writes nextcloud.network and a drop-in that attaches Caddy to it. This runs in pre_tasks, because the unit references nextcloud.network and Quadlet fails unit generation — systemctl --user start then reports "unit not found" — when that file does not exist. The site block goes into the shared Caddyfile with blockinfile, marker and all, so re-runs replace it in place.

Postgres. postgres/roles/attach writes two drop-ins next to postgres.container: Network=nextcloud.network and the shared NetworkAlias=db. roles/postgres wraps it to define the Restart Postgres handler, to flush handlers afterwards (a new drop-in means Postgres must be recreated to join the network, and that restart would otherwise happen after the app started), and to create the nextcloud database if it is missing. A new drop-in is a few seconds of downtime for every attached app; see postgres/readme.md.

Valkey. The same, via valkey/roles/attach, with NetworkAlias=redis: the attach puts nextcloud_net on the cache container and makes the host redis resolve from Nextcloud. What uses it is config.php, and this playbook does not write that — see "Redis" below. The recreate costs a few seconds of cache availability, and the keys survive it (see valkey/readme.md).

Background jobs

Nextcloud expects cron.php every five minutes. The job goes in this user's crontab, not root's: root cannot podman exec into another user's rootless containers, which is the one real constraint here. Nothing else is needed — the caddy playbook enables lingering, so /run/user/<uid> exists whether or not anyone is logged in, which is where podman keeps the state exec reads.

*/5 * * * * /usr/bin/podman exec -u www-data nextcloud php /var/www/html/cron.php

It is a plain ansible.builtin.cron entry, so it is one task and no unit files, and crontab -l shows it. The trade-off is that output goes to local mail rather than to a journal: if you would rather have that, a systemd user timer next to the Quadlet units is the alternative, and it is two small files.

Settings

Variable Value / purpose
podman_user Derived from the connection (ansible_facts['user_id'])
podman_data_dir / quadlet_dir ~/podman-data, ~/.config/containers/systemd
host cloud.hopsngrains.com — what Caddy serves, and the trusted domain for an unattended install
container_name nextcloud, and the 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, what Caddy proxies to
env_file ~/podman-data/nextcloud/nextcloud.env, mode 0600
pg_* / db_host The database, its credentials, and the db alias
valkey_container / valkey_unit The cache unit this app attaches to
nextcloud_admin_user / nextcloud_admin_password Empty by default; set both to install unattended
cron_minute The cron.php interval
caddy_* The shared Caddy unit and its Caddyfile

The three podman_* / quadlet_dir vars are duplicated from caddy/vars/default.yml because a play cannot include that file wholesale — it also sets host_directory. Keep them in sync. Like Caddy, the play carries no become, so there is no guard against connecting as root; that check lives 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 as that uid and Apache runs its workers as it. keep-id maps that one uid onto the connection user, so the webroot stays owned by tanshu and plain tar and rm -rf keep working on it.

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.

Redis

The Valkey attach above makes the host redis resolve from the container, and that is all this playbook does about Redis: which caches Nextcloud uses is config.php's business, and this playbook does not write it.

It deliberately does not set REDIS_HOST, which is the one Redis variable the image acts on. That variable exists to write PHP's session config, with a redirect into /usr/local/etc/php/conf.d — a directory the image owns as root and this container cannot write:

cannot create /usr/local/etc/php/conf.d/redis-session.ini: Permission denied

The entrypoint exits 2 before Apache is reached, Restart=always retries it, and systemd's start limit marks the unit failed. Leaving REDIS_HOST unset skips that write entirely; the only thing lost is PHP session storage, which falls back to the container's own filesystem.

So to put Nextcloud's caches on Valkey, set it in config.php — memcache.locking and memcache.distributed to \OC\Memcache\Redis, and redis host to redis:

podman exec -u www-data nextcloud php occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
podman exec -u www-data nextcloud php occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
podman exec -u www-data nextcloud php occ config:system:set redis host --value=redis

First run and updates

ansible-playbook -u tanshu nextcloud/playbook.yml

A fresh webroot is a valid state, and the play succeeds: the image initialises the code, writes no config.php (it has no credentials to install with), and serves the setup wizard. The readiness gate is Nextcloud's own status.php inside the container — it answers only once the entrypoint has finished and Apache is up, and it reports installed either way, so the same gate covers a wizard that has not been completed yet and an instance that is being updated.

Three things about the wizard:

  • DNS first. host must resolve here before Caddy can get a certificate, 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, database nextcloud, user postgres, and the password in nextcloud.env.
  • trusted_domains. The wizard installs with localhost as the only trusted domain, so the finished instance can answer "Access through untrusted domain" on host. Fix it with podman exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=cloud.hopsngrains.com.

To skip the wizard, set nextcloud_admin_user and nextcloud_admin_password before the first run. The entrypoint then runs occ maintenance:install itself and sets trusted_domains from host in the same pass. Both are inert once config.php exists.

Updates are a re-run: a changed unit or environment is re-templated and Quadlet recreates the container. An image bump is two commands, because a latest tag moving is not something the unit can see:

podman pull docker.io/library/nextcloud:latest && systemctl --user restart nextcloud

The entrypoint then compares version.php in the webroot against the image, rsyncs the new code in, and runs occ upgrade — one major version at a time, so several majors behind means several runs through intermediate images. An image older than the installed version makes the container exit with "downgrading is not supported". Take a database dump before a run that is going to upgrade.

Verification

systemctl --user status nextcloud
journalctl --user -u nextcloud -f
podman exec -u www-data nextcloud php occ status
podman exec nextcloud getent hosts db redis
crontab -l
curl -sSI https://cloud.hopsngrains.com

Then open it in a browser, log in, and upload a file: that 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. Confirm the new file is owned by tanshu:

find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu

Finally reboot and confirm the container comes back unattended — without loginctl enable-linger tanshu it will not, 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 reason is in the journal rather than in podman logs (the container may already be gone — Quadlet passes --rm). The play collects this itself when a start fails; by hand:

systemctl --user status nextcloud.service --no-pager -l | head -25
journalctl --user -u nextcloud.service --no-pager -n 60
podman ps -a --filter name=nextcloud

journalctl is the one that matters: the entrypoint's own output is there. A failed unit needs its start-limit counter cleared before it will try again:

systemctl --user reset-failed nextcloud.service

Notes and gotchas

  • activating is not a healthy state. A crash-looping container spends its time activating between restarts, so the play waits on status.php rather than on is-active, and reports the journal on failure.

  • The webroot holds the code, which is the image's design and not this playbook's: an image bump edits ~/podman-data/nextcloud. It is also why the table above calls apps/, core/ and lib/ regenerable.

  • occ must run as www-data — it refuses any other user, and under keep-id:uid=33 that is the user the host sees as tanshu.

  • A new drop-in restarts Postgres for everyone, registering restarts all of Caddy, and attaching restarts Valkey. 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 valid; the Caddy playbook's global-options seed only fires on a missing or empty file. Deploy Caddy first on a fresh host.

  • Client IPs. Nextcloud uses the client address for brute-force protection and for the X-Forwarded-For it logs, so the proxy has to be trusted — see caddy/readme.md, "Client IPs", including whether the original address survives the rootless port forward.

  • Backups are not this playbook's business. Whatever dumps the database and archives the webroot belongs in the same user crontab, for the same reason as cron.php, and should dump in place rather than through a helper container (--link does 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.
  • Pinning the image to a digest, so a latest bump is deliberate.
  • A backup job for the webroot and the database.