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.
321 lines
16 KiB
Markdown
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.
|