The failure that prompted this is the environment file being absent:
Error: parsing file ".../nextcloud.env": ... no such file or directory
nextcloud.service: Main process exited, code=exited, status=125/n/a
The play creates that file before it installs or starts anything, so this cannot
come from a play run: it is a start outside one, after ~/podman-data/nextcloud was
removed. The unit is WantedBy=default.target, so it retries at boot and fails the
same way until the play is run again. The readme now says so, because the fix is
not obvious from a 125.
The rescue message was also stale: it sent readers to look for "Permission denied"
under /usr/local/etc/php/conf.d, which was the PHP session file this playbook no
longer creates or mounts, and it had lost the reset-failed hint when the message
was shortened. It now names the failure that actually happens (a removed webroot),
keeps the database/cache/image pointers that are still real, and gives the command
for a unit left failed by an earlier attempt.
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:
- caddy/playbook.yml — the reverse proxy. It 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 playbook
attaches to it and creates its own
nextclouddatabase in it, so the cluster can be brand new and empty. - 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.
hostmust resolve here before Caddy can get a certificate, and before the wizard is reachable at all. - The database already exists —
roles/postgrescreated it, or verified it, before the container started. Point the wizard at hostdb, databasenextcloud, userpostgres, and the password innextcloud.env. trusted_domains. The wizard installs withlocalhostas the only trusted domain, so the finished instance can answer "Access through untrusted domain" onhost. Fix it withpodman 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
-
The environment file is state, and it lives inside the webroot. So is the code,
config.phpanddata/. Removing~/podman-data/nextcloudtherefore leaves a unit that cannot start —parsing file .../nextcloud.env: no such file or directory, exit status 125 — and because the generated unit isWantedBy=default.targetit will also fail that way at boot. Re-run the playbook: it recreates the directory, the environment file and the container. -
activatingis not a healthy state. A crash-looping container spends its timeactivatingbetween restarts, so the play waits onstatus.phprather than onis-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 callsapps/,core/andlib/regenerable. -
occmust run aswww-data— it refuses any other user, and underkeep-id:uid=33that is the user the host sees astanshu. -
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-Forit 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 (--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.
- Pinning the image to a digest, so a
latestbump is deliberate. - A backup job for the webroot and the database.