Make the Nextcloud playbook standalone: first run installs, later runs update

The playbook is meant to be usable on its own, on any host, the way every other
one here is: the first run creates the container, and every run after that
converges it. It had picked up a migration shape instead -- a readme that assumed
a webroot copied in from monoco, a warning that nagged when config.php was
missing, and, worse, readiness gates that only passed for an instance that was
already installed.

That last one was a real bug for the intended use. On a host with no webroot, the
image initialises the code, writes no config.php because it has no admin
credentials to install with, and serves the setup wizard -- so `occ status` never
reports installed: true, and the play would have spent its whole retry budget and
then failed on a first run that was going exactly as designed.

What replaces it:

- The readiness gate is Apache answering status.php inside the container, which
  is Nextcloud's own endpoint and reports `installed` either way. It is the one
  thing `is-active` cannot tell us, and it passes for a wizard that has not been
  completed.
- config.php's presence then decides the report: wait for maintenance mode to
  clear and print `occ status` for an instance, or say what is left for a human to
  do for a fresh one -- and succeed. The two optional credentials in
  vars/default.yml install unattended instead, in which case the same endpoints
  come back installed and the wizard note never appears.
- The app creates its own database. Postgres is a shared unit with one default
  database and nothing else in this repo knows this app's database name, so a
  fresh cluster previously reached the installer and failed on "database
  nextcloud does not exist". It is a lookup and a `createdb`, a no-op afterwards,
  and it fails with the instruction to run postgres/playbook.yml if the cluster
  is not there rather than with a bare psql error.
- Updates. An update in a Quadlet deployment is "pull, then recreate": the unit
  names a tag, `podman run` reuses the local image, and nothing fetches a newer
  one. The role pulls on every run and compares the image ID either side, so an
  unchanged tag is not a change and a running site is not restarted for nothing.
- Handlers are flushed before verification, so a queued recreate -- new image,
  changed unit, changed environment -- happens before the checks rather than after
  them, and the checks describe what will keep running.

Removed: the entire monoco migration section and its copy commands, the
config.php warning that existed only to serve it, the host-specific notes, and the
narrative comments about this play's first runs. References to the audit and
migration planning docs go with them; the ownership reasoning is stated inline,
since it does not depend on where an instance came from. `hosts:` is now the only
host-specific thing in the directory.

The readme is rewritten around the two paths it now has -- "First run" and
"Updates" -- with the deploy order, the session-file trap and the debugging
commands kept.
This commit is contained in:
tanshu committed 2026-10-16 12:59:18 +05:30
1 parent 1038037e45
commit be53cec757
11 files changed
+504 -665

No files matched your search

