Files
playbooks/nextcloud/readme.md
T
tanshu 05452127ec Cut the Nextcloud role back to what it actually needs
The role had grown machinery that the problem does not require. Removed:

- The init-sync lock pre-create. It guarded against one file in the webroot being
  owned by a subuid, which is cosmetic: the file is 0644, so it is readable and
  removable by host-side tools either way.
- The image ID read/pull pair. No other service playbook pulls an image, and the
  update path is two commands the readme now spells out.
- redis-session.ini.j2 and its three variables. The mount is load-bearing -- the
  entrypoint cannot write into the image's conf.d and dies trying -- but the
  template is not: the entrypoint writes that file itself on every start. One
  touched empty file and one Volume line replace the template and the variables.
- The config.php stat, the wizard note, the maintenance-mode wait, the occ status
  report, the getent checks, the timer report. The installed/not-installed
  branching existed only because the readiness gates were written for an installed
  instance; with a gate that fits both, none of the branching is needed.
- Comments that narrated this play's earlier runs rather than the system.

What is left is four tasks before the start, a start-plus-gate block whose rescue
collects the journal, and four tasks for the background-job timer: 149 lines and
about 130 of them code, against 313 before. The readme loses its migration-era
bulk for the same reason and is 297 lines, in line with the other services'.

The one gate is now Nextcloud's own status.php inside the container. It answers
only once the entrypoint has finished -- including any `occ upgrade`, which runs
before Apache starts, so an update in progress is waited out rather than missed --
it reports `installed` either way, so a fresh webroot and an installed instance go
through the same check, and it carries `maintenance` as well, which is the one
state a probe would otherwise call healthy while every page returned 503.
2026-10-16 15:18:55 +05:30

298 lines
14 KiB
Markdown

# 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.