# 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, 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, 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](../postgres/readme.md). **Valkey.** The same, via [valkey/roles/attach](../valkey/roles/attach/tasks/main.yaml), 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](../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/` 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](../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`: ```bash 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 ```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 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`: ```bash 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: ```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 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): ```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 job for the webroot and the database.