+9
View File
@@ -4,6 +4,12 @@
# (postgres/playbook.yml) and its cache in the shared Valkey unit # (postgres/playbook.yml) and its cache in the shared Valkey unit
# (valkey/playbook.yml). # (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 # 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 # units belong to it, so the connection's PAM session is what makes
# `systemctl --user` work without any environment juggling. # `systemctl --user` work without any environment juggling.
@@ -19,6 +25,9 @@
# - name: Rootless Podman prerequisites # - name: Rootless Podman prerequisites
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" # 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 # The variables this play needs are duplicated at the top of vars/default.yml, so
# it stands on its own. # it stands on its own.
# #
+234 -425
View File
@@ -1,46 +1,44 @@
# Nextcloud (rootless Podman + Quadlet) # Nextcloud (rootless Podman + Quadlet)
Runs Nextcloud on `emily` as a **rootless Podman** container managed by a Runs Nextcloud as a **rootless Podman** container managed by a **systemd user
**systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md), unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md), with its
with its database in the shared [rootless Postgres](../postgres/readme.md) and its database in the shared [rootless Postgres](../postgres/readme.md) and its cache in
cache in the shared [rootless Valkey](../valkey/readme.md). Replaces the the shared [rootless Valkey](../valkey/readme.md).
`docker_container` deployment on `monoco`.
The container name, network, unit, Caddyfile upstream and database are all Standalone and idempotent, and host-agnostic apart from `hosts:` in
`nextcloud`, as they have been since the Docker-era playbook. The **host is [playbook.yml](playbook.yml) and `host` in [vars/default.yml](vars/default.yml):
unchanged**: `cloud.hopsngrains.com`.
Nextcloud is the one service here whose webroot is *also* its code directory. - **First run** creates the webroot, the environment file, the unit and the
The Docker-era playbook bind-mounted `/var/lib/nextcloud` at `/var/www/html`, and background-job timer, starts the container, and leaves a fresh instance ready
this one keeps that shape: the image's `VOLUME`, `config/config.php`, `data/`, for its [setup wizard](#first-run) — or installs it unattended, if you set the
`custom_apps/` and every PHP file live under one mount. That single fact drives two optional credentials first.
both the [migration](#migrating-the-data-from-monoco) and the ownership story - **Every run after that** converges the same things, and Quadlet recreates the
below — see [What is state, and what is not](#what-is-state-and-what-is-not). 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 ## Deploy order
playbook is the last step:
1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns Three shared services have to exist on the host first, each of them standalone:
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.
`config.php` connects to the hosts `db` and `redis`. Until this playbook has 1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns the
attached `nextcloud_net` to Postgres and to Valkey, neither name resolves on this Caddyfile this site appends to, supplies the `caddy.service` handlers, and is
network, so running this playbook is what makes the app reachable — there is no the one play that applies the rootless-Podman prerequisites.
separate step. 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 ## Layout
@@ -49,10 +47,10 @@ nextcloud/
├── playbook.yml ├── playbook.yml
├── vars/default.yml ├── vars/default.yml
└── roles/ └── roles/
├── postgres/ # attaches this app's network to Postgres (+ its handler) ├── postgres/ # the database, and this app's network on the cluster
├── valkey/ # attaches this app's network to Valkey (+ its handler) ├── valkey/ # this app's network on the cache
├── nextcloud/ # webroot, the 0600 env file, the Quadlet unit, the cron ├── nextcloud/ # webroot, env file, Quadlet unit, session config,
│ # timer, start + verify │ # background-job timer, start + verify
└── caddy/ # appends this site's block to the shared Caddyfile └── 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) | | `~/.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/` | 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/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 | | `~/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 ## What is state, and what is not
Per [docs/backup-plan.md §2.1](../docs/backup-plan.md), the instance is 1.2 GB The webroot is about 1.2 GB on a working instance, and most of it is not worth
and only a fraction of it is irreplaceable: backing up:
| Path | Keep? | Why | | 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 | | `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 | | `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 The database is separate and is not in this directory at all. A backup that
mount holds the code, the entrypoint decides what to do by reading matters is `config/`, `data/`, `custom_apps/` **and** a `pg_dump` of the
`/var/www/html/version.php` **from the mount**: copy the whole tree and the first `nextcloud` database taken together — files without the database restore to an
start is close to a no-op; copy only the state and `version.php` is missing, so instance that does not know about its own contents, and the reverse is just as
every start looks like a brand-new instance — no `occ upgrade`, no version useless. Nextcloud's own guidance is `occ maintenance:mode --on` around the pair;
tracking — and Nextcloud would then have to be talked through a manual upgrade. 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 ## 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. for the same reason.
`network_name` is `nextcloud`, so the *file* is `nextcloud.network` and the `network_name` is `nextcloud`, so the *file* is `nextcloud.network` and the
*Podman network* is `nextcloud_net` (`NetworkName=` in the network unit pins it, *Podman network* is `nextcloud_net`.
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.
The site block is appended to the shared Caddyfile by The site block is appended to the shared Caddyfile by
[roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the [roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the
other services do. **Only `caddyfile_path` changed** — from other services do — plus the HSTS header Nextcloud asks for. There is no
`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile` — and the request-body limit to set: Caddy streams request bodies and imposes none by
block itself is the Docker-era one, HSTS header included: default.
```
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.
## How it attaches to Postgres ## 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/50-nextcloud.conf` | `Network=nextcloud.network` |
| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` | | `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` |
`config.php`'s `dbhost` is `db`, which is what the Docker-era container's `db` is the alias `config.php` connects to; the `postgres` container name resolves
`POSTGRES_HOST=db` wrote; the `postgres` container name resolves too, and the play too, and the play checks both.
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: reasons:
- it defines the `Restart Postgres` handler the include notifies; and - it defines the `Restart Postgres` handler the include notifies;
- it **flushes handlers** immediately afterwards. On a first attach the drop-in - it **flushes handlers** immediately afterwards. On a first attach the drop-in is
is new, so Postgres has to be *recreated* to join `nextcloud_net` — and that new, so Postgres has to be *recreated* to join `nextcloud_net` — and that restart
restart is a handler, which by default runs at the end of the play, after the is a handler, which by default runs at the end of the play, after the `nextcloud`
`nextcloud` role has already started the app. The app would then come up before role has already started the app. The app would then come up before `db`
`db` resolves, fail its own readiness check, and fail the play on a machine resolves, fail its readiness check, and fail the play on a machine where nothing
where nothing is wrong. The flush also fires the `Restart Caddy` queued by the is wrong. The flush also fires the `Restart Caddy` queued by the register
register pre_task, which is what attaches Caddy to the network. On later runs pre_task, which is what attaches Caddy to the network. On later runs nothing is
nothing is pending and it is a no-op. 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 Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new drop-in
drop-in recreates Postgres, which is a few seconds of downtime for **every** recreates Postgres, which is a few seconds of downtime for **every** attached app,
attached app, not just this one. not just this one.
## How it attaches to Valkey ## 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/50-nextcloud.conf` | `Network=nextcloud.network` |
| `valkey.container.d/10-network-aliases.conf` | `NetworkAlias=redis` | | `valkey.container.d/10-network-aliases.conf` | `NetworkAlias=redis` |
`redis` is not a name this playbook chose. The image writes `config.php`'s Redis `redis` is not a name this playbook chose: the image writes the Redis host into
host from `REDIS_HOST`, the Docker-era `env:` block set it to `redis`, and the `config.php` and into PHP's session config from `REDIS_HOST`, which is `redis`
same variable rewrites PHP's session-handler config on **every** start. Renaming here. Renaming it would mean editing a live instance's configuration for nothing.
it would mean editing `config.php` on a live instance for nothing.
It is also **not optional**. `config.php` carries `memcache.locking` pointed at It is also **not optional**. Go through the setup wizard and Nextcloud puts
Redis, so an unresolvable `redis` does not merely make things slower — uploads `memcache.locking` on Redis; the image's own install does too when the unattended
fail, and they fail at write time rather than at startup. That is why credentials are set. An unresolvable `redis` therefore does not merely make things
`roles/valkey` flushes handlers before the app starts (same reason as Postgres), slower — uploads fail, and they fail at write time rather than at startup. That is
and why `REDIS_HOST` is in the environment file at all. 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 The cost of the attach is a recreate of Valkey: no keys are lost (RDB persistence
as Postgres's. No keys are lost — RDB persistence is on by default, the stop is is on by default, the stop is graceful, and the new container loads the snapshot
graceful, and the new container loads the snapshot back off the bind mount — but back off the bind mount), but for the seconds it is down, PHP sessions and
for the seconds it is down, Nextcloud's PHP sessions and its `memcache.locking` `memcache.locking` are unreachable. See [valkey/readme.md](../valkey/readme.md),
are unreachable, so logins and uploads stall rather than fail cleanly. See "The trade-off".
[valkey/readme.md](../valkey/readme.md), "The trade-off".
## Background jobs ## Background jobs
Nextcloud expects `cron.php` to run every five minutes, and the Docker-era Nextcloud expects `cron.php` every five minutes. It cannot be a crontab entry:
playbook scheduled it in **root's crontab**: 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:
*/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:
| Unit | What it does | | 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` | | `~/.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 — 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 that is what brings the timer back after a reboot. `journalctl --user -u
`journalctl --user -u nextcloud-cron` shows every run and every failure, which the nextcloud-cron` shows every run and every failure.
old crontab never did.
The background-job *mode* is left alone: `config.php` came from monoco with The background-job *mode* is left alone. Both this timer and AJAX polling run
whatever it had, and this playbook does not switch it to `cron`. Both the timer jobs, and job locking keeps them from colliding; switching AJAX off is
and AJAX polling run jobs; job locking is what keeps them from colliding. If you `occ background:cron`, a deliberate change rather than something this play
want AJAX disabled, that is `occ background:cron`, a deliberate change rather imposes.
than a migration step.
## Settings ## Settings
@@ -244,19 +212,21 @@ than a migration step.
| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | | `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand |
| `podman_data_dir` | `~/podman-data` | | `podman_data_dir` | `~/podman-data` |
| `quadlet_dir` | `~/.config/containers/systemd` | | `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 | | `container_name` | `nextcloud`, and the Quadlet unit / container name |
| `image` | `docker.io/library/nextcloud:latest` — fully qualified on purpose | | `image` | `docker.io/library/nextcloud:latest` — fully qualified on purpose |
| `network_name` | `nextcloud` → `nextcloud.network` / `nextcloud_net` | | `network_name` | `nextcloud` → `nextcloud.network` / `nextcloud_net` |
| `host_directory` / `webroot` | `~/podman-data/nextcloud` → `/var/www/html` | | `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 | | `container_port` | `80`, the port inside the container that Caddy proxies to |
| `env_file` | `~/podman-data/nextcloud/nextcloud.env`, mode `0600` | | `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 | | `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 | | `db_host` | `db` — the alias `config.php` connects to |
| `valkey_container` / `valkey_unit` | The shared cache unit this app attaches 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 | | `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 | | `cron_unit` / `cron_schedule` / `user_unit_dir` | The background-job timer |
| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | | `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 connecting as `root`**; the prerequisites, including that check, live in
`caddy/playbook.yml`. `caddy/playbook.yml`.
`UserNS=keep-id:uid=33,gid=33` — `33` is the uid the server writes as. Whatever `UserNS=keep-id:uid=33,gid=33`. `33` is `www-data`, the uid the webroot is owned
the entrypoint's *own* starting uid under this mapping, the writes that matter land by: the entrypoint rsyncs the code in with `--chown www-data:www-data` and Apache
on `podman_user`: the code is rsynced with `--chown www-data:www-data`, and Apache's runs its workers as that account. `keep-id` maps that one uid onto the connection
workers run as `www-data` user, so the whole webroot stays owned by `tanshu` and plain `tar` and `rm -rf`
([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). `33` is also keep working on it.
what the data is owned by on monoco, which is what makes the copy below need no
`chown` pass.
Two paths the entrypoint writes fall outside that arrangement, and both are handled Two paths the entrypoint writes fall outside that arrangement, and both are
explicitly rather than left to chance — each one is somewhere a naive deployment handled explicitly rather than left to chance — each is somewhere a naive
ends up with a file owned by a `165536+` subuid, or with no deployment at all: 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 - **`/var/www/html/nextcloud-init-sync.lock`**, which the entrypoint creates with
root-owned shell redirect. The role **pre-creates it** as `podman_user`: the 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 redirect truncates the file rather than recreating it, so its owner never
afterwards. changes afterwards.
- **`/usr/local/etc/php/conf.d/redis-session.ini`**, in the image, which is not - **`/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 — 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 and it is, because PHP sessions should survive a redeploy — the entrypoint
sessions should survive a redeploy — the entrypoint generates that session generates that session configuration on **every** start:
configuration on **every** start:
```sh ```sh
/entrypoint.sh:121 /entrypoint.sh:121
@@ -345,14 +318,9 @@ chmods them `1777`:
done done
``` ```
`POSTGRES_PASSWORD` is the fleet's shared `123456`, which matches so the session file is the only write that was ever going to fail.
`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.
## Running ## First run
```command ```command
ansible-playbook -u tanshu nextcloud/playbook.yml 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 The play targets `emily` directly, so the inventory must contain it — the one in
`prepare-server/` does, and its `ansible.cfg` names it. `prepare-server/` does, and its `ansible.cfg` names it.
This play applies **only** this app's units and data. The rootless-Podman On a host with no webroot yet, this is what happens, in order: the webroot and the
prerequisites — installing podman, `loginctl enable-linger`, the low-port sysctl PHP session directory are created; the `0600` environment file, the session config
and the Quadlet directory — are the shared task file at and the Quadlet unit are written; the unit is started; anything the run queued (a
[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and new image, a changed unit or environment) is applied *before* verification; and
are **not** run here. A host that has already run `caddy/playbook.yml` has them. 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 At that point there is a running container and **no instance yet** — the image
*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. The initialises the code, writes no `config.php` (it has no admin credentials to
cron **timer** is a plain unit and is enabled explicitly, as described above. install with), and serves the setup wizard. The play says so and succeeds:
## Migrating the data from monoco ```
The container is running, but no instance is installed in
The database and the files have to correspond, so this is a freeze, not a /home/tanshu/podman-data/nextcloud yet. Open https://cloud.hopsngrains.com and
best-effort copy. Run it from the machine that holds the `monoco` and `emily` SSH finish the setup wizard with the database credentials in .../nextcloud.env —
aliases, as in [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). database nextcloud, user postgres, host db. ...
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;"'
``` ```
### 1. Freeze monoco Three things to know about that wizard:
- **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:
```bash ```bash
# Stop the app first: a stopped container cannot write to data/ while it is being podman exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=cloud.hopsngrains.com
# 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'
``` ```
### 2. Make the database correspond (only if it moved since the cluster move) 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.
The cluster was dumped on **2026-10-15**. If monoco's Nextcloud has served writes ## Updates
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.
If the app is already running on `emily` from an earlier attempt, stop it first: Nothing special is required: re-run the playbook. What happens depends on what
`DROP DATABASE` needs the connections gone, and a running Nextcloud holds several. changed.
```bash | Changed | What the play does |
ssh emily 'systemctl --user stop nextcloud.service' # only if it is already deployed |---|---|
| 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 |
```bash The image is pulled on every run precisely because a Quadlet unit alone cannot see
# Dump on monoco (Postgres is a separate container and is still running). a tag move: the unit names `nextcloud:latest`, `podman run` reuses whatever image
ssh monoco 'mkdir -p ~/pg-migrate && cd ~/pg-migrate is local, and nothing fetches a newer one. The role reads the image ID either side
docker exec -e PGPASSWORD=123456 postgres pg_dump -U postgres -Fc nextcloud \ of the pull, so an unchanged tag reports no change and does not restart a running
| zstd -T0 -6 -o nextcloud-$(date +%Y%m%d-%H%M).dump.zst site. A pull that fails (a host with no route to the registry) is reported and the
ls -lh nextcloud-*.dump.zst' run continues with the local image.
# Transfer, then check the checksum on both sides before trusting it. **Version drift is the one irreversible step.** The entrypoint compares
ssh monoco 'cat ~/pg-migrate/nextcloud-*.dump.zst' \ `version.php` in the webroot against the image. A newer image rsyncs its code in
| ssh emily 'cat > ~/pg-migrate/nextcloud.dump.zst' and runs `occ upgrade` before Apache answers — and it will only step **one major
a=$(ssh monoco 'sha256sum ~/pg-migrate/nextcloud-*.dump.zst | cut -d" " -f1') version at a time**, so several majors behind means several runs with an
b=$(ssh emily 'sha256sum ~/pg-migrate/nextcloud.dump.zst | cut -d" " -f1') intermediate image. An image *older* than the installed version makes the
[ -n "$a" ] && [ "$a" = "$b" ] && echo "transfer OK: $a" \ container exit with "downgrading is not supported". So: pin the tag if you want
|| { echo "MISMATCH — do not restore: $a vs $b"; false; } no surprises, and take a database dump before a run that is going to upgrade.
# Reload it on emily. This DROPS the existing database — it is the one the `occ upgrade` sets maintenance mode for its duration, and the play **waits for it
# cluster move restored, and the frozen dump replaces it. to clear** rather than asserting it is off — so a long upgrade is tolerated, and an
ssh emily 'cd ~/pg-migrate instance left in maintenance mode (a backup that set `--on` and never finished) is
podman exec -e PGPASSWORD=123456 postgres \ reported with the one-line command to clear it. That distinction matters because
psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ `status.php` is exempt from maintenance mode: a site in that state looks healthy
-c "DROP DATABASE IF EXISTS nextcloud" \ to a probe while returning `503` to every real request.
-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"'
```
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.
## Verification ## Verification
@@ -565,28 +420,23 @@ systemctl --user list-timers nextcloud-cron.timer
systemctl --user start nextcloud-cron.service # run one by hand systemctl --user start nextcloud-cron.service # run one by hand
journalctl --user -u nextcloud-cron -n 20 journalctl --user -u nextcloud-cron -n 20
# The data really is the copied tree: # The database this play created, and its size:
ssh emily 'ls -ln ~/podman-data/nextcloud | head' podman exec -e PGPASSWORD=123456 postgres psql -U postgres -l | grep nextcloud
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 site, end to end: # The site, end to end:
curl -sSI https://cloud.hopsngrains.com curl -sSI https://cloud.hopsngrains.com
``` ```
Then open it **in a browser** and do two things a `curl` cannot: log in, and 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 **upload a file**. Uploading is the check that matters — it exercises the uid-33
uid-33 mapping (a wrong `keep-id` shows up as a permissions error), the Redis mapping (a wrong `keep-id` shows up as a permissions error), the Redis file
file locking in `config.php`, and PHP sessions, which the image configures from locking in `config.php`, and PHP sessions, which the image configures from
`REDIS_HOST`. `REDIS_HOST`.
After the upload, confirm on the host that the new file is owned by `tanshu`: After the upload, confirm on the host that the new file is owned by `tanshu`:
```bash ```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 # 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 unattended — without `loginctl enable-linger tanshu` neither will, and the failure
is silent. 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 ### Debugging a unit that will not start
A unit that has hit systemd's start limit reports only a state, and `Restart=always` 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: when a start fails; by hand:
```bash ```bash
ssh emily 'systemctl --user status nextcloud.service --no-pager -l | head -25 systemctl --user status nextcloud.service --no-pager -l | head -25
systemctl --user cat nextcloud.service # what systemd is running systemctl --user cat nextcloud.service # what systemd is running
journalctl --user -u nextcloud.service --no-pager -n 60 journalctl --user -u nextcloud.service --no-pager -n 60
podman ps -a --filter name=nextcloud podman ps -a --filter name=nextcloud
podman logs --tail 40 nextcloud' podman logs --tail 40 nextcloud
``` ```
Read it in this order: 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 above; anything about `db`/`redis` is the network attach; `downgrading is not
supported` is version drift. supported` is version drift.
2. **`podman ps -a`** — a container left behind under this name makes `podman run` 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 refuse to start.
interrupted run.
3. **`systemctl --user cat`** — the generated unit, if a drop-in or a `Network=` 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). 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 A `failed` unit needs its start-limit counter cleared before it will try again:
diagnostics at all, which is why the wait now lives inside the start block whose
rescue collects the above. ```bash
systemctl --user reset-failed nextcloud.service
systemctl --user start nextcloud.service
```
## Notes and gotchas ## 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` 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 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 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 ["The PHP session file"](#the-php-session-file).
write is no longer fatal. - **`activating` is not a healthy state.** A crash-looping container spends its
- **Maintenance mode travels with `config.php`.** The backup plan recommends time `activating` between restarts, so the play's wait retries only while the
`occ maintenance:mode --on` before a consistent dump state can still change on its own and otherwise hands over to a rescue that has
([docs/backup-plan.md §3.4](../docs/backup-plan.md)); if monoco was left that the logs. A bare `is-active` check would report either state as a failure with no
way, the copied configuration carries `maintenance => true` and the site returns explanation.
`503` for everything except `status.php`. The play **waits for maintenance mode - **A new drop-in restarts Postgres for everyone** (a few seconds without a
to clear** — it is on for the whole of a first-start `occ upgrade`, so it cannot database for every attached app), **registering restarts all of Caddy** (a brief
simply assert — and if it never clears, fails with the one-line command to turn restart of every hosted site) and **attaching restarts Valkey** (seconds without
it off. That is deliberate: "the app is up but every page 503s" points anywhere sessions or locks, though the keys survive the graceful stop). See the
but at maintenance mode. trade-offs in [postgres/readme.md](../postgres/readme.md),
- **Version drift is the one irreversible step.** The copied `version.php` is what [caddy/readme.md](../caddy/readme.md) and [valkey/readme.md](../valkey/readme.md).
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).
- **If the Caddyfile does not exist yet**, this playbook creates it containing only - **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 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 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. or zero-length file. Deploy Caddy first on a fresh host.
- **DNS.** `cloud.hopsngrains.com` must resolve to `emily` before Caddy can obtain - **Client IPs.** Nextcloud uses the client address for brute-force protection and
a certificate for it; the old certificates live in `~/podman-data/caddy/data` for the `X-Forwarded-For` it logs, so the reverse proxy has to be trusted — see
and are reused if the hostname is unchanged. [caddy/readme.md](../caddy/readme.md), "Client IPs", which also covers whether
- **The backup script still assumes Docker.** `~/bin/nextcloud-backup.sh` uses the original address survives the rootless port forward at all.
`--link postgres:db`, which Podman does not have, and its `sudo tar` / - **The webroot holds the code.** That is the image's design, not this playbook's:
`chown $(logname)` gymnastics are unnecessary now that the data is owned by you. an image bump edits `~/podman-data/nextcloud`. It is also why `apps/`, `core/`
It needs the `podman exec` form from and `lib/` are listed as regenerable above — they are, but only from an image
[postgres/readme.md](../postgres/readme.md), "Backups"; the database dump should that is the same version or newer.
also be taken with `occ maintenance:mode --on` around it, as - **`occ` must run as `www-data`.** It refuses to run as any other user, and under
[docs/backup-plan.md §3.4](../docs/backup-plan.md) says. `keep-id:uid=33` that is the same user the host sees as `tanshu`. Every
- **`~/bin/empty-trash.sh` targets a container named `cloud`**, which never `podman exec` in this file therefore carries `-u www-data`; the plain form works
existed on this host under that name — it is `nextcloud` — and it runs as root, for `getent`, which does not care.
which cannot `podman exec` into these containers at all. - **The backup scripts are not this playbook's business.** Whatever dumps the
- **Do not restart the container on monoco.** It would write to monoco's old database and archives the webroot has to run as a **systemd user timer** next to
cluster, which is being kept intact purely as a rollback. 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 ## Not done here
- Rotating the secrets that are committed in plaintext — `pg_password` is the - 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 fleet's shared `123456`, and it lives in `nextcloud.env` at mode `0600` rather
than in the world-readable unit file. than in the world-readable unit file.
- Switching the background-job mode to `cron` (`occ background:cron`), which the - Switching the background-job mode to `cron` (`occ background:cron`).
Docker-era deployment also never did. - Pinning the image to a digest, so that a `latest` bump is always deliberate.
- Replacing the broken `~/bin/nextcloud-backup.sh` with a systemd user timer, and - A backup timer for the webroot and the database.
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.
+2 -3
View File
@@ -10,9 +10,8 @@
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has # when the Caddyfile does not exist yet, on a host where the Caddy playbook has
# not run. # not run.
# #
# The marker is unchanged from the Docker-era role, so an existing # The marker is stable, so re-runs replace this site's block in place rather than
# cloud.hopsngrains.com block written on monoco is replaced in place rather than # appending a second copy of it.
# duplicated.
- name: "Add the {{ host }} site block to the Caddyfile" - name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile: ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}" path: "{{ caddyfile_path }}"
+136 -167
View File
@@ -1,7 +1,16 @@
--- ---
# Nextcloud as a rootless Podman Quadlet unit. # 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 # XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
# user manager is reachable through the standard environment. # user manager is reachable through the standard environment.
@@ -13,45 +22,15 @@
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: '0755' 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 # 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 # (`) 9> /var/www/html/nextcloud-init-sync.lock`, in the nextcloud/docker
# entrypoint). Creating it here means it belongs to {{ podman_user }} and stays # 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 # that way: the redirect *truncates* the file, it does not recreate it, so its
# owner never changes. # owner never changes.
# #
# Left to the container, the file would be created by container-root — a subuid # Left to the container, the file would be created by the entrypoint's root phase
# under keep-id — and would be the one thing in the webroot not owned by # — a subuid under keep-id — and would be the one thing in the webroot not owned
# {{ podman_user }}. # by {{ podman_user }}.
# #
# `force: false` leaves an existing file completely alone, which is what makes # `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 # this idempotent and what stops it failing on a file a previous run stranded as
@@ -77,26 +56,55 @@
mode: '0600' mode: '0600'
notify: Restart Nextcloud notify: Restart Nextcloud
# The second path the entrypoint writes, and the one that made this play fail for # An update in a Quadlet deployment is "pull, then recreate": the unit references
# real: with REDIS_HOST set, the entrypoint generates PHP's session-handler config # a tag, `podman run` reuses whatever image is local, and nothing fetches a newer
# at {{ php_session_ini_mount }} on every start, and that path's directory in the # `latest` on its own. The ID is read either side of the pull so an unchanged tag
# php base image is # 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" # RUN mkdir -p "$PHP_INI_DIR/conf.d"
# #
# — created by root, never chowned. The uid this container runs as therefore has # The uid this container runs as therefore cannot create a file there, and the
# no write bit on the directory, the entrypoint dies with # 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
# /entrypoint.sh: 121: cannot create # replaces the directory entry, so the directory's ownership stops mattering — and
# /usr/local/etc/php/conf.d/redis-session.ini: Permission denied # 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".
# 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.
- name: Ensure the PHP session config directory exists - name: Ensure the PHP session config directory exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ php_conf_dir }}" path: "{{ php_conf_dir }}"
@@ -141,19 +149,11 @@
# This waits rather than checking once, because `systemctl start` returns # This waits rather than checking once, because `systemctl start` returns
# while the unit is still `activating` and `is-active` exits 3 for that state. # 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 # `activating` is not evidence of a healthy start: a container that crash-loops
# wrong once and should not again: a container that crash-loops spends its # spends its time `activating` between `Restart=always` attempts. What this
# time `activating` between `Restart=always` attempts, and the first run here # wait buys is a prompt, bounded stop — it retries only while the state can
# was read as a healthy start mid-transition when the entrypoint was in fact # still change on its own, and a terminal `failed` or `inactive` falls through
# dying on every attempt (it could not write # to the rescue below, which has the logs.
# /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.
- name: Wait for the Nextcloud unit to become active - name: Wait for the Nextcloud unit to become active
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: nextcloud_active register: nextcloud_active
@@ -167,15 +167,13 @@
rescue: rescue:
# systemd reports "control process exited with error code" and nothing else, # systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (an unattached network, an # which is useless for the usual failures (an unattached network, an
# unreachable database, an unwritable webroot, a container name left behind # unreachable database, an unwritable webroot, a path in the image the
# by an interrupted run). Pull the real reason out. # entrypoint cannot write). Pull the real reason out.
# #
# `cat` is in the list because the generated unit settles what systemd thinks # `systemctl cat` settles what systemd thinks it is running, and `podman ps -a`
# it is waiting for, and `podman ps -a` is in it because the failure most # catches a container left behind under this name by an interrupted run, which
# likely to bite after an aborted run is a container that already exists # makes `podman run` refuse to start and leaves `podman logs` showing the old
# under this name: `podman run` then refuses to start, `Restart=always` # container and explaining nothing.
# retries it into systemd's start limit, and the unit ends up `failed` with
# nothing in `podman logs` to explain it.
- name: Collect Nextcloud diagnostics - name: Collect Nextcloud diagnostics
ansible.builtin.command: "{{ item }}" ansible.builtin.command: "{{ item }}"
loop: loop:
@@ -195,113 +193,85 @@
- name: Fail - name: Fail
ansible.builtin.fail: ansible.builtin.fail:
msg: >- msg: >-
Nextcloud did not come up — see the diagnostics above. If `podman ps Nextcloud did not come up — see the diagnostics above. If `podman ps -a`
-a` shows a container that already exists under this name, it was left shows a container already using this name, it was left behind by an
behind by an interrupted run; clear it and start again with: interrupted run; clear it with:
systemctl --user reset-failed {{ unit_name }} ; systemctl --user reset-failed {{ unit_name }} ;
systemctl --user stop {{ unit_name }} ; podman rm -f {{ container_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. # Apply anything queued above — a new image, a changed unit, a changed environment
# The entrypoint rsyncs the image's code into the webroot and, when the copied # file — *before* verifying, so that the checks below describe what will keep
# version.php is older than the image, runs `occ upgrade` against # running rather than what is about to be replaced. A restart here is picked up by
# {{ pg_database }} before Apache answers — and `Restart=always` means systemd # the waits that follow.
# reports an active unit even while that crash-loops. - 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 # The probe runs inside the container with the image's own PHP, so it depends on
# (uid {{ app_uid }}): occ refuses to run as a user other than the one that owns # nothing the play has to install.
# config.php, which under keep-id is what the mount is owned by. - name: Wait for Apache to serve status.php
# ansible.builtin.command: >-
# The retry budget of the first wait matches the unit's TimeoutStartSec, because podman exec {{ container_name }} php -r
# a first run after an image bump really can spend minutes in the database '$b=@file_get_contents("http://127.0.0.1/status.php"); if ($b === false) exit(1);
# upgrade. $j=json_decode($b, true); exit(isset($j["installed"]) ? 0 : 1);'
# register: nextcloud_http
# The status JSON is matched after stripping spaces rather than with a regex, so until: nextcloud_http.rc == 0
# both the compact form and a pretty-printed one match, and there is no `\s` to retries: 60
# escape (an invalid escape in a Jinja string literal is only a warning today and delay: 5
# may not stay one). changed_when: false
- name: Wait for Nextcloud to become ready
block: # config.php is what the installer writes, so its presence is what separates "an
- name: Wait for Nextcloud to report itself installed # 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: >- ansible.builtin.command: >-
podman exec -u www-data {{ container_name }} podman exec -u www-data {{ container_name }}
php occ status --output=json php occ status --output=json
register: nextcloud_status register: nextcloud_status
until: >- until: >-
nextcloud_status.rc == 0 nextcloud_status.rc == 0
and ((nextcloud_status.stdout | replace(' ', '')) is search('"installed":true')) and ('"maintenance":true' not in (nextcloud_status.stdout | replace(' ', '')))
retries: 120 retries: 120
delay: 5 delay: 5
changed_when: false changed_when: false
when: nextcloud_config.stat.exists
# Maintenance mode is ON for the whole of `occ upgrade`, which the entrypoint - name: Report the instance status
# 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: ansible.builtin.debug:
msg: "{{ nextcloud_ready.stdout }}" msg: "{{ nextcloud_status.stdout_lines | default(['(not installed yet)']) }}"
# 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.
- 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);'
register: nextcloud_http
until: nextcloud_http.rc == 0
retries: 24
delay: 5
changed_when: false
# The database and the cache arrive over the networks that roles/postgres and # 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, # roles/valkey attached them to. Check the names the app actually asks for, plus
# plus the container names, because a wrong alias is the failure that looks like # the container names, because a wrong alias is the failure that looks like a
# a broken app rather than a broken network. # broken app rather than a broken network.
- name: Confirm Nextcloud can resolve the database and the cache - name: Confirm Nextcloud can resolve the database and the cache
ansible.builtin.command: >- ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ item }} podman exec {{ container_name }} getent hosts {{ item }}
@@ -323,11 +293,10 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Background jobs # Background jobs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The Docker-era playbook added a *root* crontab entry running `php cron.php` # Nextcloud expects cron.php every five minutes. It cannot be a crontab entry:
# every five minutes. Root cannot `podman exec` into another user's rootless # root cannot `podman exec` into another user's rootless containers, and there is
# containers (docs/podman-migration.md §8(b)), so it becomes a systemd user timer # no container user to own a crontab. A systemd user timer next to the Quadlet
# next to the Quadlet units — which is also what makes it survive a reboot and # units is the equivalent, and it is what comes back after a reboot.
# report its failures in the journal.
- name: Ensure the systemd user unit directory exists - name: Ensure the systemd user unit directory exists
ansible.builtin.file: ansible.builtin.file:
@@ -1,14 +1,11 @@
# Nextcloud background jobs — systemd user service. # Nextcloud background jobs — systemd user service.
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. # Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
# #
# This replaces the Docker-era root crontab entry: # 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
# */5 * * * * docker exec -u www-data nextcloud php cron.php # 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
# Root cannot `podman exec` into another user's rootless containers, and a # the stack.
# 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).
# #
# No XDG_RUNTIME_DIR juggling is needed: systemd's *user* manager sets it for # 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 # every unit it starts, which is also why the whole playbook runs as the
@@ -1,8 +1,8 @@
# Nextcloud background jobs — systemd user timer. # Nextcloud background jobs — systemd user timer.
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. # Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
# #
# Fires nextcloud-cron.service every five minutes, which is what Nextcloud's own # Fires nextcloud-cron.service every five minutes, which is the interval
# installation guide asks for and what the Docker-era root crontab did. # Nextcloud's own installation guide asks for.
[Unit] [Unit]
Description=Run Nextcloud background jobs every five minutes Description=Run Nextcloud background jobs every five minutes
@@ -18,39 +18,35 @@ ContainerName={{ container_name }}
Image={{ image }} Image={{ image }}
# Map the container's www-data account (uid {{ app_uid }}) onto {{ podman_user }} # 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 — # on the host, so the webroot stays owned by {{ podman_user }} and no subuid-owned
# but it rsyncs the code in with `--chown www-data:www-data` and Apache drops to # files appear to break backups. The entrypoint rsyncs the code in with
# www-data, so everything it writes lands on {{ podman_user }} and no subuid-owned # `--chown www-data:www-data` and Apache runs its workers as that account.
# 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.
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }} UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
# POSTGRES_* and REDIS_HOST live here rather than as Environment= lines above: # 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 # 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. # 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 # POSTGRES_* are what the entrypoint installs with on a fresh webroot;
# generated. REDIS_HOST is still load-bearing on every start, because the # REDIS_HOST is load-bearing on every start, because the entrypoint rewrites
# entrypoint rewrites PHP's session-handler config from it. # PHP's session-handler config from it.
EnvironmentFile={{ env_file }} EnvironmentFile={{ env_file }}
Network={{ network_name }}.network Network={{ network_name }}.network
# The image's VOLUME and Apache document root. This is the whole installation: # The image's VOLUME and Apache document root. This is the whole installation: the
# the code (version.php, core/, lib/, apps/), config/config.php, data/ and # code (version.php, core/, lib/, apps/), config/config.php, data/ and
# custom_apps/. The Docker-era playbook bind-mounted exactly this, and the # custom_apps/. The entrypoint's own version check reads version.php from here, so
# entrypoint's version check reads version.php from here — which is why the # an image bump rsyncs new code into this directory and upgrades the database in
# migration copies the tree rather than only the state. See readme.md. # place — see readme.md, "First run and updates".
Volume={{ host_directory }}:{{ webroot }} Volume={{ host_directory }}:{{ webroot }}
# PHP's session-handler config. The entrypoint writes this file itself on every # 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 # 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 # 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 # runs as has no write bit on the directory. Mounting a host-owned file over the
# makes that write land somewhere allowed — and because the mounted file replaces # path is what makes that write land somewhere allowed — and because the mounted
# the directory entry, the directory's ownership never comes into it. # 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 # 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 # 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] [Service]
Restart=always Restart=always
# The entrypoint rsyncs the image's code into the webroot and, if the copied # A first start initialises the webroot; a start after an image bump rsyncs the
# version.php is older than the image, runs `occ upgrade` before Apache answers. # new code in and runs `occ upgrade` before Apache answers. Both can take minutes
# On a 320 MB database that is minutes, not seconds. # on a large instance.
TimeoutStartSec=600 TimeoutStartSec=600
[Install] [Install]
@@ -5,15 +5,23 @@
# and POSTGRES_PASSWORD is in it. Note that `podman inspect` still prints the # and POSTGRES_PASSWORD is in it. Note that `podman inspect` still prints the
# resulting environment to anyone who can run podman as this user. # resulting environment to anyone who can run podman as this user.
# #
# config.php is copied from monoco, not generated, so the POSTGRES_* values are # POSTGRES_* are what the entrypoint uses to write config.php when it installs a
# not an install step any more — they only have to agree with what config.php # fresh instance; on an instance that is already installed they only have to agree
# already carries, which is exactly what the Docker-era container passed. # with what config.php already carries, and nothing rewrites it.
# #
# REDIS_HOST is different: the entrypoint reads it on *every* start and rewrites # REDIS_HOST is live on *every* start: the entrypoint regenerates PHP's session
# PHP's session handler from it (/usr/local/etc/php/conf.d/redis-session.ini), so # handler from it ({{ php_session_ini_mount }}), so a wrong value here shows up as
# a wrong value here shows up as failed logins rather than as a missing cache. # failed logins rather than as a missing cache.
POSTGRES_DB={{ pg_database }} POSTGRES_DB={{ pg_database }}
POSTGRES_USER={{ pg_user }} POSTGRES_USER={{ pg_user }}
POSTGRES_PASSWORD={{ pg_password }} POSTGRES_PASSWORD={{ pg_password }}
POSTGRES_HOST={{ db_host }} POSTGRES_HOST={{ db_host }}
REDIS_HOST={{ redis_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 %}
+36
View File
@@ -26,3 +26,39 @@
# runs nothing is pending and this is a no-op. # runs nothing is pending and this is a no-op.
- name: Apply the Postgres network change before starting Nextcloud - name: Apply the Postgres network change before starting Nextcloud
ansible.builtin.meta: flush_handlers 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
+4 -4
View File
@@ -8,10 +8,10 @@
# asserts that nextcloud.network already exists, and notifies the "Restart # asserts that nextcloud.network already exists, and notifies the "Restart
# Valkey" handler defined in this role. # Valkey" handler defined in this role.
# #
# `redis` is the alias config.php connects to, not the container name: the image # `redis` is the alias the app connects to, not the container name: REDIS_HOST is
# writes the host from REDIS_HOST, which the Docker-era `env:` block set to # what the image writes the PHP session handler from, and what this play puts in
# `redis`, and the same variable configures PHP's session handler on every start. # config.php's Redis configuration on an unattended install. Renaming it would
# Renaming it here would mean editing config.php on a live instance for nothing. # mean editing a live instance's configuration for nothing.
- name: Attach Nextcloud to Valkey - name: Attach Nextcloud to Valkey
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml" ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
+45 -29
View File
@@ -19,6 +19,8 @@ quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nextcloud # 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 host: cloud.hopsngrains.com
container_name: nextcloud container_name: nextcloud
@@ -26,7 +28,7 @@ unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against # Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when the # 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 image: docker.io/library/nextcloud:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the # The Quadlet file is <network_name>.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 # 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/, # 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 # custom_apps/ and every app's PHP file live under it. See readme.md, "What is
# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that # state, and what is not".
# shape on purpose. See readme.md, "What is state, and what is not".
host_directory: "{{ podman_data_dir }}/{{ container_name }}" host_directory: "{{ podman_data_dir }}/{{ container_name }}"
webroot: /var/www/html webroot: /var/www/html
# The uid the *server* writes as: 33 is www-data, which owns the webroot, because # 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 # 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 # its workers as that account. `docker exec nextcloud id -u` reports 0 in the
# host, because the entrypoint drops privileges after starting — do not derive the # upstream image, because the entrypoint drops privileges after starting — do not
# mapping from that number (docs/podman-compat-audit.md §3.2). # derive the mapping from that number (docs/podman-compat-audit.md §3.2).
app_uid: 33 app_uid: 33
app_gid: 33 app_gid: 33
# The port Apache listens on inside the container, and the one Caddy proxies to. # The port Apache listens on inside the container, and the one Caddy proxies to.
container_port: 80 container_port: 80
# PHP's session-handler config, and the one path in the image this deployment has # PHP's session-handler config, and the one path in the image the entrypoint has
# to write to. The entrypoint generates the file from REDIS_HOST on every start, # to write to. It generates the file from REDIS_HOST on every start, but its
# but its target directory (`/usr/local/etc/php/conf.d`) is created by root in the # target directory (`/usr/local/etc/php/conf.d`) is created by root in the php
# php base image with no chown, so the uid this container runs as cannot create a # base image with no chown, so the uid this container runs as cannot create a file
# file inside it. Mounting a host-owned file over that exact path is what gives # inside it. Mounting a host-owned file over that exact path is what gives the
# the write somewhere it is allowed to land, and the template below keeps the # write somewhere it is allowed to land, and the template keeps the image's own
# image's own content in it so a refused write is not fatal either. See readme.md, # content in it so a refused write is not fatal either. See readme.md, "The PHP
# "The PHP session file". # session file".
php_conf_dir: "{{ host_directory }}/php-conf.d" php_conf_dir: "{{ host_directory }}/php-conf.d"
php_session_ini: "{{ php_conf_dir }}/redis-session.ini" php_session_ini: "{{ php_conf_dir }}/redis-session.ini"
php_session_ini_mount: /usr/local/etc/php/conf.d/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_container: postgres
postgres_unit: "{{ postgres_container }}.service" postgres_unit: "{{ postgres_container }}.service"
# The credentials and database name the Docker-era playbook embedded in its # The database this instance uses, and the credentials for it. They have to match
# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB, # whatever the shared Postgres unit was initialised with (postgres/vars/default.yml)
# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree. # — 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_user: postgres
pg_password: '123456' pg_password: '123456'
pg_database: nextcloud pg_database: nextcloud
# `db` is the host config.php actually connects to — the Docker-era container set # `db` is the alias postgres/roles/attach adds to the shared Postgres unit, and
# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared # the host this instance's config.php connects to.
# Postgres unit.
db_host: db db_host: db
# The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and # The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and
@@ -100,21 +101,36 @@ redis_host: redis
caddy_name: caddy caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" 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 # Background jobs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in # Nextcloud wants `cron.php` every five minutes. It cannot be a crontab entry:
# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root # root cannot `podman exec` into another user's rootless containers, and there is
# cannot `podman exec` into another user's rootless containers, so it becomes a # no container user to own a crontab. A systemd user timer next to the Quadlet
# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)). # units is the equivalent.
cron_unit: "{{ container_name }}-cron" cron_unit: "{{ container_name }}-cron"
cron_schedule: "*:0/5" cron_schedule: "*:0/5"
user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user" user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user"
# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and # The site block. Caddy terminates TLS, and Nextcloud asks for HSTS. There is no
# Nextcloud has always asked for HSTS. There is no request-body limit to carry # request-body limit to carry over: Caddy streams bodies and imposes none by
# over — Caddy streams bodies and imposes none by default, whereas the dead nginx # default.
# front end needed client_max_body_size spelled out.
snippet_block: | snippet_block: |
{{ host }} { {{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }} reverse_proxy {{ container_name }}:{{ container_port }}