# Nextcloud (rootless Podman + Quadlet) Runs Nextcloud as a **rootless Podman** container managed by a **systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md), with its database in the shared [rootless Postgres](../postgres/readme.md) and its cache in the shared [rootless Valkey](../valkey/readme.md). Standalone and idempotent, and host-agnostic apart from `hosts:` in [playbook.yml](playbook.yml) and `host` in [vars/default.yml](vars/default.yml): - **First run** creates the webroot, the environment file, the unit and the background-job timer, 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](../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](../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](../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, session config, cron timer └── 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 | | `~/.config/systemd/user/nextcloud-cron.{service,timer}` | the background-job timer | | `~/podman-data/nextcloud/` | the whole `/var/www/html` | | `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600` | | `~/podman-data/nextcloud/redis-session.ini` | mounted into the image; see [below](#the-php-session-file) | ## 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](../postgres/readme.md). **Valkey.** The same, via [valkey/roles/attach](../valkey/roles/attach/tasks/main.yaml), with `NetworkAlias=redis`. `redis` is not a name this playbook chose: `REDIS_HOST` is what the image writes PHP's session config from, and Redis is where the setup wizard puts `memcache.locking`. An unresolvable `redis` therefore breaks uploads at write time rather than at startup, which is why the attach is flushed before the app starts as well. The recreate costs a few seconds of cache availability; the keys survive it (see [valkey/readme.md](../valkey/readme.md)). ## 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 a container has no crontab of its own. A root crontab would fail; a *user* crontab would need `XDG_RUNTIME_DIR` faked into every line and a cron daemon present. The role installs a systemd user pair instead, next to the Quadlet units: | 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 `enable`d — that is what brings the timer back after a reboot — and `journalctl --user -u nextcloud-cron` shows every run. ## 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` / `redis_host` | The cache unit, and the `redis` alias | | `nextcloud_admin_user` / `nextcloud_admin_password` | Empty by default; set both to install unattended | | `cron_*` / `user_unit_dir` | The background-job timer | | `caddy_*` | The shared Caddy unit and its Caddyfile | The three `podman_*` / `quadlet_dir` vars are duplicated from [caddy/vars/default.yml](../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. ## The PHP session file The entrypoint generates PHP's session config on every start, with a redirect: ```sh /entrypoint.sh:121 } > /usr/local/etc/php/conf.d/redis-session.ini ``` That directory belongs to the image's root — the php base image creates it with `mkdir -p "$PHP_INI_DIR/conf.d"` and never chowns it — so the write is refused, the entrypoint exits `2` before Apache is reached, `Restart=always` retries it, systemd hits its start limit, and the unit ends up **`failed`** with the only explanation in the journal: ``` cannot create /usr/local/etc/php/conf.d/redis-session.ini: Permission denied ``` The role creates `~/podman-data/nextcloud/redis-session.ini` and the unit binds it 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`. 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`. ## First run and updates ```command 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: ```bash 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 ```bash 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 systemctl --user list-timers nextcloud-cron.timer 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`: ```bash find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu ``` Finally reboot and confirm the container and the timer come back 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 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: ```bash 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: ```bash 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](../postgres/readme.md), [caddy/readme.md](../caddy/readme.md) and [valkey/readme.md](../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](../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 has to run as a **systemd user timer** for the same reason `cron.php` does, and should dump in place rather than through a helper container (`--link` does not exist in Podman): ```bash 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 timer for the webroot and the database.