Files
playbooks/nextcloud/readme.md
T
tanshu ecb8913153 Run the Nextcloud container as root, with www-data still mapped onto the user
The second real run failed with:

    (13)Permission denied: AH00072: make_sock: could not bind to address [::]:80
    no listening sockets available, shutting down

`keep-id:uid=33` does not only build the mapping, it also runs the container
process as the mapped uid -- so the entrypoint and apache2 were running as
www-data, and www-data has no capabilities. Port 80 inside the container's own
network namespace therefore belongs to root. The caddy unit is the control: it
binds 80 and 443 with keep-id:uid=0, and container-root in a userns does have
NET_BIND_SERVICE.

Tested on emily before writing this, because the last two fixes here were reasoned
from the wrong model:

    podman run --userns keep-id:uid=33,gid=33 IMG id            -> uid=33(www-data)
    podman run --userns keep-id:uid=33,gid=33 --user 0 IMG id   -> uid=0(root)

So User=0 restores the process root, and it does not touch the mapping: www-data
is still the connection user, so the webroot stays theirs, the entrypoint's
--chown lands on them, and occ keeps running as www-data through the entrypoint's
own su. That is the configuration the image is written for -- it expects root to
bind the port and to chown the webroot -- which is the real argument for this
option over the two that also tested clean: --sysctl
net.ipv4.ip_unprivileged_port_start=80 and --cap-add NET_BIND_SERVICE. Both would
have fixed the symptom while leaving the entrypoint permanently unprivileged,
which is exactly what produced this failure and the redis-session.ini one before
it.

The readme's debugging section now lists all three failures this playbook has
produced and what each means, and the rescue message points there instead of
repeating a stale list.
2026-10-16 16:05:33 +05:30

321 lines
16 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, 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/<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](../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`.
Two lines decide who the container is, and both are needed:
- **`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.
- **`User=0`** — `keep-id` also runs the container *process* as the mapped uid,
i.e. as `www-data`, and the image's entrypoint is written to start as root. It
fails at the first thing that needs it: apache2 as `www-data` cannot bind port
80 (`(13)Permission denied: AH00072: make_sock: could not bind to address
[::]:80`). Forcing the process back to root fixes that, and does not disturb the
mapping above — `www-data` is still `tanshu`, so the webroot is still `tanshu`'s
and `occ` still runs as `www-data` through the entrypoint's own `su`.
`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. The
three failures this playbook has actually produced, and what each one means:
| In the journal | Cause |
|---|---|
| `parsing file .../nextcloud.env: no such file or directory`, `status=125` | the webroot was removed without re-running the play |
| `make_sock: could not bind to address [::]:80` with `Permission denied` | the container process is not root, so it may not use a privileged port — the unit's `User=0` is missing |
| `cannot create /usr/local/etc/php/conf.d/...: Permission denied` | `REDIS_HOST` is set; the entrypoint cannot write that path in the image (see [Redis](#redis)) |
A `failed` unit needs its start-limit counter cleared before it will try again:
```bash
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.php` and `data/`. Removing `~/podman-data/nextcloud` therefore
leaves a unit that cannot start — `parsing file .../nextcloud.env: no such file
or directory`, exit status 125 — and because the generated unit is
`WantedBy=default.target` it will also fail that way at boot. Re-run the playbook:
it recreates the directory, the environment file and the container.
- **`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.