The role created ~/podman-data/nextcloud/redis-session.ini and the unit bind-mounted it over the image path, purely because the entrypoint cannot write there itself. Removed: the task, the mount, and -- because it is the only thing that triggers the write -- REDIS_HOST from the environment file. The entrypoint now skips configure_redis_session entirely and starts. The Redis attach stays. The valkey role still puts nextcloud_net on the cache container with the alias `redis`, so an instance whose config.php names that host reaches it. Writing that configuration is config.php's business, and the readme gives the three `occ config:system:set` lines for anyone who wants it, including that this playbook will not do it for them. The only thing lost is PHP session storage, which falls back to the container's own filesystem -- a redeploy logs users out. That is the trade for not shipping a file whose sole purpose is to work around a directory the image owns as root. The env template and the valkey role comment both say why REDIS_HOST is absent, so that adding it back is recognisable as the regression it is.
298 lines
14 KiB
Markdown
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, 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`.
|
|
|
|
`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.
|