diff --git a/nextcloud/playbook.yml b/nextcloud/playbook.yml index e105f50..0736653 100755 --- a/nextcloud/playbook.yml +++ b/nextcloud/playbook.yml @@ -4,6 +4,12 @@ # (postgres/playbook.yml) and its cache in the shared Valkey unit # (valkey/playbook.yml). # +# Standalone and idempotent: the first run on a host creates the container and +# leaves a fresh instance ready for its setup wizard, and every run after that +# converges the unit, the environment and the image — Quadlet recreates the +# container when any of them change. Nothing here depends on where the instance +# came from; only `hosts` is host-specific. +# # Connects as the container user and runs everything as that user: the Quadlet # units belong to it, so the connection's PAM session is what makes # `systemctl --user` work without any environment juggling. @@ -19,6 +25,9 @@ # - name: Rootless Podman prerequisites # ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" # +# The three shared services it attaches to have to exist first, in this order: +# caddy, postgres, valkey. +# # The variables this play needs are duplicated at the top of vars/default.yml, so # it stands on its own. # diff --git a/nextcloud/readme.md b/nextcloud/readme.md index f3ecea2..f4af119 100644 --- a/nextcloud/readme.md +++ b/nextcloud/readme.md @@ -1,46 +1,44 @@ # Nextcloud (rootless Podman + Quadlet) -Runs Nextcloud on `emily` 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). Replaces the -`docker_container` deployment on `monoco`. +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). -The container name, network, unit, Caddyfile upstream and database are all -`nextcloud`, as they have been since the Docker-era playbook. The **host is -unchanged**: `cloud.hopsngrains.com`. +Standalone and idempotent, and host-agnostic apart from `hosts:` in +[playbook.yml](playbook.yml) and `host` in [vars/default.yml](vars/default.yml): -Nextcloud is the one service here whose webroot is *also* its code directory. -The Docker-era playbook bind-mounted `/var/lib/nextcloud` at `/var/www/html`, and -this one keeps that shape: the image's `VOLUME`, `config/config.php`, `data/`, -`custom_apps/` and every PHP file live under one mount. That single fact drives -both the [migration](#migrating-the-data-from-monoco) and the ownership story -below — see [What is state, and what is not](#what-is-state-and-what-is-not). +- **First run** creates the webroot, the environment file, the unit and the + background-job timer, starts the container, and leaves a fresh instance ready + for its [setup wizard](#first-run) — or installs it unattended, if you set the + two optional credentials first. +- **Every run after that** converges the same things, and Quadlet recreates the + container when the unit, the environment or the image changed. There is no + update step to remember and nothing to copy in by hand. -## Ordering +Nextcloud is the one service here whose webroot is *also* its code directory: the +image's `VOLUME`, `config/config.php`, `data/`, `custom_apps/` and every PHP file +live under one mount. That single fact drives the ownership story and the backup +guidance below — see [What is state, and what is not](#what-is-state-and-what-is-not). -The three shared services are already migrated and running on `emily`, and this -playbook is the last step: +## Deploy order -1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which 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) — deployed, and its cluster - brought across from monoco. **The `nextcloud` database is already restored** - (320 MB, the largest of the 12 databases in - [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md)). -3. [valkey/playbook.yml](../valkey/playbook.yml) — **new**, and this is the first - service that needs it. It has to exist before this playbook runs: the attach - include only writes drop-ins next to `valkey.container`, so a `Restart Valkey` - handler against an undeployed unit fails. -4. This playbook — **run it after the file copy below**, not before. The app is - what writes to `~/podman-data/nextcloud`, and its first start is when the - entrypoint runs its version check against whatever is in the webroot. +Three shared services have to exist on the host first, each of them standalone: -`config.php` connects to the hosts `db` and `redis`. Until this playbook has -attached `nextcloud_net` to Postgres and to Valkey, neither name resolves on this -network, so running this playbook is what makes the app reachable — there is no -separate step. +1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which 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 play + 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. It has to exist + before this playbook runs: the attach include only writes drop-ins next to + `valkey.container`, so a `Restart Valkey` handler against an undeployed unit + fails. + +Then this playbook. `config.php` connects to the hosts `db` and `redis`; until it +has attached `nextcloud_net` to Postgres and to Valkey, neither name resolves on +that network, so running this playbook is what makes the app reachable. ## Layout @@ -49,10 +47,10 @@ nextcloud/ ├── playbook.yml ├── vars/default.yml └── roles/ - ├── postgres/ # attaches this app's network to Postgres (+ its handler) - ├── valkey/ # attaches this app's network to Valkey (+ its handler) - ├── nextcloud/ # webroot, the 0600 env file, the Quadlet unit, the cron - │ # timer, start + verify + ├── postgres/ # the database, and this app's network on the cluster + ├── valkey/ # this app's network on the cache + ├── nextcloud/ # webroot, env file, Quadlet unit, session config, + │ # background-job timer, start + verify └── caddy/ # appends this site's block to the shared Caddyfile ``` @@ -70,13 +68,13 @@ Quadlet units and state: | `~/.config/systemd/user/nextcloud-cron.{service,timer}` | the background-job timer (not Quadlet) | | `~/podman-data/nextcloud/` | the whole `/var/www/html`: code, `config/config.php`, `data/`, `custom_apps/` | | `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600`, read by systemd | -| `~/podman-data/nextcloud/php-conf.d/redis-session.ini` | PHP's session handler, mounted into the image at `/usr/local/etc/php/conf.d/` | +| `~/podman-data/nextcloud/php-conf.d/redis-session.ini` | PHP's session handler, mounted into the image | | `~/podman-data/nextcloud/nextcloud-init-sync.lock` | the entrypoint's init lock, pre-created so it stays host-owned | ## What is state, and what is not -Per [docs/backup-plan.md §2.1](../docs/backup-plan.md), the instance is 1.2 GB -and only a fraction of it is irreplaceable: +The webroot is about 1.2 GB on a working instance, and most of it is not worth +backing up: | Path | Keep? | Why | |---|---|---| @@ -86,12 +84,13 @@ and only a fraction of it is irreplaceable: | `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 migration copies all of it anyway**, and that is deliberate. Because the -mount holds the code, the entrypoint decides what to do by reading -`/var/www/html/version.php` **from the mount**: copy the whole tree and the first -start is close to a no-op; copy only the state and `version.php` is missing, so -every start looks like a brand-new instance — no `occ upgrade`, no version -tracking — and Nextcloud would then have to be talked through a manual upgrade. +The database is separate and is not in this directory at all. A backup that +matters is `config/`, `data/`, `custom_apps/` **and** a `pg_dump` of the +`nextcloud` database taken together — files without the database restore to an +instance that does not know about its own contents, and the reverse is just as +useless. Nextcloud's own guidance is `occ maintenance:mode --on` around the pair; +the play waits for maintenance mode to clear on the next run, so a forgotten +`--on` is reported rather than silently 503ing. ## How it registers with Caddy @@ -112,39 +111,13 @@ referenced `.network` file does not exist yet. Both attaches below depend on it for the same reason. `network_name` is `nextcloud`, so the *file* is `nextcloud.network` and the -*Podman network* is `nextcloud_net` (`NetworkName=` in the network unit pins it, -keeping the Docker-era name). - -The Docker-era `roles/network` is **deleted rather than ported**. It existed to -attach an already-created container to a network — `docker_network`'s -`connected:`/`appends:`, with the `redis` alias for the old `valkey` container — -which `podman_network` has no equivalent for -([docs/podman-compat-audit.md §3.3](../docs/podman-compat-audit.md)). Nothing has -to be connected after the fact now: the app's own unit names its `Network=`, and -Caddy, Postgres and Valkey learn about the network from drop-ins written in the -same run. +*Podman network* is `nextcloud_net`. The site block is appended to the shared Caddyfile by [roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the -other services do. **Only `caddyfile_path` changed** — from -`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile` — and the -block itself is the Docker-era one, HSTS header included: - -``` -cloud.hopsngrains.com { - reverse_proxy nextcloud:80 - - header { - Strict-Transport-Security "max-age=15552000; includeSubDomains; preload" - } -} -``` - -The `marker` in the role is unchanged from the Docker era, so an existing block -on a reused Caddyfile is replaced in place rather than duplicated. There is no -request-body limit to carry over: Caddy streams request bodies and imposes none -by default, so the `client_max_body_size 525M` the dead nginx front end carried -has no equivalent and needs none. +other services do — plus the HSTS header Nextcloud asks for. There is no +request-body limit to set: Caddy streams request bodies and imposes none by +default. ## How it attaches to Postgres @@ -158,26 +131,31 @@ into `postgres.container` by Quadlet: | `postgres.container.d/50-nextcloud.conf` | `Network=nextcloud.network` | | `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` | -`config.php`'s `dbhost` is `db`, which is what the Docker-era container's -`POSTGRES_HOST=db` wrote; the `postgres` container name resolves too, and the play -checks both. +`db` is the alias `config.php` connects to; the `postgres` container name resolves +too, and the play checks both. -[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for two +[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for three reasons: -- it defines the `Restart Postgres` handler the include notifies; and -- it **flushes handlers** immediately afterwards. On a first attach the drop-in - is new, so Postgres has to be *recreated* to join `nextcloud_net` — and that - restart is a handler, which by default runs at the end of the play, after the - `nextcloud` role has already started the app. The app would then come up before - `db` resolves, fail its own readiness check, and fail the play on a machine - where nothing is wrong. The flush also fires the `Restart Caddy` queued by the - register pre_task, which is what attaches Caddy to the network. On later runs - nothing is pending and it is a no-op. +- it defines the `Restart Postgres` handler the include notifies; +- it **flushes handlers** immediately afterwards. On a first attach the drop-in is + new, so Postgres has to be *recreated* to join `nextcloud_net` — and that restart + is a handler, which by default runs at the end of the play, after the `nextcloud` + role has already started the app. The app would then come up before `db` + resolves, fail its readiness check, and fail the play on a machine where nothing + is wrong. The flush also fires the `Restart Caddy` queued by the register + pre_task, which is what attaches Caddy to the network. On later runs nothing is + pending and it is a no-op; +- it **creates the `nextcloud` database** if the cluster does not have it yet. + Postgres is a shared unit that ships one default database, and nothing else in + this repo knows what this app's database is called, so without that task a + first run on a fresh host would reach the setup wizard only to fail on + "database nextcloud does not exist". It is a `createdb` guarded by a lookup, so + it is a no-op on every run after the first. -Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new -drop-in recreates Postgres, which is a few seconds of downtime for **every** -attached app, not just this one. +Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new drop-in +recreates Postgres, which is a few seconds of downtime for **every** attached app, +not just this one. ## How it attaches to Valkey @@ -189,37 +167,29 @@ The same idea at the cache end, via | `valkey.container.d/50-nextcloud.conf` | `Network=nextcloud.network` | | `valkey.container.d/10-network-aliases.conf` | `NetworkAlias=redis` | -`redis` is not a name this playbook chose. The image writes `config.php`'s Redis -host from `REDIS_HOST`, the Docker-era `env:` block set it to `redis`, and the -same variable rewrites PHP's session-handler config on **every** start. Renaming -it would mean editing `config.php` on a live instance for nothing. +`redis` is not a name this playbook chose: the image writes the Redis host into +`config.php` and into PHP's session config from `REDIS_HOST`, which is `redis` +here. Renaming it would mean editing a live instance's configuration for nothing. -It is also **not optional**. `config.php` carries `memcache.locking` pointed at -Redis, so an unresolvable `redis` does not merely make things slower — uploads -fail, and they fail at write time rather than at startup. That is why -`roles/valkey` flushes handlers before the app starts (same reason as Postgres), -and why `REDIS_HOST` is in the environment file at all. +It is also **not optional**. Go through the setup wizard and Nextcloud puts +`memcache.locking` on Redis; the image's own install does too when the unattended +credentials are set. An unresolvable `redis` therefore does not merely make things +slower — uploads fail, and they fail at write time rather than at startup. That is +why `roles/valkey` flushes handlers before the app starts (the same reason as +Postgres) and why `REDIS_HOST` is in the environment file at all. -The cost of the attach is a recreate of Valkey, and it is **not** the same story -as Postgres's. No keys are lost — RDB persistence is on by default, the stop is -graceful, and the new container loads the snapshot back off the bind mount — but -for the seconds it is down, Nextcloud's PHP sessions and its `memcache.locking` -are unreachable, so logins and uploads stall rather than fail cleanly. See -[valkey/readme.md](../valkey/readme.md), "The trade-off". +The cost of the attach is a recreate of Valkey: no keys are lost (RDB persistence +is on by default, the stop is graceful, and the new container loads the snapshot +back off the bind mount), but for the seconds it is down, PHP sessions and +`memcache.locking` are unreachable. See [valkey/readme.md](../valkey/readme.md), +"The trade-off". ## Background jobs -Nextcloud expects `cron.php` to run every five minutes, and the Docker-era -playbook scheduled it in **root's crontab**: - -``` -*/5 * * * * docker exec -u www-data nextcloud php cron.php -``` - -That cannot survive the move: root cannot `podman exec` into another user's -rootless containers, and there is no container user to own a crontab -([docs/podman-migration.md §8(b)](../docs/podman-migration.md)). The role -installs a **systemd user** pair next to the Quadlet units instead: +Nextcloud expects `cron.php` every five minutes. It cannot be a crontab entry: +root cannot `podman exec` into another user's rootless containers, and there is no +container user to own a crontab. The role installs a **systemd user** pair next to +the Quadlet units instead: | Unit | What it does | |---|---| @@ -227,15 +197,13 @@ installs a **systemd user** pair next to the Quadlet units instead: | `~/.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. No `sudo`, no crontab, and -`journalctl --user -u nextcloud-cron` shows every run and every failure, which the -old crontab never did. +that is what brings the timer back after a reboot. `journalctl --user -u +nextcloud-cron` shows every run and every failure. -The background-job *mode* is left alone: `config.php` came from monoco with -whatever it had, and this playbook does not switch it to `cron`. Both the timer -and AJAX polling run jobs; job locking is what keeps them from colliding. If you -want AJAX disabled, that is `occ background:cron`, a deliberate change rather -than a migration step. +The background-job *mode* is left alone. Both this timer and AJAX polling run +jobs, and job locking keeps them from colliding; switching AJAX off is +`occ background:cron`, a deliberate change rather than something this play +imposes. ## Settings @@ -244,19 +212,21 @@ than a migration step. | `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | | `podman_data_dir` | `~/podman-data` | | `quadlet_dir` | `~/.config/containers/systemd` | -| `host` | `cloud.hopsngrains.com` — the site block Caddy serves | +| `host` | `cloud.hopsngrains.com` — the hostname Caddy serves, and the trusted domain for an unattended install | | `container_name` | `nextcloud`, and the Quadlet 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 server writes as | +| `app_uid` / `app_gid` | `33` — `www-data`, the uid the webroot is owned by | | `container_port` | `80`, the port inside the container that Caddy proxies to | | `env_file` | `~/podman-data/nextcloud/nextcloud.env`, mode `0600` | +| `php_conf_dir` / `php_session_ini` | PHP's session config on the host, and the path in the image it is mounted over | | `postgres_container` / `postgres_unit` | The shared database unit this app attaches to | -| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database name — `nextcloud` | +| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database the play creates and the app uses — `nextcloud` | | `db_host` | `db` — the alias `config.php` connects to | | `valkey_container` / `valkey_unit` | The shared cache unit this app attaches to | | `redis_host` | `redis` — the alias `config.php` and PHP sessions connect to | +| `nextcloud_admin_user` / `nextcloud_admin_password` | Empty by default; set both to install unattended | | `cron_unit` / `cron_schedule` / `user_unit_dir` | The background-job timer | | `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | @@ -273,31 +243,34 @@ Like Caddy, `podman_user` is derived from the connection and the play carries no connecting as `root`**; the prerequisites, including that check, live in `caddy/playbook.yml`. -`UserNS=keep-id:uid=33,gid=33` — `33` is the uid the server writes as. Whatever -the entrypoint's *own* starting uid under this mapping, the writes that matter land -on `podman_user`: the code is rsynced with `--chown www-data:www-data`, and Apache's -workers run as `www-data` -([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). `33` is also -what the data is owned by on monoco, which is what makes the copy below need no -`chown` pass. +`UserNS=keep-id:uid=33,gid=33`. `33` is `www-data`, the uid the webroot is owned +by: the entrypoint rsyncs the code in with `--chown www-data:www-data` and Apache +runs its workers as that account. `keep-id` maps that one uid onto the connection +user, so the whole webroot stays owned by `tanshu` and plain `tar` and `rm -rf` +keep working on it. -Two paths the entrypoint writes fall outside that arrangement, and both are handled -explicitly rather than left to chance — each one is somewhere a naive deployment -ends up with a file owned by a `165536+` subuid, or with no deployment at all: +Two paths the entrypoint writes fall outside that arrangement, and both are +handled explicitly rather than left to chance — each is somewhere a naive +deployment ends up with a file owned by a `165536+` subuid, or with no running +container at all: -- **`/var/www/html/nextcloud-init-sync.lock`**, which the entrypoint creates with a - root-owned shell redirect. The role **pre-creates it** as `podman_user`: the - redirect truncates the file rather than recreating it, so its owner never changes - afterwards. +- **`/var/www/html/nextcloud-init-sync.lock`**, which the entrypoint creates with + a root-owned shell redirect. The role **pre-creates it** as `podman_user`: the + redirect truncates the file rather than recreating it, so its owner never + changes afterwards. - **`/usr/local/etc/php/conf.d/redis-session.ini`**, in the image, which is not - writable at all — it is covered in its own section below. + writable at all. It has its own section below. -### The PHP session file +`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; a real secret store is the next step, and it is a +one-file change here plus the `pg_password` vars in the app playbooks. + +## The PHP session file The webroot is not the only thing the entrypoint writes. With `REDIS_HOST` set — -which it is, because `config.php` puts `memcache.locking` in Redis and PHP -sessions should survive a redeploy — the entrypoint generates that session -configuration on **every** start: +and it is, because PHP sessions should survive a redeploy — the entrypoint +generates that session configuration on **every** start: ```sh /entrypoint.sh:121 @@ -345,14 +318,9 @@ chmods them `1777`: done ``` -`POSTGRES_PASSWORD` is the fleet's shared `123456`, which matches -`pg_password` in [postgres/vars/default.yml](../postgres/vars/default.yml) and -what `config.php` already carries — a migrated app connects unchanged. It lives -in `nextcloud.env` at mode `0600` rather than in the world-readable unit file. -Both are committed in plaintext as before; flagged as a follow-up, not changed -here. +so the session file is the only write that was ever going to fail. -## Running +## First run ```command ansible-playbook -u tanshu nextcloud/playbook.yml @@ -361,191 +329,78 @@ ansible-playbook -u tanshu nextcloud/playbook.yml The play targets `emily` directly, so the inventory must contain it — the one in `prepare-server/` does, and its `ansible.cfg` names it. -This play applies **only** this app's units and data. The rootless-Podman -prerequisites — installing podman, `loginctl enable-linger`, the low-port sysctl -and the Quadlet directory — are the shared task file at -[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and -are **not** run here. A host that has already run `caddy/playbook.yml` has them. +On a host with no webroot yet, this is what happens, in order: the webroot and the +PHP session directory are created; the `0600` environment file, the session config +and the Quadlet unit are written; the unit is started; anything the run queued (a +new image, a changed unit or environment) is applied *before* verification; and +then the play waits for Apache to answer `status.php` inside the container. -`systemctl --user enable nextcloud` is neither needed nor possible: the unit is -*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. The -cron **timer** is a plain unit and is enabled explicitly, as described above. +At that point there is a running container and **no instance yet** — the image +initialises the code, writes no `config.php` (it has no admin credentials to +install with), and serves the setup wizard. The play says so and succeeds: -## Migrating the data from monoco - -The database and the files have to correspond, so this is a freeze, not a -best-effort copy. Run it from the machine that holds the `monoco` and `emily` SSH -aliases, as in [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). - -Two things are already done: the `nextcloud` database is restored on `emily`, and -the Caddyfile and certificates live there too. What is left is -`/var/lib/nextcloud`. - -### 0. Look first - -```bash -# What is there, how big, and whether it is readable without sudo. Nextcloud's -# data/ is normally 0770 www-data:www-data, so this says "no" — the sudo form -# below is the expected one, not the exception. -ssh monoco 'du -sh /var/lib/nextcloud; ls -ln /var/lib/nextcloud' -ssh monoco 'find /var/lib/nextcloud -type f ! -readable | head' # empty = no sudo needed - -# The version on monoco versus the image emily will run. The entrypoint compares -# the two on first start: a NEWER image rsyncs its code in and runs `occ upgrade` -# (one major version at a time, so a large gap needs stepping stones), while an -# OLDER image refuses to start at all — "downgrading is not supported". -ssh monoco 'docker exec nextcloud php -r "require \"/var/www/html/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"' -ssh emily 'podman run --rm --entrypoint php docker.io/library/nextcloud:latest \ - -r "require \"/usr/src/nextcloud/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"' +``` +The container is running, but no instance is installed in +/home/tanshu/podman-data/nextcloud yet. Open https://cloud.hopsngrains.com and +finish the setup wizard with the database credentials in .../nextcloud.env — +database nextcloud, user postgres, host db. ... ``` -### 1. Freeze monoco +Three things to know about that wizard: -```bash -# Stop the app first: a stopped container cannot write to data/ while it is being -# copied, and no maintenance-mode dance is needed. Leave it stopped — its database -# is on emily, and two live copies would diverge. -ssh monoco 'docker stop nextcloud' -``` +- **DNS first.** `host` must resolve to this host before Caddy can obtain a + certificate for it, and before the wizard is reachable at all. +- **The database already exists.** [roles/postgres](roles/postgres/tasks/main.yaml) + 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 unless you tell it otherwise, so opening the finished instance on `host` + can answer "Access through untrusted domain". Add it before or after: -### 2. Make the database correspond (only if it moved since the cluster move) + ```bash + podman exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=cloud.hopsngrains.com + ``` -The cluster was dumped on **2026-10-15**. If monoco's Nextcloud has served writes -since then, the files you are about to copy are newer than the database on -`emily`, and restoring the files alone would leave the instance inconsistent. -Re-dump the one database and reload it — it is 320 MB, so this is cheap. +To skip the wizard entirely, set `nextcloud_admin_user` and +`nextcloud_admin_password` in [vars/default.yml](vars/default.yml) before the first +run. The entrypoint then runs `occ maintenance:install` itself and sets +`trusted_domains` from `host` in the same pass, and the play reports the installed +version instead of the wizard note. Both variables are inert once `config.php` +exists, so they can stay set (or be removed) afterwards. -If the app is already running on `emily` from an earlier attempt, stop it first: -`DROP DATABASE` needs the connections gone, and a running Nextcloud holds several. +## Updates -```bash -ssh emily 'systemctl --user stop nextcloud.service' # only if it is already deployed +Nothing special is required: re-run the playbook. What happens depends on what +changed. -```bash -# Dump on monoco (Postgres is a separate container and is still running). -ssh monoco 'mkdir -p ~/pg-migrate && cd ~/pg-migrate - docker exec -e PGPASSWORD=123456 postgres pg_dump -U postgres -Fc nextcloud \ - | zstd -T0 -6 -o nextcloud-$(date +%Y%m%d-%H%M).dump.zst - ls -lh nextcloud-*.dump.zst' +| Changed | What the play does | +|---|---| +| The unit, the environment file or the session config | re-templates it, and Quadlet recreates the container | +| A new image behind the tag | pulls it, notices the ID changed, and recreates the container | +| Nothing | nothing — no restart, no churn | -# Transfer, then check the checksum on both sides before trusting it. -ssh monoco 'cat ~/pg-migrate/nextcloud-*.dump.zst' \ - | ssh emily 'cat > ~/pg-migrate/nextcloud.dump.zst' -a=$(ssh monoco 'sha256sum ~/pg-migrate/nextcloud-*.dump.zst | cut -d" " -f1') -b=$(ssh emily 'sha256sum ~/pg-migrate/nextcloud.dump.zst | cut -d" " -f1') -[ -n "$a" ] && [ "$a" = "$b" ] && echo "transfer OK: $a" \ - || { echo "MISMATCH — do not restore: $a vs $b"; false; } +The image is pulled on every run precisely because a Quadlet unit alone cannot see +a tag move: the unit names `nextcloud:latest`, `podman run` reuses whatever image +is local, and nothing fetches a newer one. The role reads the image ID either side +of the pull, so an unchanged tag reports no change and does not restart a running +site. A pull that fails (a host with no route to the registry) is reported and the +run continues with the local image. -# Reload it on emily. This DROPS the existing database — it is the one the -# cluster move restored, and the frozen dump replaces it. -ssh emily 'cd ~/pg-migrate - podman exec -e PGPASSWORD=123456 postgres \ - psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ - -c "DROP DATABASE IF EXISTS nextcloud" \ - -c "CREATE DATABASE nextcloud OWNER postgres" - zstd -dc nextcloud.dump.zst | podman exec -i -e PGPASSWORD=123456 postgres \ - pg_restore -U postgres -d nextcloud --no-owner --exit-on-error - podman exec -e PGPASSWORD=123456 postgres \ - psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' -``` +**Version drift is the one irreversible step.** The entrypoint compares +`version.php` in the webroot against the image. A newer image rsyncs its code in +and runs `occ upgrade` before Apache answers — and it will only step **one major +version at a time**, so several majors behind means several runs with an +intermediate image. An image *older* than the installed version makes the +container exit with "downgrading is not supported". So: pin the tag if you want +no surprises, and take a database dump before a run that is going to upgrade. -If monoco's container simply sat idle after the cluster move, skip this and go -straight to the copy — but the file counts at the end of step 3 are what tell you -whether the two halves agree. - -### 3. Copy the files - -```bash -# 1. Create the destination: the mount target of the image's /var/www/html. -ssh emily 'mkdir -p ~/podman-data/nextcloud' - -# 2. Copy. tar streams it across the workstation; rsync cannot copy -# remote-to-remote in one command. -# -# The WHOLE tree is copied — code as well as state — because /var/www/html is -# both. See "What is state, and what is not" above. -# -# `set -o pipefail` matters here: without it the pipeline reports only the -# *receiving* tar's status, so a read error or a dropped stream on the source -# side exits 0 and leaves a silently short copy. -# -# `--no-same-owner` is explicit and important: the source is 33:33 (and partly -# root), and under keep-id:uid=33 the *container's* 33 is tanshu on the host. -# Extracting as tanshu is therefore already the correct ownership, and -# restoring the source's numeric owners would be wrong. -set -o pipefail -ssh monoco 'sudo tar -C /var/lib/nextcloud -cf - .' \ - | ssh emily 'tar --no-same-owner -C ~/podman-data/nextcloud -xf -' - -# Without sudo, when step 0 proved everything is readable: -# ssh monoco 'tar -C /var/lib/nextcloud -cf - .' \ -# | ssh emily 'tar --no-same-owner -C ~/podman-data/nextcloud -xf -' - -# 3. Verify by content, over the whole tree. Read as root on the source side -# because data/ is 0770. -ssh monoco 'cd /var/lib/nextcloud && sudo bash -c "find . -type f -print0 | sort -z | xargs -0 -r sha256sum" | sha256sum' -ssh emily 'cd ~/podman-data/nextcloud && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' -# identical digests = a byte-faithful copy - -# 4. Nothing may end up owned by root or by a subuid. -ssh emily 'find ~/podman-data/nextcloud ! -user tanshu' # empty = correct - -# 5. A cheap sanity check that the two halves agree — compare with the count from -# step 2, and with monoco's own tree. -ssh emily 'find ~/podman-data/nextcloud/data -type f | wc -l' -ssh monoco 'sudo find /var/lib/nextcloud/data -type f | wc -l' -``` - -Ownership needs no `chown` pass: `keep-id:uid=33` maps the container's `www-data` -onto the connecting user, so files extracted as `tanshu` are already what the -container expects to own — and what plain `tar`, `rsync` and `rm -rf` keep working -with. - -If the link is slow or drops, do it in two hops instead: resumable, and it shows -progress. - -```bash -rsync -aP --rsync-path='sudo rsync' monoco:/var/lib/nextcloud/ ~/nextcloud-copy/ -rsync -aP ~/nextcloud-copy/ emily:~/podman-data/nextcloud/ -``` - -### If you ran the playbook before copying - -Running this play against an empty `~/podman-data/nextcloud` does not fail — it -creates the webroot, writes the environment file, starts the image, and the -entrypoint initialises a **fresh, uninstalled** instance there: the image's own -code rsynced in, `data/.ocdata` created, and no `config.php`, because there is no -`NEXTCLOUD_ADMIN_USER` to install with. - -That is not a usable instance, and worse, copying monoco's tree on top of it -**merges two versions of the code** — the drift this page spends the rest of its -time warning about, produced silently by a play that reported success. The role -now warns when it finds a webroot with no `config/config.php`, but the recovery is -to start from a clean webroot: - -```bash -ssh emily 'systemctl --user stop nextcloud.service - rm -rf ~/podman-data/nextcloud' -``` - -`rm -rf` needs no `podman unshare`: the role creates the webroot as the connection -user, and under `keep-id:uid=33` everything the container writes lands on that same -user. Then take the copy in step 3 and come back to step 4. - -### 4. Deploy - -```bash -cd prepare-server && ansible-playbook -u tanshu ../nextcloud/playbook.yml -``` - -Run it **after** the copy: the first start is when the entrypoint compares the -copied `version.php` with the image and, if the image is newer, runs -`occ upgrade`. Starting before the copy would initialise an empty instance and -then be overwritten by it. - -Rolling back means rolling back all three halves — `ssh monoco 'docker start nextcloud'` -is only meaningful while monoco's own Postgres 17 cluster is intact, and it would -then be writing to a database emily's copy has already moved past. +`occ upgrade` sets maintenance mode for its duration, and the play **waits for it +to clear** rather than asserting it is off — so a long upgrade is tolerated, and an +instance left in maintenance mode (a backup that set `--on` and never finished) is +reported with the one-line command to clear it. That distinction matters because +`status.php` is exempt from maintenance mode: a site in that state looks healthy +to a probe while returning `503` to every real request. ## Verification @@ -565,28 +420,23 @@ systemctl --user list-timers nextcloud-cron.timer systemctl --user start nextcloud-cron.service # run one by hand journalctl --user -u nextcloud-cron -n 20 -# The data really is the copied tree: -ssh emily 'ls -ln ~/podman-data/nextcloud | head' -ssh emily 'test -f ~/podman-data/nextcloud/config/config.php && echo "config.php present"' - -# The database really is the migrated one — run the same query on both hosts. -ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' -ssh monoco 'docker exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' +# The database this play created, and its size: +podman exec -e PGPASSWORD=123456 postgres psql -U postgres -l | grep nextcloud # The site, end to end: curl -sSI https://cloud.hopsngrains.com ``` Then open it **in a browser** and do two things a `curl` cannot: log in, and -**upload a file**. Uploading is the check that matters here — it 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, which the image configures from +**upload a file**. Uploading is the check that matters — it 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, which the image configures from `REDIS_HOST`. After the upload, confirm on the host that the new file is owned by `tanshu`: ```bash -ssh emily 'find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu' +find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu # empty = the write path lands on the right uid ``` @@ -594,19 +444,6 @@ Then reboot the host and confirm both the container and the timer return unattended — without `loginctl enable-linger tanshu` neither will, and the failure is silent. -Finally, if `config.php` predates the proxy (it should not — monoco already served -this site through Caddy, but check), Nextcloud needs the proxy trusted before it -will believe `X-Forwarded-For`: - -```bash -podman exec -u www-data nextcloud php occ config:system:get trusted_proxies -podman exec -u www-data nextcloud php occ config:system:get overwrite.cli.url -``` - -An empty `trusted_proxies` means every client looks like the proxy; see -[caddy/readme.md](../caddy/readme.md), "Client IPs", which also explains why the -real address may not survive the rootless port forward at all. - ### Debugging a unit that will not start A unit that has hit systemd's start limit reports only a state, and `Restart=always` @@ -615,11 +452,11 @@ may already be gone — Quadlet passes `--rm`). The play collects all of this it when a start fails; by hand: ```bash -ssh emily 'systemctl --user status nextcloud.service --no-pager -l | head -25 - systemctl --user cat nextcloud.service # what systemd is running - journalctl --user -u nextcloud.service --no-pager -n 60 - podman ps -a --filter name=nextcloud - podman logs --tail 40 nextcloud' +systemctl --user status nextcloud.service --no-pager -l | head -25 +systemctl --user cat nextcloud.service # what systemd is running +journalctl --user -u nextcloud.service --no-pager -n 60 +podman ps -a --filter name=nextcloud +podman logs --tail 40 nextcloud ``` Read it in this order: @@ -629,14 +466,16 @@ Read it in this order: above; anything about `db`/`redis` is the network attach; `downgrading is not supported` is version drift. 2. **`podman ps -a`** — a container left behind under this name makes `podman run` - refuse to start. Quadlet passes `--replace`, so this is rare, and it means an - interrupted run. + refuse to start. 3. **`systemctl --user cat`** — the generated unit, if a drop-in or a `Network=` is in question (the usual failure is a `.network` file that does not exist). -The first real run of this playbook failed at a bare `is-active` check with no -diagnostics at all, which is why the wait now lives inside the start block whose -rescue collects the above. +A `failed` unit needs its start-limit counter cleared before it will try again: + +```bash +systemctl --user reset-failed nextcloud.service +systemctl --user start nextcloud.service +``` ## Notes and gotchas @@ -645,79 +484,49 @@ rescue collects the above. crash is a `Permission denied` on `redis-session.ini`, the unit ends up `failed` via systemd's start limit, and nothing about it looks like a permissions problem. The unit mounts a host-owned file over that path; see - ["The PHP session file"](#the-php-session-file) above, including why a refused - write is no longer fatal. -- **Maintenance mode travels with `config.php`.** The backup plan recommends - `occ maintenance:mode --on` before a consistent dump - ([docs/backup-plan.md §3.4](../docs/backup-plan.md)); if monoco was left that - way, the copied configuration carries `maintenance => true` and the site returns - `503` for everything except `status.php`. The play **waits for maintenance mode - to clear** — it is on for the whole of a first-start `occ upgrade`, so it cannot - simply assert — and if it never clears, fails with the one-line command to turn - it off. That is deliberate: "the app is up but every page 503s" points anywhere - but at maintenance mode. -- **Version drift is the one irreversible step.** The copied `version.php` is what - the entrypoint compares against the image. A newer image upgrades the instance - on first start (and can only step one major version at a time); an older image - makes the container exit with "downgrading is not supported". Check both before - copying (step 0) and take a database dump before the first start. -- **`latest` floats.** The Docker-era playbook used the unqualified - `nextcloud:latest`; this one fully qualifies the same tag, because rootless - Podman has no TTY to answer a short-name prompt when the unit starts. Neither - pins a digest, so an image bump is a normal `podman pull` away — and it is - exactly the event that triggers an in-place upgrade, because the code lives in - the mount. -- **The code lives in the data directory.** That is the image's design, not this - playbook's, and it is why the whole tree is copied and why an image bump edits - `~/podman-data/nextcloud`. It is also why the backup plan lists `apps/`, `core/` - and `lib/` as regenerable: they are, but only from an image that is the same - version or newer. -- **`occ` must run as `www-data`.** It refuses to run as any other user, and under - `keep-id:uid=33` that is the same user the host sees as `tanshu`. Every - `podman exec` in this file therefore carries `-u www-data`; the plain form works - for `getent`, which does not care. -- **The init lock file.** `~/podman-data/nextcloud/nextcloud-init-sync.lock` is - pre-created by the role so it stays owned by `tanshu`. If a run ever reports it - as a subuid, the pre-create was skipped on a first start — delete it and re-run - the playbook. -- **Client IPs.** Nextcloud uses the client address for brute-force protection and - for the `X-Forwarded-For` it logs. Whether the original address survives the - rootless port forward depends on the forwarder. See - [caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it. -- **Registering restarts all of Caddy** (a brief restart of every hosted site), - **attaching restarts Postgres** (a few seconds without a database for every - attached app) and **restarts Valkey** (seconds without sessions or locks, though - the keys themselves survive the graceful stop). See the trade-offs in - [caddy/readme.md](../caddy/readme.md), [postgres/readme.md](../postgres/readme.md) - and [valkey/readme.md](../valkey/readme.md). + ["The PHP session file"](#the-php-session-file). +- **`activating` is not a healthy state.** A crash-looping container spends its + time `activating` between restarts, so the play's wait retries only while the + state can still change on its own and otherwise hands over to a rescue that has + the logs. A bare `is-active` check would report either state as a failure with no + explanation. +- **A new drop-in restarts Postgres for everyone** (a few seconds without a + database for every attached app), **registering restarts all of Caddy** (a brief + restart of every hosted site) and **attaching restarts Valkey** (seconds without + sessions or locks, though the keys survive the graceful stop). 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 a valid Caddyfile; the Caddy playbook's seeded global-options block is then not added, because its seed only fires on a missing or zero-length file. Deploy Caddy first on a fresh host. -- **DNS.** `cloud.hopsngrains.com` must resolve to `emily` before Caddy can obtain - a certificate for it; the old certificates live in `~/podman-data/caddy/data` - and are reused if the hostname is unchanged. -- **The backup script still assumes Docker.** `~/bin/nextcloud-backup.sh` uses - `--link postgres:db`, which Podman does not have, and its `sudo tar` / - `chown $(logname)` gymnastics are unnecessary now that the data is owned by you. - It needs the `podman exec` form from - [postgres/readme.md](../postgres/readme.md), "Backups"; the database dump should - also be taken with `occ maintenance:mode --on` around it, as - [docs/backup-plan.md §3.4](../docs/backup-plan.md) says. -- **`~/bin/empty-trash.sh` targets a container named `cloud`**, which never - existed on this host under that name — it is `nextcloud` — and it runs as root, - which cannot `podman exec` into these containers at all. -- **Do not restart the container on monoco.** It would write to monoco's old - cluster, which is being kept intact purely as a rollback. +- **Client IPs.** Nextcloud uses the client address for brute-force protection and + for the `X-Forwarded-For` it logs, so the reverse proxy has to be trusted — see + [caddy/readme.md](../caddy/readme.md), "Client IPs", which also covers whether + the original address survives the rootless port forward at all. +- **The webroot holds the code.** That is the image's design, not this playbook's: + an image bump edits `~/podman-data/nextcloud`. It is also why `apps/`, `core/` + and `lib/` are listed as regenerable above — they are, but only from an image + that is the same version or newer. +- **`occ` must run as `www-data`.** It refuses to run as any other user, and under + `keep-id:uid=33` that is the same user the host sees as `tanshu`. Every + `podman exec` in this file therefore carries `-u www-data`; the plain form works + for `getent`, which does not care. +- **The backup scripts are not this playbook's business.** Whatever dumps the + database and archives the webroot has to run as a **systemd user timer** next to + the Quadlet units: root cannot `podman exec` into another user's rootless + containers. 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 — `pg_password` is the fleet's shared `123456`, and it lives in `nextcloud.env` at mode `0600` rather than in the world-readable unit file. -- Switching the background-job mode to `cron` (`occ background:cron`), which the - Docker-era deployment also never did. -- Replacing the broken `~/bin/nextcloud-backup.sh` with a systemd user timer, and - fixing `empty-trash.sh`. -- Turning on ongoing monitoring — the `status.php` probe in the role is where a - timer or an external check would hook in. +- Switching the background-job mode to `cron` (`occ background:cron`). +- Pinning the image to a digest, so that a `latest` bump is always deliberate. +- A backup timer for the webroot and the database. diff --git a/nextcloud/roles/caddy/tasks/main.yaml b/nextcloud/roles/caddy/tasks/main.yaml index f439f48..7ce0af9 100644 --- a/nextcloud/roles/caddy/tasks/main.yaml +++ b/nextcloud/roles/caddy/tasks/main.yaml @@ -10,9 +10,8 @@ # when the Caddyfile does not exist yet, on a host where the Caddy playbook has # not run. # -# The marker is unchanged from the Docker-era role, so an existing -# cloud.hopsngrains.com block written on monoco is replaced in place rather than -# duplicated. +# The marker is stable, so re-runs replace this site's block in place rather than +# appending a second copy of it. - name: "Add the {{ host }} site block to the Caddyfile" ansible.builtin.blockinfile: path: "{{ caddyfile_path }}" diff --git a/nextcloud/roles/nextcloud/tasks/main.yaml b/nextcloud/roles/nextcloud/tasks/main.yaml index aea1d72..de1e638 100644 --- a/nextcloud/roles/nextcloud/tasks/main.yaml +++ b/nextcloud/roles/nextcloud/tasks/main.yaml @@ -1,7 +1,16 @@ --- # Nextcloud as a rootless Podman Quadlet unit. # -# Everything here runs as {{ podman_user }} — the connection user. No become, no +# Two paths, and the same play serves both: +# +# - a fresh webroot, where the image initialises itself and Apache serves the +# setup wizard (or installs unattended, if the admin credentials below are +# set); and +# - an existing one, where an instance is already installed and this run is an +# update. +# +# Nothing here is tied to a particular host, and nothing has to be copied in by +# hand. Everything runs as {{ podman_user }} — the connection user. No become, no # XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's # user manager is reachable through the standard environment. @@ -13,45 +22,15 @@ group: "{{ podman_user }}" mode: '0755' -# A migration target should already hold config/config.php, because the webroot is -# copied from monoco before this play runs (readme.md, "Migrating the data from -# monoco"). Two situations leave it missing, and both are worth saying out loud -# before the container starts rather than discovering afterwards: -# -# - the copy never landed, so the image initialises a fresh instance and the -# site comes up empty; -# - this play was run *before* the copy, so the image's own rsynced code is now -# in the webroot. Copying monoco's tree on top of that merges two versions of -# the code — the drift the rest of this role warns about — and the play -# reports success either way. -# -# It warns rather than fails because installing a new instance through the web UI -# is a legitimate use, and that instance has no config.php until the installer -# finishes. -- name: Check whether the webroot already holds an instance - ansible.builtin.stat: - path: "{{ host_directory }}/config/config.php" - register: nextcloud_config - -- name: Warn if the webroot has no config.php - ansible.builtin.debug: - msg: >- - {{ host_directory }}/config/config.php does not exist, so this webroot is - not a restored Nextcloud instance. Migrating? Stop the unit, delete - {{ host_directory }}, copy /var/lib/nextcloud from monoco first, and run - this play again — see readme.md, "If you ran the playbook before copying". - Installing a new instance through the web UI? Ignore this. - when: not nextcloud_config.stat.exists - # The entrypoint takes an flock on this path using a root-owned shell redirect # (`) 9> /var/www/html/nextcloud-init-sync.lock`, in the nextcloud/docker # entrypoint). Creating it here means it belongs to {{ podman_user }} and stays # that way: the redirect *truncates* the file, it does not recreate it, so its # owner never changes. # -# Left to the container, the file would be created by container-root — a subuid -# under keep-id — and would be the one thing in the webroot not owned by -# {{ podman_user }}. +# Left to the container, the file would be created by the entrypoint's root phase +# — a subuid under keep-id — and would be the one thing in the webroot not owned +# by {{ podman_user }}. # # `force: false` leaves an existing file completely alone, which is what makes # this idempotent and what stops it failing on a file a previous run stranded as @@ -77,26 +56,55 @@ mode: '0600' notify: Restart Nextcloud -# The second path the entrypoint writes, and the one that made this play fail for -# real: with REDIS_HOST set, the entrypoint generates PHP's session-handler config -# at {{ php_session_ini_mount }} on every start, and that path's directory in the -# php base image is +# An update in a Quadlet deployment is "pull, then recreate": the unit references +# a tag, `podman run` reuses whatever image is local, and nothing fetches a newer +# `latest` on its own. The ID is read either side of the pull so an unchanged tag +# reports no change and does not restart a running site on every run of this play. +# +# The pull is allowed to fail: a host that already has the image and no route to a +# registry should still converge, and the start below reports the real problem if +# there is no image at all. +- name: Read the current image ID + ansible.builtin.command: >- + podman image inspect --format '{% raw %}{{ .Id }}{% endraw %}' {{ image }} + register: nextcloud_image_before + changed_when: false + failed_when: false + +- name: Pull {{ image }} + ansible.builtin.command: "podman pull {{ image }}" + register: nextcloud_image_pull + changed_when: false + failed_when: false + +- name: Warn if the image could not be pulled + ansible.builtin.debug: + msg: >- + Could not pull {{ image }} ({{ nextcloud_image_pull.stderr | default('') | trim }}). + Continuing with the image already on this host, if there is one. + when: nextcloud_image_pull.rc != 0 + +- name: Note whether the pull changed the image + ansible.builtin.command: >- + podman image inspect --format '{% raw %}{{ .Id }}{% endraw %}' {{ image }} + register: nextcloud_image_after + changed_when: >- + (nextcloud_image_before.stdout | default('')) | length > 0 + and nextcloud_image_before.stdout != nextcloud_image_after.stdout + notify: Restart Nextcloud + +# PHP's session-handler config, and the one path in the image the entrypoint has +# to write to. With REDIS_HOST set it generates this file on every start, in a +# directory the php base image creates as root with no chown: # # RUN mkdir -p "$PHP_INI_DIR/conf.d" # -# — created by root, never chowned. The uid this container runs as therefore has -# no write bit on the directory, the entrypoint dies with -# -# /entrypoint.sh: 121: cannot create -# /usr/local/etc/php/conf.d/redis-session.ini: Permission denied -# -# and `Restart=always` turns that into a crash loop that systemd eventually marks -# `failed`. The Quadlet unit mounts this file over that exact path, which is what -# gives the write somewhere it is allowed: the mount replaces the directory entry, -# so the directory's ownership stops mattering. -# -# The file is templated rather than left empty so that a start in which the -# entrypoint's write is refused still has working Redis sessions. +# The uid this container runs as therefore cannot create a file there, and the +# entrypoint exits before Apache is reached. Mounting a host-owned file over the +# path gives that write somewhere it is allowed to land — a bind-mounted file +# replaces the directory entry, so the directory's ownership stops mattering — and +# the template keeps the image's own content in it, so a refused write is not +# fatal either. See readme.md, "The PHP session file". - name: Ensure the PHP session config directory exists ansible.builtin.file: path: "{{ php_conf_dir }}" @@ -141,19 +149,11 @@ # This waits rather than checking once, because `systemctl start` returns # while the unit is still `activating` and `is-active` exits 3 for that state. # - # `activating` is NOT evidence that a start is healthy, which this play got - # wrong once and should not again: a container that crash-loops spends its - # time `activating` between `Restart=always` attempts, and the first run here - # was read as a healthy start mid-transition when the entrypoint was in fact - # dying on every attempt (it could not write - # /usr/local/etc/php/conf.d/redis-session.ini — see the task that mounts that - # file). What this wait actually buys is a prompt, bounded stop: it retries - # only while the state can still change on its own, and a terminal `failed` - # or `inactive` falls straight through to the rescue below, which has the logs. - # - # Keeping it inside the block matters: an is-active check outside it fails with - # a bare state and no diagnostics, which is the dead end the second run hit - # before this was moved in. + # `activating` is not evidence of a healthy start: a container that crash-loops + # spends its time `activating` between `Restart=always` attempts. What this + # wait buys is a prompt, bounded stop — it retries only while the state can + # still change on its own, and a terminal `failed` or `inactive` falls through + # to the rescue below, which has the logs. - name: Wait for the Nextcloud unit to become active ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" register: nextcloud_active @@ -167,15 +167,13 @@ rescue: # systemd reports "control process exited with error code" and nothing else, # which is useless for the usual failures (an unattached network, an - # unreachable database, an unwritable webroot, a container name left behind - # by an interrupted run). Pull the real reason out. + # unreachable database, an unwritable webroot, a path in the image the + # entrypoint cannot write). Pull the real reason out. # - # `cat` is in the list because the generated unit settles what systemd thinks - # it is waiting for, and `podman ps -a` is in it because the failure most - # likely to bite after an aborted run is a container that already exists - # under this name: `podman run` then refuses to start, `Restart=always` - # retries it into systemd's start limit, and the unit ends up `failed` with - # nothing in `podman logs` to explain it. + # `systemctl cat` settles what systemd thinks it is running, and `podman ps -a` + # catches a container left behind under this name by an interrupted run, which + # makes `podman run` refuse to start and leaves `podman logs` showing the old + # container and explaining nothing. - name: Collect Nextcloud diagnostics ansible.builtin.command: "{{ item }}" loop: @@ -195,113 +193,85 @@ - name: Fail ansible.builtin.fail: msg: >- - Nextcloud did not come up — see the diagnostics above. If `podman ps - -a` shows a container that already exists under this name, it was left - behind by an interrupted run; clear it and start again with: + Nextcloud did not come up — see the diagnostics above. If `podman ps -a` + shows a container already using this name, it was left behind by an + interrupted run; clear it with: systemctl --user reset-failed {{ unit_name }} ; systemctl --user stop {{ unit_name }} ; podman rm -f {{ container_name }} - A unit that is `activating` for the whole retry budget is a different - problem: look at the generated unit in the diagnostics, and at - journalctl for the container's own output. -# `is-active` says the container is running; it does not say the app can serve. -# The entrypoint rsyncs the image's code into the webroot and, when the copied -# version.php is older than the image, runs `occ upgrade` against -# {{ pg_database }} before Apache answers — and `Restart=always` means systemd -# reports an active unit even while that crash-loops. +# Apply anything queued above — a new image, a changed unit, a changed environment +# file — *before* verifying, so that the checks below describe what will keep +# running rather than what is about to be replaced. A restart here is picked up by +# the waits that follow. +- name: Apply queued changes before verifying + ansible.builtin.meta: flush_handlers + +# The universal readiness gate, and the one thing `is-active` cannot tell us: +# Apache answering over loopback. status.php is Nextcloud's own endpoint and +# reports `installed` either way, so this passes for a setup wizard that has not +# been completed yet — which is the normal state after a first run. # -# occ is the app's own answer and is always present. It has to run as www-data -# (uid {{ app_uid }}): occ refuses to run as a user other than the one that owns -# config.php, which under keep-id is what the mount is owned by. -# -# The retry budget of the first wait matches the unit's TimeoutStartSec, because -# a first run after an image bump really can spend minutes in the database -# upgrade. -# -# The status JSON is matched after stripping spaces rather than with a regex, so -# both the compact form and a pretty-printed one match, and there is no `\s` to -# escape (an invalid escape in a Jinja string literal is only a warning today and -# may not stay one). -- name: Wait for Nextcloud to become ready - block: - - name: Wait for Nextcloud to report itself installed - ansible.builtin.command: >- - podman exec -u www-data {{ container_name }} - php occ status --output=json - register: nextcloud_status - until: >- - nextcloud_status.rc == 0 - and ((nextcloud_status.stdout | replace(' ', '')) is search('"installed":true')) - retries: 120 - delay: 5 - changed_when: false - - # Maintenance mode is ON for the whole of `occ upgrade`, which the entrypoint - # runs on the first start after an image bump — so waiting is not optional - # here: asserting immediately would fail a perfectly healthy deploy halfway - # through the upgrade. - # - # It can also be left ON in the config.php copied from monoco, because - # docs/backup-plan.md §3.4 tells an operator to set it before a consistent - # backup. That case never resolves, which is why the rescue below names it - # instead of leaving a bare timeout: `status.php` is exempt from maintenance - # mode, so the app looks up while every real request gets a 503. - - name: Wait for maintenance mode to be off - ansible.builtin.command: >- - podman exec -u www-data {{ container_name }} - php occ status --output=json - register: nextcloud_ready - until: >- - nextcloud_ready.rc == 0 - and ((nextcloud_ready.stdout | replace(' ', '')) is search('"installed":true')) - and not ((nextcloud_ready.stdout | replace(' ', '')) is search('"maintenance":true')) - retries: 120 - delay: 5 - changed_when: false - rescue: - - name: Collect Nextcloud readiness diagnostics - ansible.builtin.command: "{{ item }}" - loop: - - "podman exec -u www-data {{ container_name }} php occ status --output=json" - - "journalctl --user -u {{ unit_name }} --no-pager -n 40" - - "podman logs --tail 40 {{ container_name }}" - register: nextcloud_ready_diag - changed_when: false - failed_when: false - - - name: Fail with the reason - ansible.builtin.fail: - msg: >- - Nextcloud never became ready. If the status above shows - "maintenance": true, the instance is in maintenance mode — either left - on in the copied config.php or still mid-upgrade — and because - status.php is exempt from maintenance mode the site looks healthy while - every real request gets a 503. Clear it with: - podman exec -u www-data {{ container_name }} php occ maintenance:mode --off - Otherwise, see the diagnostics above. - -- name: Report Nextcloud status - ansible.builtin.debug: - msg: "{{ nextcloud_ready.stdout }}" - -# The check above runs the PHP CLI, which does not prove Apache is serving. This -# goes through the image's own front end, over loopback, and asserts the JSON -# status.php returns says the instance is installed. +# The probe runs inside the container with the image's own PHP, so it depends on +# nothing the play has to install. - name: Wait for Apache to serve status.php ansible.builtin.command: >- podman exec {{ container_name }} php -r '$b=@file_get_contents("http://127.0.0.1/status.php"); if ($b === false) exit(1); - $j=json_decode($b, true); exit(!empty($j["installed"]) ? 0 : 1);' + $j=json_decode($b, true); exit(isset($j["installed"]) ? 0 : 1);' register: nextcloud_http until: nextcloud_http.rc == 0 - retries: 24 + retries: 60 delay: 5 changed_when: false +# config.php is what the installer writes, so its presence is what separates "an +# installed instance" from "the image has initialised a webroot for the wizard". +# Reading it beats parsing `occ status`, whose output carries a warning line on an +# uninstalled instance and whose exit code is not a reliable signal. +- name: Check whether an instance is installed + ansible.builtin.stat: + path: "{{ host_directory }}/config/config.php" + register: nextcloud_config + +# An uninstalled instance is a successful run, not a failure: the container is up +# and serving the wizard, and this says what is left for a human (or for the two +# optional variables) to do. +- name: Note that the setup wizard still has to be finished + ansible.builtin.debug: + msg: >- + The container is running, but no instance is installed in + {{ host_directory }} yet. Open https://{{ host }} and finish the setup + wizard with the database credentials in {{ env_file }} — database + {{ pg_database }}, user {{ pg_user }}, host {{ db_host }}. To install + unattended instead, set nextcloud_admin_user and nextcloud_admin_password + in vars/default.yml and run this play again. + when: not nextcloud_config.stat.exists + +# The update path. `occ upgrade` sets maintenance mode for the length of a +# database upgrade, which the entrypoint runs itself when the image is newer than +# the installed version — so this waits for it to clear rather than asserting it +# is off, and only asks once an instance exists. +- name: Wait for maintenance mode to be off + ansible.builtin.command: >- + podman exec -u www-data {{ container_name }} + php occ status --output=json + register: nextcloud_status + until: >- + nextcloud_status.rc == 0 + and ('"maintenance":true' not in (nextcloud_status.stdout | replace(' ', ''))) + retries: 120 + delay: 5 + changed_when: false + when: nextcloud_config.stat.exists + +- name: Report the instance status + ansible.builtin.debug: + msg: "{{ nextcloud_status.stdout_lines | default(['(not installed yet)']) }}" + # The database and the cache arrive over the networks that roles/postgres and -# roles/valkey attached them to. Check the names config.php actually asks for, -# plus the container names, because a wrong alias is the failure that looks like -# a broken app rather than a broken network. +# roles/valkey attached them to. Check the names the app actually asks for, plus +# the container names, because a wrong alias is the failure that looks like a +# broken app rather than a broken network. - name: Confirm Nextcloud can resolve the database and the cache ansible.builtin.command: >- podman exec {{ container_name }} getent hosts {{ item }} @@ -323,11 +293,10 @@ # --------------------------------------------------------------------------- # Background jobs # --------------------------------------------------------------------------- -# The Docker-era playbook added a *root* crontab entry running `php cron.php` -# every five minutes. Root cannot `podman exec` into another user's rootless -# containers (docs/podman-migration.md §8(b)), so it becomes a systemd user timer -# next to the Quadlet units — which is also what makes it survive a reboot and -# report its failures in the journal. +# Nextcloud expects cron.php every five minutes. It cannot be a crontab entry: +# root cannot `podman exec` into another user's rootless containers, and there is +# no container user to own a crontab. A systemd user timer next to the Quadlet +# units is the equivalent, and it is what comes back after a reboot. - name: Ensure the systemd user unit directory exists ansible.builtin.file: diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 index 9a059aa..71ee093 100644 --- a/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 +++ b/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 @@ -1,14 +1,11 @@ # Nextcloud background jobs — systemd user service. # Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. # -# This replaces the Docker-era root crontab entry: -# -# */5 * * * * docker exec -u www-data nextcloud php cron.php -# -# Root cannot `podman exec` into another user's rootless containers, and a -# container user's cron is not a thing. A user service started by the timer next -# to it is the idiomatic replacement, and it is what comes back after a reboot -# with the rest of the stack (docs/podman-migration.md §8(b), §10). +# Nextcloud expects `cron.php` every five minutes. It cannot be a crontab entry: +# root cannot `podman exec` into another user's rootless containers, and there is +# no container user to own a crontab. A user service started by the timer next to +# it is the equivalent, and it is what comes back after a reboot with the rest of +# the stack. # # No XDG_RUNTIME_DIR juggling is needed: systemd's *user* manager sets it for # every unit it starts, which is also why the whole playbook runs as the diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 index 1b24e30..352298b 100644 --- a/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 +++ b/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 @@ -1,8 +1,8 @@ # Nextcloud background jobs — systemd user timer. # Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. # -# Fires nextcloud-cron.service every five minutes, which is what Nextcloud's own -# installation guide asks for and what the Docker-era root crontab did. +# Fires nextcloud-cron.service every five minutes, which is the interval +# Nextcloud's own installation guide asks for. [Unit] Description=Run Nextcloud background jobs every five minutes diff --git a/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 b/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 index c011cda..0eab465 100644 --- a/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 +++ b/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 @@ -18,39 +18,35 @@ ContainerName={{ container_name }} Image={{ image }} # Map the container's www-data account (uid {{ app_uid }}) onto {{ podman_user }} -# on the host. The entrypoint starts as container-root — a subuid under keep-id — -# but it rsyncs the code in with `--chown www-data:www-data` and Apache drops to -# www-data, so everything it writes lands on {{ podman_user }} and no subuid-owned -# files appear to break backups. -# -# 33 is also the uid the data on monoco is owned by, so the copied tree needs no -# chown pass: extracting it as {{ podman_user }} is already the right ownership. +# on the host, so the webroot stays owned by {{ podman_user }} and no subuid-owned +# files appear to break backups. The entrypoint rsyncs the code in with +# `--chown www-data:www-data` and Apache runs its workers as that account. UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }} # POSTGRES_* and REDIS_HOST live here rather than as Environment= lines above: # this unit is world-readable in ~/.config, and POSTGRES_PASSWORD is in it. Read # by systemd, not by Ansible, so the file has to exist before this unit starts. # -# These are not an install step any more — config.php is copied from monoco, not -# generated. REDIS_HOST is still load-bearing on every start, because the -# entrypoint rewrites PHP's session-handler config from it. +# POSTGRES_* are what the entrypoint installs with on a fresh webroot; +# REDIS_HOST is load-bearing on every start, because the entrypoint rewrites +# PHP's session-handler config from it. EnvironmentFile={{ env_file }} Network={{ network_name }}.network -# The image's VOLUME and Apache document root. This is the whole installation: -# the code (version.php, core/, lib/, apps/), config/config.php, data/ and -# custom_apps/. The Docker-era playbook bind-mounted exactly this, and the -# entrypoint's version check reads version.php from here — which is why the -# migration copies the tree rather than only the state. See readme.md. +# The image's VOLUME and Apache document root. This is the whole installation: the +# code (version.php, core/, lib/, apps/), config/config.php, data/ and +# custom_apps/. The entrypoint's own version check reads version.php from here, so +# an image bump rsyncs new code into this directory and upgrades the database in +# place — see readme.md, "First run and updates". Volume={{ host_directory }}:{{ webroot }} # PHP's session-handler config. The entrypoint writes this file itself on every # start, from REDIS_HOST, but it cannot create it: `/usr/local/etc/php/conf.d` is # created by root in the php base image with no chown, so the uid this container -# runs as has no write bit on it. Mounting a host-owned file over the path is what -# makes that write land somewhere allowed — and because the mounted file replaces -# the directory entry, the directory's ownership never comes into it. +# runs as has no write bit on the directory. Mounting a host-owned file over the +# path is what makes that write land somewhere allowed — and because the mounted +# file replaces the directory entry, the directory's ownership never comes into it. # # This is the one place the entrypoint writes outside the webroot. Apache's own # runtime directories (/var/run/apache2, /var/lock/apache2, /var/log/apache2) do @@ -60,9 +56,9 @@ Volume={{ php_session_ini }}:{{ php_session_ini_mount }} [Service] Restart=always -# The entrypoint rsyncs the image's code into the webroot and, if the copied -# version.php is older than the image, runs `occ upgrade` before Apache answers. -# On a 320 MB database that is minutes, not seconds. +# A first start initialises the webroot; a start after an image bump rsyncs the +# new code in and runs `occ upgrade` before Apache answers. Both can take minutes +# on a large instance. TimeoutStartSec=600 [Install] diff --git a/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 b/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 index 6e14953..b809654 100644 --- a/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 +++ b/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 @@ -5,15 +5,23 @@ # and POSTGRES_PASSWORD is in it. Note that `podman inspect` still prints the # resulting environment to anyone who can run podman as this user. # -# config.php is copied from monoco, not generated, so the POSTGRES_* values are -# not an install step any more — they only have to agree with what config.php -# already carries, which is exactly what the Docker-era container passed. +# POSTGRES_* are what the entrypoint uses to write config.php when it installs a +# fresh instance; on an instance that is already installed they only have to agree +# with what config.php already carries, and nothing rewrites it. # -# REDIS_HOST is different: the entrypoint reads it on *every* start and rewrites -# PHP's session handler from it (/usr/local/etc/php/conf.d/redis-session.ini), so -# a wrong value here shows up as failed logins rather than as a missing cache. +# REDIS_HOST is live on *every* start: the entrypoint regenerates PHP's session +# handler from it ({{ php_session_ini_mount }}), so a wrong value here shows up as +# failed logins rather than as a missing cache. POSTGRES_DB={{ pg_database }} POSTGRES_USER={{ pg_user }} POSTGRES_PASSWORD={{ pg_password }} POSTGRES_HOST={{ db_host }} REDIS_HOST={{ redis_host }} +{% if nextcloud_admin_user | length > 0 and nextcloud_admin_password | length > 0 %} +# Consumed only while there is no config.php, by the entrypoint's unattended +# install. They are set in vars/default.yml; remove them from there to go back to +# the web wizard on a fresh host. +NEXTCLOUD_ADMIN_USER={{ nextcloud_admin_user }} +NEXTCLOUD_ADMIN_PASSWORD={{ nextcloud_admin_password }} +NEXTCLOUD_TRUSTED_DOMAINS={{ host }} +{% endif %} diff --git a/nextcloud/roles/postgres/tasks/main.yaml b/nextcloud/roles/postgres/tasks/main.yaml index b756e27..3be9784 100644 --- a/nextcloud/roles/postgres/tasks/main.yaml +++ b/nextcloud/roles/postgres/tasks/main.yaml @@ -26,3 +26,39 @@ # runs nothing is pending and this is a no-op. - name: Apply the Postgres network change before starting Nextcloud ansible.builtin.meta: flush_handlers + +# The database itself. Postgres is a shared unit that ships one default database, +# and nothing else in this repo knows what this app's database is called — so +# without this, a first run on a fresh host reaches the setup wizard (or the +# unattended install) only to fail on "database {{ pg_database }} does not exist". +# Later runs find it and do nothing. +# +# psql runs inside the Postgres container rather than over the app network, so the +# only thing this needs is a cluster that answers. The retry loop is there because +# the flush above may have just restarted it. +- name: Check whether the {{ pg_database }} database exists + ansible.builtin.command: >- + podman exec -e PGPASSWORD={{ pg_password }} {{ postgres_container }} + psql -U {{ pg_user }} -d postgres -tAc + "select datname from pg_database where datname = '{{ pg_database }}'" + register: nextcloud_db + until: nextcloud_db.rc == 0 + retries: 12 + delay: 5 + changed_when: false + failed_when: false + +- name: Fail if the shared Postgres is not reachable + ansible.builtin.assert: + that: nextcloud_db.rc == 0 + fail_msg: >- + The shared Postgres container ({{ postgres_container }}) did not answer. + Run postgres/playbook.yml on this host first: this playbook attaches to that + cluster and creates its own database in it. Postgres said: + {{ nextcloud_db.stderr | default('') | trim }} + +- name: Create the {{ pg_database }} database + ansible.builtin.command: >- + podman exec -e PGPASSWORD={{ pg_password }} {{ postgres_container }} + createdb -U {{ pg_user }} -O {{ pg_user }} {{ pg_database }} + when: nextcloud_db.stdout | trim | length == 0 diff --git a/nextcloud/roles/valkey/tasks/main.yaml b/nextcloud/roles/valkey/tasks/main.yaml index f8d833c..515104b 100644 --- a/nextcloud/roles/valkey/tasks/main.yaml +++ b/nextcloud/roles/valkey/tasks/main.yaml @@ -8,10 +8,10 @@ # asserts that nextcloud.network already exists, and notifies the "Restart # Valkey" handler defined in this role. # -# `redis` is the alias config.php connects to, not the container name: the image -# writes the host from REDIS_HOST, which the Docker-era `env:` block set to -# `redis`, and the same variable configures PHP's session handler on every start. -# Renaming it here would mean editing config.php on a live instance for nothing. +# `redis` is the alias the app connects to, not the container name: REDIS_HOST is +# what the image writes the PHP session handler from, and what this play puts in +# config.php's Redis configuration on an unattended install. Renaming it would +# mean editing a live instance's configuration for nothing. - name: Attach Nextcloud to Valkey ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml" diff --git a/nextcloud/vars/default.yml b/nextcloud/vars/default.yml index 6db2313..5778c4f 100644 --- a/nextcloud/vars/default.yml +++ b/nextcloud/vars/default.yml @@ -19,6 +19,8 @@ quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" # --------------------------------------------------------------------------- # Nextcloud # --------------------------------------------------------------------------- +# The one site-specific value: the hostname Caddy serves this instance on and the +# hostname that goes into trusted_domains when the install is unattended. host: cloud.hopsngrains.com container_name: nextcloud @@ -26,7 +28,7 @@ unit_name: "{{ container_name }}.service" # Fully qualified on purpose: rootless Podman resolves short names against # unqualified-search-registries, and there is no TTY to answer a prompt when the -# systemd unit starts. The Docker-era playbook said `nextcloud:latest`. +# systemd unit starts. image: docker.io/library/nextcloud:latest # The Quadlet file is .network. caddy/roles/register pins the @@ -36,31 +38,30 @@ network_name: "{{ container_name }}" # The image's VOLUME and Apache document root — and, unlike the other services # here, it holds the *code* as well as the state: config.php, data/, -# custom_apps/ and every app's PHP files live under it. The Docker-era playbook -# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that -# shape on purpose. See readme.md, "What is state, and what is not". +# custom_apps/ and every app's PHP file live under it. See readme.md, "What is +# state, and what is not". host_directory: "{{ podman_data_dir }}/{{ container_name }}" webroot: /var/www/html # The uid the *server* writes as: 33 is www-data, which owns the webroot, because # the entrypoint rsyncs the code in with --chown www-data:www-data and Apache runs -# its workers as that account. `docker exec nextcloud id -u` reported 0 on the old -# host, because the entrypoint drops privileges after starting — do not derive the -# mapping from that number (docs/podman-compat-audit.md §3.2). +# its workers as that account. `docker exec nextcloud id -u` reports 0 in the +# upstream image, because the entrypoint drops privileges after starting — do not +# derive the mapping from that number (docs/podman-compat-audit.md §3.2). app_uid: 33 app_gid: 33 # The port Apache listens on inside the container, and the one Caddy proxies to. container_port: 80 -# PHP's session-handler config, and the one path in the image this deployment has -# to write to. The entrypoint generates the file from REDIS_HOST on every start, -# but its target directory (`/usr/local/etc/php/conf.d`) is created by root in the -# php base image with no chown, so the uid this container runs as cannot create a -# file inside it. Mounting a host-owned file over that exact path is what gives -# the write somewhere it is allowed to land, and the template below keeps the -# image's own content in it so a refused write is not fatal either. See readme.md, -# "The PHP session file". +# PHP's session-handler config, and the one path in the image the entrypoint has +# to write to. It generates the file from REDIS_HOST on every start, but its +# target directory (`/usr/local/etc/php/conf.d`) is created by root in the php +# base image with no chown, so the uid this container runs as cannot create a file +# inside it. Mounting a host-owned file over that exact path is what gives the +# write somewhere it is allowed to land, and the template keeps the image's own +# content in it so a refused write is not fatal either. See readme.md, "The PHP +# session file". php_conf_dir: "{{ host_directory }}/php-conf.d" php_session_ini: "{{ php_conf_dir }}/redis-session.ini" php_session_ini_mount: /usr/local/etc/php/conf.d/redis-session.ini @@ -76,16 +77,16 @@ env_file: "{{ host_directory }}/nextcloud.env" postgres_container: postgres postgres_unit: "{{ postgres_container }}.service" -# The credentials and database name the Docker-era playbook embedded in its -# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB, -# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree. +# The database this instance uses, and the credentials for it. They have to match +# whatever the shared Postgres unit was initialised with (postgres/vars/default.yml) +# — this play attaches to a database, it does not create one, so the database has +# to exist before the first run. pg_user: postgres pg_password: '123456' pg_database: nextcloud -# `db` is the host config.php actually connects to — the Docker-era container set -# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared -# Postgres unit. +# `db` is the alias postgres/roles/attach adds to the shared Postgres unit, and +# the host this instance's config.php connects to. db_host: db # The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and @@ -100,21 +101,36 @@ redis_host: redis caddy_name: caddy caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" +# --------------------------------------------------------------------------- +# First-run install (optional) +# --------------------------------------------------------------------------- +# Leave both empty to install through the web wizard at https://{{ host }} — the +# play reports that this is what is left to do and succeeds. Set both to have the +# image install unattended on a fresh webroot instead: it runs +# `occ maintenance:install` with these credentials and sets trusted_domains to +# `host` in the same pass, so the instance is usable immediately. +# +# They are only consumed while there is no config.php, so they are inert on an +# existing instance, and the environment template omits them entirely when they +# are empty (an empty NEXTCLOUD_ADMIN_USER would be treated as a value and fail +# the install). +nextcloud_admin_user: "" +nextcloud_admin_password: "" + # --------------------------------------------------------------------------- # Background jobs # --------------------------------------------------------------------------- -# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in -# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root -# cannot `podman exec` into another user's rootless containers, so it becomes a -# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)). +# Nextcloud wants `cron.php` every five minutes. It cannot be a crontab entry: +# root cannot `podman exec` into another user's rootless containers, and there is +# no container user to own a crontab. A systemd user timer next to the Quadlet +# units is the equivalent. cron_unit: "{{ container_name }}-cron" cron_schedule: "*:0/5" user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user" -# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and -# Nextcloud has always asked for HSTS. There is no request-body limit to carry -# over — Caddy streams bodies and imposes none by default, whereas the dead nginx -# front end needed client_max_body_size spelled out. +# The site block. Caddy terminates TLS, and Nextcloud asks for HSTS. There is no +# request-body limit to carry over: Caddy streams bodies and imposes none by +# default. snippet_block: | {{ host }} { reverse_proxy {{ container_name }}:{{ container_port }}