Run Nextcloud as a rootless Podman Quadlet
Replaces the docker_container playbook, which bind-mounted /var/lib/nextcloud at /var/www/html on monoco and ran cron.php from root's crontab. Nextcloud now runs on emily behind the shared Caddy, with its database in the shared Postgres and its cache in the shared Valkey, each attached the way every other service attaches to them. /var/www/html is the unusual part: it holds the code as well as the state -- version.php, core/, lib/ and apps/ alongside config/config.php, data/ and custom_apps/ -- and the entrypoint's version check reads version.php from the mount. That is why the migration copies the whole tree rather than only the three irreplaceable directories (docs/backup-plan.md 2.1 lists the rest as regenerable, and they are, but only from an image that is the same version or newer). It is also what makes an image bump an in-place upgrade: a newer image rsyncs its code in and runs `occ upgrade` on first start, and an older one refuses to start at all, so the version on both hosts is worth checking before the copy. UserNS=keep-id:uid=33,gid=33. 33 is www-data, the uid the server actually writes as -- `docker exec nextcloud id -u` reports 0, because the entrypoint drops privileges after starting (docs/podman-compat-audit.md 3.2). 33 is also what the data is owned by on monoco, so the copy needs no chown pass: extracting as the connection user is already the ownership the container expects. One file is created by container-root and never chowned, the entrypoint's nextcloud-init-sync.lock, so the role pre-creates it -- the entrypoint truncates that file rather than recreating it, so its owner then never changes, and the webroot ends up with no subuid-owned file in it. The Valkey attach is not optional. config.php carries memcache.locking pointed at Redis, written by the image from REDIS_HOST, and the same variable rewrites PHP's session-handler config on every start -- so an unresolvable `redis` does not just make things slower, it fails uploads at write time. Hence the `redis` alias rather than the container name, and hence roles/valkey flushing handlers before the app starts, the same as roles/postgres. cron.php moves from root's crontab to a systemd user timer, because root cannot `podman exec` into another user's rootless containers and there is no container user to own a crontab (docs/podman-migration.md 8b). Unlike the Quadlet units this pair is plain and is genuinely enabled, which is what brings it back after a reboot; journalctl --user -u nextcloud-cron shows every run, which the crontab never did. Readiness is three gates, because no one of them is sufficient: is-active (which Restart=always satisfies even while crash-looping), `occ status` reporting installed, and status.php served over loopback by Apache rather than by the PHP CLI. The second is followed by a wait for maintenance mode to clear rather than an assert: maintenance mode is on for the whole of a first-start `occ upgrade`, so asserting it off would fail a healthy deploy halfway through. The case that never clears is a config.php copied with `maintenance => true`, which docs/backup-plan.md 3.4 tells an operator to set before a consistent dump, and which is invisible from outside because status.php is exempt from maintenance mode -- so the play names it and prints the command that clears it. Register and attach ordering is the same as the other service playbooks: register runs in pre_tasks, because the unit references nextcloud.network and Quadlet fails unit generation when that file does not exist, and both attaches flush handlers before the app starts, which is also what pulls Caddy onto the network. The Caddy snippet is unchanged, HSTS header included; only caddyfile_path moves, from /var/lib/caddy/conf/Caddyfile to ~/podman-data/caddy/conf/Caddyfile. The Docker-era roles/network is deleted rather than ported: it existed to attach an already-created container to a network with the `redis` alias, and podman_network has no connected:/appends: equivalent (docs/podman-compat-audit.md 3.3). Nothing is published host-side; Caddy reaches nextcloud:80 over nextcloud_net. The rootless-Podman prerequisites are not applied here: they are the shared task file at caddy/roles/podman, and a host that has run caddy/playbook.yml has them. valkey/playbook.yml has to have been run first, or the first Restart Valkey handler fails against a unit that does not exist yet.
This commit is contained in:
1 parent
3587bc9001
commit
43ea43e88b
16 files changed
+1307
-88
No files matched your search
+53
-8
@@ -1,13 +1,58 @@
|
||||
#################################################
|
||||
# DO Community Playbooks: Nextcloud
|
||||
#################################################
|
||||
---
|
||||
- hosts: monoco
|
||||
become: true
|
||||
# Nextcloud as a rootless Podman Quadlet unit, behind the shared Caddy unit
|
||||
# (caddy/playbook.yml), with its database in the shared Postgres unit
|
||||
# (postgres/playbook.yml) and its cache in the shared Valkey unit
|
||||
# (valkey/playbook.yml).
|
||||
#
|
||||
# Connects as the container user and runs everything as that user: the Quadlet
|
||||
# units belong to it, so the connection's PAM session is what makes
|
||||
# `systemctl --user` work without any environment juggling.
|
||||
#
|
||||
# gather_facts is required: podman_user and the home directory come from the
|
||||
# user_id / user_dir facts.
|
||||
#
|
||||
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
|
||||
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
|
||||
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
|
||||
# a host that has already run caddy/playbook.yml has them:
|
||||
#
|
||||
# - name: Rootless Podman prerequisites
|
||||
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
|
||||
#
|
||||
# The variables this play needs are duplicated at the top of vars/default.yml, so
|
||||
# it stands on its own.
|
||||
#
|
||||
# Run with: ansible-playbook -u tanshu nextcloud/playbook.yml
|
||||
- name: Deploy Nextcloud as a rootless Podman Quadlet
|
||||
hosts: emily
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- vars/default.yml
|
||||
|
||||
pre_tasks:
|
||||
# This must happen before the app unit is started, not after: the generated
|
||||
# unit references {{ network_name }}.network, and Quadlet fails *unit
|
||||
# generation* (so `systemctl --user start` reports "unit not found") when a
|
||||
# referenced .network file does not exist yet. The same include writes the
|
||||
# Caddy-side drop-in that attaches Caddy to this network, and both attaches
|
||||
# below need the network unit to exist as well.
|
||||
#
|
||||
# network_name is a play var, so the include can see it directly.
|
||||
- name: Register Nextcloud's network with Caddy
|
||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||
|
||||
roles:
|
||||
- network
|
||||
- nextcloud
|
||||
- caddy
|
||||
# Attaches this app's network to the shared Postgres unit, which is what
|
||||
# makes config.php's `db` host resolve, and owns the "Restart Postgres"
|
||||
# handler that postgres/roles/attach notifies. Runs first so the database is
|
||||
# reachable before the app starts.
|
||||
- postgres
|
||||
# The same at the cache end: attaches this app's network to the shared Valkey
|
||||
# unit, which is what makes config.php's `redis` host resolve, and owns the
|
||||
# "Restart Valkey" handler that valkey/roles/attach notifies.
|
||||
- valkey
|
||||
# The app: the webroot, the 0600 environment file, the Quadlet unit, the
|
||||
# background-job timer, start + verify.
|
||||
- nextcloud
|
||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
||||
- caddy
|
||||
+603
-2
@@ -1,2 +1,603 @@
|
||||
The web/nginx.conf is from
|
||||
https://github.com/nextcloud/docker/blob/master/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf
|
||||
# Nextcloud (rootless Podman + Quadlet)
|
||||
|
||||
Runs Nextcloud on `emily` as a **rootless Podman** container managed by a
|
||||
**systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md),
|
||||
with its database in the shared [rootless Postgres](../postgres/readme.md) and its
|
||||
cache in the shared [rootless Valkey](../valkey/readme.md). Replaces the
|
||||
`docker_container` deployment on `monoco`.
|
||||
|
||||
The container name, network, unit, Caddyfile upstream and database are all
|
||||
`nextcloud`, as they have been since the Docker-era playbook. The **host is
|
||||
unchanged**: `cloud.hopsngrains.com`.
|
||||
|
||||
Nextcloud is the one service here whose webroot is *also* its code directory.
|
||||
The Docker-era playbook bind-mounted `/var/lib/nextcloud` at `/var/www/html`, and
|
||||
this one keeps that shape: the image's `VOLUME`, `config/config.php`, `data/`,
|
||||
`custom_apps/` and every PHP file live under one mount. That single fact drives
|
||||
both the [migration](#migrating-the-data-from-monoco) and the ownership story
|
||||
below — see [What is state, and what is not](#what-is-state-and-what-is-not).
|
||||
|
||||
## Ordering
|
||||
|
||||
The three shared services are already migrated and running on `emily`, and this
|
||||
playbook is the last step:
|
||||
|
||||
1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns
|
||||
the Caddyfile this site appends to, supplies the `caddy.service` handlers, and
|
||||
is the one play that applies the rootless-Podman prerequisites.
|
||||
2. [postgres/playbook.yml](../postgres/playbook.yml) — 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
|
||||
attached `nextcloud_net` to Postgres and to Valkey, neither name resolves on this
|
||||
network, so running this playbook is what makes the app reachable — there is no
|
||||
separate step.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
nextcloud/
|
||||
├── playbook.yml
|
||||
├── vars/default.yml
|
||||
└── roles/
|
||||
├── postgres/ # attaches this app's network to Postgres (+ its handler)
|
||||
├── valkey/ # attaches this app's network to Valkey (+ its handler)
|
||||
├── nextcloud/ # webroot, the 0600 env file, the Quadlet unit, the cron
|
||||
│ # timer, start + verify
|
||||
└── caddy/ # appends this site's block to the shared Caddyfile
|
||||
```
|
||||
|
||||
Quadlet units and state:
|
||||
|
||||
| On the host | What |
|
||||
|---|---|
|
||||
| `~/.config/containers/systemd/nextcloud.container` | the generated unit (Ansible-templated) |
|
||||
| `~/.config/containers/systemd/nextcloud.network` | the app network, written by `caddy/roles/register` |
|
||||
| `~/.config/containers/systemd/caddy.container.d/50-nextcloud.conf` | attaches Caddy to that network |
|
||||
| `~/.config/containers/systemd/postgres.container.d/50-nextcloud.conf` | attaches Postgres to it |
|
||||
| `~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf` | the shared `db` alias |
|
||||
| `~/.config/containers/systemd/valkey.container.d/50-nextcloud.conf` | attaches Valkey to it |
|
||||
| `~/.config/containers/systemd/valkey.container.d/10-network-aliases.conf` | the shared `redis` alias |
|
||||
| `~/.config/systemd/user/nextcloud-cron.{service,timer}` | the background-job timer (not Quadlet) |
|
||||
| `~/podman-data/nextcloud/` | the whole `/var/www/html`: code, `config/config.php`, `data/`, `custom_apps/` |
|
||||
| `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600`, read by systemd |
|
||||
|
||||
## What is state, and what is not
|
||||
|
||||
Per [docs/backup-plan.md §2.1](../docs/backup-plan.md), the instance is 1.2 GB
|
||||
and only a fraction of it is irreplaceable:
|
||||
|
||||
| Path | Keep? | Why |
|
||||
|---|---|---|
|
||||
| `data/` | **required** | user files, `appdata_*`, versions, thumbnails, the log |
|
||||
| `config/` | **required** | `config.php` — without it the instance is not restorable, and neither is the encryption material |
|
||||
| `custom_apps/` | **required** | apps installed from the app store |
|
||||
| `themes/` | if themed | only exists when a theme was installed |
|
||||
| `version.php`, `core/`, `lib/`, `apps/`, `3rdparty/`, `dist/` | regenerable | shipped by the image, rsynced into the webroot on start |
|
||||
|
||||
**The migration copies all of it anyway**, and that is deliberate. Because the
|
||||
mount holds the code, the entrypoint decides what to do by reading
|
||||
`/var/www/html/version.php` **from the mount**: copy the whole tree and the first
|
||||
start is close to a no-op; copy only the state and `version.php` is missing, so
|
||||
every start looks like a brand-new instance — no `occ upgrade`, no version
|
||||
tracking — and Nextcloud would then have to be talked through a manual upgrade.
|
||||
|
||||
## How it registers with Caddy
|
||||
|
||||
Caddy is the base unit and knows nothing about its upstreams. This playbook calls
|
||||
the shared include in
|
||||
[caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the
|
||||
network unit **and** the drop-in that pulls it into Caddy:
|
||||
|
||||
```yaml
|
||||
- name: Register Nextcloud's network with Caddy
|
||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||
```
|
||||
|
||||
It runs in `pre_tasks`, **before** the app unit is started, because
|
||||
`nextcloud.container` references `nextcloud.network`: Quadlet fails *unit
|
||||
generation* — so `systemctl --user start` reports "unit not found" — when a
|
||||
referenced `.network` file does not exist yet. Both attaches below depend on it
|
||||
for the same reason.
|
||||
|
||||
`network_name` is `nextcloud`, so the *file* is `nextcloud.network` and the
|
||||
*Podman network* is `nextcloud_net` (`NetworkName=` in the network unit pins it,
|
||||
keeping the Docker-era name).
|
||||
|
||||
The Docker-era `roles/network` is **deleted rather than ported**. It existed to
|
||||
attach an already-created container to a network — `docker_network`'s
|
||||
`connected:`/`appends:`, with the `redis` alias for the old `valkey` container —
|
||||
which `podman_network` has no equivalent for
|
||||
([docs/podman-compat-audit.md §3.3](../docs/podman-compat-audit.md)). Nothing has
|
||||
to be connected after the fact now: the app's own unit names its `Network=`, and
|
||||
Caddy, Postgres and Valkey learn about the network from drop-ins written in the
|
||||
same run.
|
||||
|
||||
The site block is appended to the shared Caddyfile by
|
||||
[roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the
|
||||
other services do. **Only `caddyfile_path` changed** — from
|
||||
`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile` — and the
|
||||
block itself is the Docker-era one, HSTS header included:
|
||||
|
||||
```
|
||||
cloud.hopsngrains.com {
|
||||
reverse_proxy nextcloud:80
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=15552000; includeSubDomains; preload"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `marker` in the role is unchanged from the Docker era, so an existing block
|
||||
on a reused Caddyfile is replaced in place rather than duplicated. There is no
|
||||
request-body limit to carry over: Caddy streams request bodies and imposes none
|
||||
by default, so the `client_max_body_size 525M` the dead nginx front end carried
|
||||
has no equivalent and needs none.
|
||||
|
||||
## How it attaches to Postgres
|
||||
|
||||
Postgres knows nothing about its clients, and this playbook uses the shared
|
||||
include in [postgres/roles/attach](../postgres/roles/attach/tasks/main.yaml)
|
||||
rather than writing the drop-ins itself. That writes two files, both mergeable
|
||||
into `postgres.container` by Quadlet:
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `postgres.container.d/50-nextcloud.conf` | `Network=nextcloud.network` |
|
||||
| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` |
|
||||
|
||||
`config.php`'s `dbhost` is `db`, which is what the Docker-era container's
|
||||
`POSTGRES_HOST=db` wrote; the `postgres` container name resolves too, and the play
|
||||
checks both.
|
||||
|
||||
[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for two
|
||||
reasons:
|
||||
|
||||
- it defines the `Restart Postgres` handler the include notifies; and
|
||||
- it **flushes handlers** immediately afterwards. On a first attach the drop-in
|
||||
is new, so Postgres has to be *recreated* to join `nextcloud_net` — and that
|
||||
restart is a handler, which by default runs at the end of the play, after the
|
||||
`nextcloud` role has already started the app. The app would then come up before
|
||||
`db` resolves, fail its own readiness check, and fail the play on a machine
|
||||
where nothing is wrong. The flush also fires the `Restart Caddy` queued by the
|
||||
register pre_task, which is what attaches Caddy to the network. On later runs
|
||||
nothing is pending and it is a no-op.
|
||||
|
||||
Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new
|
||||
drop-in recreates Postgres, which is a few seconds of downtime for **every**
|
||||
attached app, not just this one.
|
||||
|
||||
## How it attaches to Valkey
|
||||
|
||||
The same idea at the cache end, via
|
||||
[valkey/roles/attach](../valkey/roles/attach/tasks/main.yaml):
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `valkey.container.d/50-nextcloud.conf` | `Network=nextcloud.network` |
|
||||
| `valkey.container.d/10-network-aliases.conf` | `NetworkAlias=redis` |
|
||||
|
||||
`redis` is not a name this playbook chose. The image writes `config.php`'s Redis
|
||||
host from `REDIS_HOST`, the Docker-era `env:` block set it to `redis`, and the
|
||||
same variable rewrites PHP's session-handler config on **every** start. Renaming
|
||||
it would mean editing `config.php` on a live instance for nothing.
|
||||
|
||||
It is also **not optional**. `config.php` carries `memcache.locking` pointed at
|
||||
Redis, so an unresolvable `redis` does not merely make things slower — uploads
|
||||
fail, and they fail at write time rather than at startup. That is why
|
||||
`roles/valkey` flushes handlers before the app starts (same reason as Postgres),
|
||||
and why `REDIS_HOST` is in the environment file at all.
|
||||
|
||||
The cost of the attach is a recreate of Valkey, and it is **not** the same story
|
||||
as Postgres's. No keys are lost — RDB persistence is on by default, the stop is
|
||||
graceful, and the new container loads the snapshot back off the bind mount — but
|
||||
for the seconds it is down, Nextcloud's PHP sessions and its `memcache.locking`
|
||||
are unreachable, so logins and uploads stall rather than fail cleanly. See
|
||||
[valkey/readme.md](../valkey/readme.md), "The trade-off".
|
||||
|
||||
## Background jobs
|
||||
|
||||
Nextcloud expects `cron.php` to run every five minutes, and the Docker-era
|
||||
playbook scheduled it in **root's crontab**:
|
||||
|
||||
```
|
||||
*/5 * * * * docker exec -u www-data nextcloud php cron.php
|
||||
```
|
||||
|
||||
That cannot survive the move: root cannot `podman exec` into another user's
|
||||
rootless containers, and there is no container user to own a crontab
|
||||
([docs/podman-migration.md §8(b)](../docs/podman-migration.md)). The role
|
||||
installs a **systemd user** pair next to the Quadlet units instead:
|
||||
|
||||
| Unit | What it does |
|
||||
|---|---|
|
||||
| `~/.config/systemd/user/nextcloud-cron.service` | `Type=oneshot`, `podman exec -u www-data nextcloud php /var/www/html/cron.php` |
|
||||
| `~/.config/systemd/user/nextcloud-cron.timer` | `OnCalendar=*:0/5`, `Persistent=true` |
|
||||
|
||||
Unlike the Quadlet units these are plain units, so they really are `enable`d —
|
||||
that is what brings the timer back after a reboot. No `sudo`, no crontab, and
|
||||
`journalctl --user -u nextcloud-cron` shows every run and every failure, which the
|
||||
old crontab never did.
|
||||
|
||||
The background-job *mode* is left alone: `config.php` came from monoco with
|
||||
whatever it had, and this playbook does not switch it to `cron`. Both the timer
|
||||
and AJAX polling run jobs; job locking is what keeps them from colliding. If you
|
||||
want AJAX disabled, that is `occ background:cron`, a deliberate change rather
|
||||
than a migration step.
|
||||
|
||||
## Settings
|
||||
|
||||
| Variable | Value / purpose |
|
||||
|---|---|
|
||||
| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand |
|
||||
| `podman_data_dir` | `~/podman-data` |
|
||||
| `quadlet_dir` | `~/.config/containers/systemd` |
|
||||
| `host` | `cloud.hopsngrains.com` — the site block Caddy serves |
|
||||
| `container_name` | `nextcloud`, and the Quadlet unit / container name |
|
||||
| `image` | `docker.io/library/nextcloud:latest` — fully qualified on purpose |
|
||||
| `network_name` | `nextcloud` → `nextcloud.network` / `nextcloud_net` |
|
||||
| `host_directory` / `webroot` | `~/podman-data/nextcloud` → `/var/www/html` |
|
||||
| `app_uid` / `app_gid` | `33` — `www-data`, the uid the server writes as |
|
||||
| `container_port` | `80`, the port inside the container that Caddy proxies to |
|
||||
| `env_file` | `~/podman-data/nextcloud/nextcloud.env`, mode `0600` |
|
||||
| `postgres_container` / `postgres_unit` | The shared database unit this app attaches to |
|
||||
| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database name — `nextcloud` |
|
||||
| `db_host` | `db` — the alias `config.php` connects to |
|
||||
| `valkey_container` / `valkey_unit` | The shared cache unit this app attaches to |
|
||||
| `redis_host` | `redis` — the alias `config.php` and PHP sessions connect to |
|
||||
| `cron_unit` / `cron_schedule` / `user_unit_dir` | The background-job timer |
|
||||
| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile |
|
||||
|
||||
The three `podman_*` / `quadlet_dir` vars are **duplicated** from
|
||||
[caddy/vars/default.yml](../caddy/vars/default.yml), as in every service
|
||||
playbook. That is a deliberate trade: a play cannot include the Caddy vars file
|
||||
wholesale, because it also sets `host_directory` (to the *Caddy* data directory)
|
||||
and every service defines that for itself. Keep them in sync. Caddy's fourth,
|
||||
`podman_unprivileged_port_start`, belongs to the podman tasks this play does not
|
||||
run — Nextcloud publishes nothing host-side.
|
||||
|
||||
Like Caddy, `podman_user` is derived from the connection and the play carries no
|
||||
`become`. Because the podman tasks are not run here, there is **no guard against
|
||||
connecting as `root`**; the prerequisites, including that check, live in
|
||||
`caddy/playbook.yml`.
|
||||
|
||||
`UserNS=keep-id:uid=33,gid=33` — `33` is the uid the server writes as. The
|
||||
entrypoint starts as container-root, but it rsyncs the code in with
|
||||
`--chown www-data:www-data` and Apache then drops to `www-data`, so everything it
|
||||
writes lands on `podman_user` and no subuid-owned files appear to break backups
|
||||
([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). `33` is
|
||||
also what the data is owned by on monoco, which is what makes the copy below need
|
||||
no `chown` pass.
|
||||
|
||||
One file is created by container-root and never chowned: the entrypoint takes an
|
||||
`flock` on `/var/www/html/nextcloud-init-sync.lock` using a root-owned shell
|
||||
redirect. The role **pre-creates it** as `podman_user`, because the redirect
|
||||
truncates the file rather than recreating it — so its owner never changes. Left
|
||||
to the container it would be the one file in the webroot owned by a `165536+`
|
||||
subuid and not removable by the host user.
|
||||
|
||||
`POSTGRES_PASSWORD` is the fleet's shared `123456`, which matches
|
||||
`pg_password` in [postgres/vars/default.yml](../postgres/vars/default.yml) and
|
||||
what `config.php` already carries — a migrated app connects unchanged. It lives
|
||||
in `nextcloud.env` at mode `0600` rather than in the world-readable unit file.
|
||||
Both are committed in plaintext as before; flagged as a follow-up, not changed
|
||||
here.
|
||||
|
||||
## Running
|
||||
|
||||
```command
|
||||
ansible-playbook -u tanshu nextcloud/playbook.yml
|
||||
```
|
||||
|
||||
The play targets `emily` directly, so the inventory must contain it — the one in
|
||||
`prepare-server/` does, and its `ansible.cfg` names it.
|
||||
|
||||
This play applies **only** this app's units and data. The rootless-Podman
|
||||
prerequisites — installing podman, `loginctl enable-linger`, the low-port sysctl
|
||||
and the Quadlet directory — are the shared task file at
|
||||
[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and
|
||||
are **not** run here. A host that has already run `caddy/playbook.yml` has them.
|
||||
|
||||
`systemctl --user enable nextcloud` is neither needed nor possible: the unit is
|
||||
*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. The
|
||||
cron **timer** is a plain unit and is enabled explicitly, as described above.
|
||||
|
||||
## Migrating the data from monoco
|
||||
|
||||
The database and the files have to correspond, so this is a freeze, not a
|
||||
best-effort copy. Run it from the machine that holds the `monoco` and `emily` SSH
|
||||
aliases, as in [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md).
|
||||
|
||||
Two things are already done: the `nextcloud` database is restored on `emily`, and
|
||||
the Caddyfile and certificates live there too. What is left is
|
||||
`/var/lib/nextcloud`.
|
||||
|
||||
### 0. Look first
|
||||
|
||||
```bash
|
||||
# What is there, how big, and whether it is readable without sudo. Nextcloud's
|
||||
# data/ is normally 0770 www-data:www-data, so this says "no" — the sudo form
|
||||
# below is the expected one, not the exception.
|
||||
ssh monoco 'du -sh /var/lib/nextcloud; ls -ln /var/lib/nextcloud'
|
||||
ssh monoco 'find /var/lib/nextcloud -type f ! -readable | head' # empty = no sudo needed
|
||||
|
||||
# The version on monoco versus the image emily will run. The entrypoint compares
|
||||
# the two on first start: a NEWER image rsyncs its code in and runs `occ upgrade`
|
||||
# (one major version at a time, so a large gap needs stepping stones), while an
|
||||
# OLDER image refuses to start at all — "downgrading is not supported".
|
||||
ssh monoco 'docker exec nextcloud php -r "require \"/var/www/html/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"'
|
||||
ssh emily 'podman run --rm --entrypoint php docker.io/library/nextcloud:latest \
|
||||
-r "require \"/usr/src/nextcloud/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"'
|
||||
```
|
||||
|
||||
### 1. Freeze monoco
|
||||
|
||||
```bash
|
||||
# Stop the app first: a stopped container cannot write to data/ while it is being
|
||||
# copied, and no maintenance-mode dance is needed. Leave it stopped — its database
|
||||
# is on emily, and two live copies would diverge.
|
||||
ssh monoco 'docker stop nextcloud'
|
||||
```
|
||||
|
||||
### 2. Make the database correspond (only if it moved since the cluster move)
|
||||
|
||||
The cluster was dumped on **2026-10-15**. If monoco's Nextcloud has served writes
|
||||
since then, the files you are about to copy are newer than the database on
|
||||
`emily`, and restoring the files alone would leave the instance inconsistent.
|
||||
Re-dump the one database and reload it — it is 320 MB, so this is cheap.
|
||||
|
||||
If the app is already running on `emily` from an earlier attempt, stop it first:
|
||||
`DROP DATABASE` needs the connections gone, and a running Nextcloud holds several.
|
||||
|
||||
```bash
|
||||
ssh emily 'systemctl --user stop nextcloud.service' # only if it is already deployed
|
||||
|
||||
```bash
|
||||
# Dump on monoco (Postgres is a separate container and is still running).
|
||||
ssh monoco 'mkdir -p ~/pg-migrate && cd ~/pg-migrate
|
||||
docker exec -e PGPASSWORD=123456 postgres pg_dump -U postgres -Fc nextcloud \
|
||||
| zstd -T0 -6 -o nextcloud-$(date +%Y%m%d-%H%M).dump.zst
|
||||
ls -lh nextcloud-*.dump.zst'
|
||||
|
||||
# Transfer, then check the checksum on both sides before trusting it.
|
||||
ssh monoco 'cat ~/pg-migrate/nextcloud-*.dump.zst' \
|
||||
| ssh emily 'cat > ~/pg-migrate/nextcloud.dump.zst'
|
||||
a=$(ssh monoco 'sha256sum ~/pg-migrate/nextcloud-*.dump.zst | cut -d" " -f1')
|
||||
b=$(ssh emily 'sha256sum ~/pg-migrate/nextcloud.dump.zst | cut -d" " -f1')
|
||||
[ -n "$a" ] && [ "$a" = "$b" ] && echo "transfer OK: $a" \
|
||||
|| { echo "MISMATCH — do not restore: $a vs $b"; false; }
|
||||
|
||||
# Reload it on emily. This DROPS the existing database — it is the one the
|
||||
# cluster move restored, and the frozen dump replaces it.
|
||||
ssh emily 'cd ~/pg-migrate
|
||||
podman exec -e PGPASSWORD=123456 postgres \
|
||||
psql -U postgres -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "DROP DATABASE IF EXISTS nextcloud" \
|
||||
-c "CREATE DATABASE nextcloud OWNER postgres"
|
||||
zstd -dc nextcloud.dump.zst | podman exec -i -e PGPASSWORD=123456 postgres \
|
||||
pg_restore -U postgres -d nextcloud --no-owner --exit-on-error
|
||||
podman exec -e PGPASSWORD=123456 postgres \
|
||||
psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"'
|
||||
```
|
||||
|
||||
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/
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
systemctl --user status nextcloud
|
||||
journalctl --user -u nextcloud -f
|
||||
podman logs nextcloud
|
||||
|
||||
# What the playbook checks for you:
|
||||
podman exec -u www-data nextcloud php occ status # installed, version, maintenance
|
||||
podman exec nextcloud getent hosts db # config.php's database host
|
||||
podman exec nextcloud getent hosts redis # config.php's cache host
|
||||
podman exec nextcloud getent hosts postgres valkey # the container names
|
||||
|
||||
# The background job, and that it is actually scheduled:
|
||||
systemctl --user list-timers nextcloud-cron.timer
|
||||
systemctl --user start nextcloud-cron.service # run one by hand
|
||||
journalctl --user -u nextcloud-cron -n 20
|
||||
|
||||
# The data really is the copied tree:
|
||||
ssh emily 'ls -ln ~/podman-data/nextcloud | head'
|
||||
ssh emily 'test -f ~/podman-data/nextcloud/config/config.php && echo "config.php present"'
|
||||
|
||||
# The database really is the migrated one — run the same query on both hosts.
|
||||
ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"'
|
||||
ssh monoco 'docker exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"'
|
||||
|
||||
# The site, end to end:
|
||||
curl -sSI https://cloud.hopsngrains.com
|
||||
```
|
||||
|
||||
Then open it **in a browser** and do two things a `curl` cannot: log in, and
|
||||
**upload a file**. Uploading is the check that matters here — it exercises the
|
||||
uid-33 mapping (a wrong `keep-id` shows up as a permissions error), the Redis
|
||||
file locking in `config.php`, and PHP sessions, which the image configures from
|
||||
`REDIS_HOST`.
|
||||
|
||||
After the upload, confirm on the host that the new file is owned by `tanshu`:
|
||||
|
||||
```bash
|
||||
ssh emily 'find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu'
|
||||
# empty = the write path lands on the right uid
|
||||
```
|
||||
|
||||
Then reboot the host and confirm both the container and the timer return
|
||||
unattended — without `loginctl enable-linger tanshu` neither will, and the failure
|
||||
is silent.
|
||||
|
||||
Finally, if `config.php` predates the proxy (it should not — monoco already served
|
||||
this site through Caddy, but check), Nextcloud needs the proxy trusted before it
|
||||
will believe `X-Forwarded-For`:
|
||||
|
||||
```bash
|
||||
podman exec -u www-data nextcloud php occ config:system:get trusted_proxies
|
||||
podman exec -u www-data nextcloud php occ config:system:get overwrite.cli.url
|
||||
```
|
||||
|
||||
An empty `trusted_proxies` means every client looks like the proxy; see
|
||||
[caddy/readme.md](../caddy/readme.md), "Client IPs", which also explains why the
|
||||
real address may not survive the rootless port forward at all.
|
||||
|
||||
## Notes and gotchas
|
||||
|
||||
- **Maintenance mode travels with `config.php`.** The backup plan recommends
|
||||
`occ maintenance:mode --on` before a consistent dump
|
||||
([docs/backup-plan.md §3.4](../docs/backup-plan.md)); if monoco was left that
|
||||
way, the copied configuration carries `maintenance => true` and the site returns
|
||||
`503` for everything except `status.php`. The play **waits for maintenance mode
|
||||
to clear** — it is on for the whole of a first-start `occ upgrade`, so it cannot
|
||||
simply assert — and if it never clears, fails with the one-line command to turn
|
||||
it off. That is deliberate: "the app is up but every page 503s" points anywhere
|
||||
but at maintenance mode.
|
||||
- **Version drift is the one irreversible step.** The copied `version.php` is what
|
||||
the entrypoint compares against the image. A newer image upgrades the instance
|
||||
on first start (and can only step one major version at a time); an older image
|
||||
makes the container exit with "downgrading is not supported". Check both before
|
||||
copying (step 0) and take a database dump before the first start.
|
||||
- **`latest` floats.** The Docker-era playbook used the unqualified
|
||||
`nextcloud:latest`; this one fully qualifies the same tag, because rootless
|
||||
Podman has no TTY to answer a short-name prompt when the unit starts. Neither
|
||||
pins a digest, so an image bump is a normal `podman pull` away — and it is
|
||||
exactly the event that triggers an in-place upgrade, because the code lives in
|
||||
the mount.
|
||||
- **The code lives in the data directory.** That is the image's design, not this
|
||||
playbook's, and it is why the whole tree is copied and why an image bump edits
|
||||
`~/podman-data/nextcloud`. It is also why the backup plan lists `apps/`, `core/`
|
||||
and `lib/` as regenerable: they are, but only from an image that is the same
|
||||
version or newer.
|
||||
- **`occ` must run as `www-data`.** It refuses to run as any other user, and under
|
||||
`keep-id:uid=33` that is the same user the host sees as `tanshu`. Every
|
||||
`podman exec` in this file therefore carries `-u www-data`; the plain form works
|
||||
for `getent`, which does not care.
|
||||
- **The init lock file.** `~/podman-data/nextcloud/nextcloud-init-sync.lock` is
|
||||
pre-created by the role so it stays owned by `tanshu`. If a run ever reports it
|
||||
as a subuid, the pre-create was skipped on a first start — delete it and re-run
|
||||
the playbook.
|
||||
- **Client IPs.** Nextcloud uses the client address for brute-force protection and
|
||||
for the `X-Forwarded-For` it logs. Whether the original address survives the
|
||||
rootless port forward depends on the forwarder. See
|
||||
[caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it.
|
||||
- **Registering restarts all of Caddy** (a brief restart of every hosted site),
|
||||
**attaching restarts Postgres** (a few seconds without a database for every
|
||||
attached app) and **restarts Valkey** (seconds without sessions or locks, though
|
||||
the keys themselves survive the graceful stop). See the trade-offs in
|
||||
[caddy/readme.md](../caddy/readme.md), [postgres/readme.md](../postgres/readme.md)
|
||||
and [valkey/readme.md](../valkey/readme.md).
|
||||
- **If the Caddyfile does not exist yet**, this playbook creates it containing only
|
||||
the site block. That is a valid Caddyfile; the Caddy playbook's seeded
|
||||
global-options block is then not added, because its seed only fires on a missing
|
||||
or zero-length file. Deploy Caddy first on a fresh host.
|
||||
- **DNS.** `cloud.hopsngrains.com` must resolve to `emily` before Caddy can obtain
|
||||
a certificate for it; the old certificates live in `~/podman-data/caddy/data`
|
||||
and are reused if the hostname is unchanged.
|
||||
- **The backup script still assumes Docker.** `~/bin/nextcloud-backup.sh` uses
|
||||
`--link postgres:db`, which Podman does not have, and its `sudo tar` /
|
||||
`chown $(logname)` gymnastics are unnecessary now that the data is owned by you.
|
||||
It needs the `podman exec` form from
|
||||
[postgres/readme.md](../postgres/readme.md), "Backups"; the database dump should
|
||||
also be taken with `occ maintenance:mode --on` around it, as
|
||||
[docs/backup-plan.md §3.4](../docs/backup-plan.md) says.
|
||||
- **`~/bin/empty-trash.sh` targets a container named `cloud`**, which never
|
||||
existed on this host under that name — it is `nextcloud` — and it runs as root,
|
||||
which cannot `podman exec` into these containers at all.
|
||||
- **Do not restart the container on monoco.** It would write to monoco's old
|
||||
cluster, which is being kept intact purely as a rollback.
|
||||
|
||||
## Not done here
|
||||
|
||||
- Rotating the secrets that are committed in plaintext — `pg_password` is the
|
||||
fleet's shared `123456`, and it lives in `nextcloud.env` at mode `0600` rather
|
||||
than in the world-readable unit file.
|
||||
- Switching the background-job mode to `cron` (`occ background:cron`), which the
|
||||
Docker-era deployment also never did.
|
||||
- Replacing the broken `~/bin/nextcloud-backup.sh` with a systemd user timer, and
|
||||
fixing `empty-trash.sh`.
|
||||
- Turning on ongoing monitoring — the `status.php` probe in the role is where a
|
||||
timer or an external check would hook in.
|
||||
@@ -1,5 +1,31 @@
|
||||
---
|
||||
# Handlers for Caddy role
|
||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
||||
# there. These handlers run as the connection user, so `systemctl --user` and
|
||||
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
|
||||
|
||||
# Notified by this role when the Caddyfile changes. A reload keeps existing
|
||||
# connections; a restart would briefly drop every hosted site.
|
||||
#
|
||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
||||
# not {{ caddyfile_path }}, which is the host side of it.
|
||||
- name: Reload Caddy configuration
|
||||
ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload"
|
||||
listen: "Reload Caddy"
|
||||
ansible.builtin.command: >-
|
||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||
changed_when: true
|
||||
listen: "Reload Caddy"
|
||||
|
||||
# Notified by caddy/roles/register when this service's network unit, or the
|
||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
||||
# of `docker network connect`, so the container has to be recreated — a brief
|
||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
||||
# "Trade-off worth knowing".
|
||||
#
|
||||
# Defined after the reload above so that when both are notified the cheap
|
||||
# config reload happens first.
|
||||
- name: Restart Caddy
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ caddy_name }}.service"
|
||||
scope: user
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
listen: Restart Caddy
|
||||
@@ -1,22 +1,25 @@
|
||||
---
|
||||
- name: Read current Caddyfile
|
||||
ansible.builtin.slurp:
|
||||
path: "{{ caddyfile_path }}"
|
||||
register: caddyfile_raw
|
||||
|
||||
- name: Decode Caddyfile content
|
||||
set_fact:
|
||||
caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}"
|
||||
|
||||
- name: Check if snippet already exists
|
||||
set_fact:
|
||||
snippet_present: "{{ snippet_block in caddyfile_content }}"
|
||||
|
||||
- name: Add snippet if missing
|
||||
# Append this site's block to the shared Caddyfile.
|
||||
#
|
||||
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
||||
# change as soon as the file is written; the handler only asks Caddy to re-read
|
||||
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
||||
#
|
||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
||||
# slurp the file and compare first — which also matters because slurp *fails*
|
||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
||||
# not run.
|
||||
#
|
||||
# The marker is unchanged from the Docker-era role, so an existing
|
||||
# cloud.hopsngrains.com block written on monoco is replaced in place rather than
|
||||
# duplicated.
|
||||
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||
ansible.builtin.blockinfile:
|
||||
path: "{{ caddyfile_path }}"
|
||||
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
|
||||
block: "{{ snippet_block }}"
|
||||
create: yes
|
||||
when: not snippet_present
|
||||
notify: "Reload Caddy"
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0644'
|
||||
notify: Reload Caddy
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
- name: "Ensure {{ container_name }} Docker network exists"
|
||||
docker_network:
|
||||
name: "{{ docker_network }}"
|
||||
state: present
|
||||
connected:
|
||||
- "{{ caddy_container }}"
|
||||
- "{{ postgres_container }}"
|
||||
- name: "{{ redis_container }}"
|
||||
alias: redis
|
||||
appends: yes
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
# All handlers run as the connection user ({{ podman_user }}), so systemctl
|
||||
# --user works without setting XDG_RUNTIME_DIR.
|
||||
|
||||
- name: Reload systemd user units
|
||||
ansible.builtin.command: systemctl --user daemon-reload
|
||||
changed_when: true
|
||||
listen: Reload systemd user units
|
||||
|
||||
# A changed environment file or unit file changes the generated unit, so
|
||||
# Nextcloud is recreated. Nothing is lost by that: config.php, data/ and
|
||||
# custom_apps/ are on the bind mount and the database is in Postgres. It is still
|
||||
# a few seconds of downtime for the site, and the entrypoint re-runs its version
|
||||
# check — including `occ upgrade` if the image has moved on — on the way back up.
|
||||
- name: Restart Nextcloud
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ unit_name }}"
|
||||
scope: user
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
listen: Restart Nextcloud
|
||||
@@ -1,36 +1,267 @@
|
||||
---
|
||||
# Tasks for Nextcloud role
|
||||
- name: Pull Nextcloud image
|
||||
docker_image:
|
||||
name: "{{ container_image }}"
|
||||
source: pull
|
||||
force_source: yes
|
||||
# Nextcloud as a rootless Podman Quadlet unit.
|
||||
#
|
||||
# Everything here runs as {{ podman_user }} — the connection user. No become, no
|
||||
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
|
||||
# user manager is reachable through the standard environment.
|
||||
|
||||
- name: Ensure Nextcloud Directory exists
|
||||
file:
|
||||
path: "/var/lib/{{ host_directory }}"
|
||||
state: directory
|
||||
mode: 0755
|
||||
- name: Ensure the Nextcloud webroot directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ host_directory }}"
|
||||
state: directory
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Nextcloud container
|
||||
docker_container:
|
||||
name: "{{ container_name }}"
|
||||
image: "{{ container_image }}"
|
||||
# The entrypoint takes an flock on this path using a root-owned shell redirect
|
||||
# (`) 9> /var/www/html/nextcloud-init-sync.lock`, in the nextcloud/docker
|
||||
# entrypoint). Creating it here means it belongs to {{ podman_user }} and stays
|
||||
# that way: the redirect *truncates* the file, it does not recreate it, so its
|
||||
# owner never changes.
|
||||
#
|
||||
# Left to the container, the file would be created by container-root — a subuid
|
||||
# under keep-id — and would be the one thing in the webroot not owned by
|
||||
# {{ podman_user }}.
|
||||
#
|
||||
# `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
|
||||
# a subuid (a host user cannot chown a file it does not own).
|
||||
- name: Pre-create the entrypoint's init lock file
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ host_directory }}/nextcloud-init-sync.lock"
|
||||
content: ""
|
||||
force: false
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0644'
|
||||
|
||||
# The app's database and cache coordinates are consumed by systemd
|
||||
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
|
||||
# written before the unit starts. Mode 0600: POSTGRES_PASSWORD is in it.
|
||||
- name: Write the environment file
|
||||
ansible.builtin.template:
|
||||
src: nextcloud.env.j2
|
||||
dest: "{{ env_file }}"
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0600'
|
||||
notify: Restart Nextcloud
|
||||
|
||||
- name: Install the Nextcloud Quadlet unit
|
||||
ansible.builtin.template:
|
||||
src: nextcloud.container.j2
|
||||
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0644'
|
||||
notify:
|
||||
- Reload systemd user units
|
||||
- Restart Nextcloud
|
||||
|
||||
- name: Start Nextcloud
|
||||
block:
|
||||
- name: Start Nextcloud
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ unit_name }}"
|
||||
scope: user
|
||||
state: started
|
||||
daemon_reload: true
|
||||
rescue:
|
||||
# systemd reports "control process exited with error code" and nothing else,
|
||||
# which is useless for the usual failures (an unattached network, an
|
||||
# unreachable database, an unwritable webroot). Pull the real reason out.
|
||||
- name: Collect Nextcloud diagnostics
|
||||
ansible.builtin.command: "{{ item }}"
|
||||
loop:
|
||||
- "systemctl --user status {{ unit_name }} --no-pager"
|
||||
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
|
||||
- "podman logs --tail 40 {{ container_name }}"
|
||||
register: nextcloud_diag
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show Nextcloud diagnostics
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ nextcloud_diag.results | map(attribute='stdout') | list }}"
|
||||
|
||||
- name: Fail
|
||||
ansible.builtin.fail:
|
||||
msg: "Nextcloud did not start — see the diagnostics above."
|
||||
|
||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
||||
- name: Verify Nextcloud is running
|
||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||
register: nextcloud_active
|
||||
changed_when: false
|
||||
failed_when: nextcloud_active.stdout != 'active'
|
||||
|
||||
# `is-active` says the container is running; it does not say the app can serve.
|
||||
# The entrypoint rsyncs the image's code into the webroot and, when the copied
|
||||
# version.php is older than the image, runs `occ upgrade` against
|
||||
# {{ pg_database }} before Apache answers — and `Restart=always` means systemd
|
||||
# reports an active unit even while that crash-loops.
|
||||
#
|
||||
# occ is the app's own answer and is always present. It has to run as www-data
|
||||
# (uid {{ app_uid }}): occ refuses to run as a user other than the one that owns
|
||||
# config.php, which under keep-id is what the mount is owned by.
|
||||
#
|
||||
# The retry budget of the first wait matches the unit's TimeoutStartSec, because
|
||||
# a first run after an image bump really can spend minutes in the database
|
||||
# upgrade.
|
||||
#
|
||||
# The status JSON is matched after stripping spaces rather than with a regex, so
|
||||
# both the compact form and a pretty-printed one match, and there is no `\s` to
|
||||
# escape (an invalid escape in a Jinja string literal is only a warning today and
|
||||
# may not stay one).
|
||||
- name: Wait for Nextcloud to become ready
|
||||
block:
|
||||
- name: Wait for Nextcloud to report itself installed
|
||||
ansible.builtin.command: >-
|
||||
podman exec -u www-data {{ container_name }}
|
||||
php occ status --output=json
|
||||
register: nextcloud_status
|
||||
until: >-
|
||||
nextcloud_status.rc == 0
|
||||
and ((nextcloud_status.stdout | replace(' ', '')) is search('"installed":true'))
|
||||
retries: 120
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
# Maintenance mode is ON for the whole of `occ upgrade`, which the entrypoint
|
||||
# runs on the first start after an image bump — so waiting is not optional
|
||||
# here: asserting immediately would fail a perfectly healthy deploy halfway
|
||||
# through the upgrade.
|
||||
#
|
||||
# It can also be left ON in the config.php copied from monoco, because
|
||||
# docs/backup-plan.md §3.4 tells an operator to set it before a consistent
|
||||
# backup. That case never resolves, which is why the rescue below names it
|
||||
# instead of leaving a bare timeout: `status.php` is exempt from maintenance
|
||||
# mode, so the app looks up while every real request gets a 503.
|
||||
- name: Wait for maintenance mode to be off
|
||||
ansible.builtin.command: >-
|
||||
podman exec -u www-data {{ container_name }}
|
||||
php occ status --output=json
|
||||
register: nextcloud_ready
|
||||
until: >-
|
||||
nextcloud_ready.rc == 0
|
||||
and ((nextcloud_ready.stdout | replace(' ', '')) is search('"installed":true'))
|
||||
and not ((nextcloud_ready.stdout | replace(' ', '')) is search('"maintenance":true'))
|
||||
retries: 120
|
||||
delay: 5
|
||||
changed_when: false
|
||||
rescue:
|
||||
- name: Collect Nextcloud readiness diagnostics
|
||||
ansible.builtin.command: "{{ item }}"
|
||||
loop:
|
||||
- "podman exec -u www-data {{ container_name }} php occ status --output=json"
|
||||
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
|
||||
- "podman logs --tail 40 {{ container_name }}"
|
||||
register: nextcloud_ready_diag
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Fail with the reason
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
Nextcloud never became ready. If the status above shows
|
||||
"maintenance": true, the instance is in maintenance mode — either left
|
||||
on in the copied config.php or still mid-upgrade — and because
|
||||
status.php is exempt from maintenance mode the site looks healthy while
|
||||
every real request gets a 503. Clear it with:
|
||||
podman exec -u www-data {{ container_name }} php occ maintenance:mode --off
|
||||
Otherwise, see the diagnostics above.
|
||||
|
||||
- name: Report Nextcloud status
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ nextcloud_ready.stdout }}"
|
||||
|
||||
# The check above runs the PHP CLI, which does not prove Apache is serving. This
|
||||
# goes through the image's own front end, over loopback, and asserts the JSON
|
||||
# status.php returns says the instance is installed.
|
||||
- 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
|
||||
# roles/valkey attached them to. Check the names config.php actually asks for,
|
||||
# plus the container names, because a wrong alias is the failure that looks like
|
||||
# a broken app rather than a broken network.
|
||||
- name: Confirm Nextcloud can resolve the database and the cache
|
||||
ansible.builtin.command: >-
|
||||
podman exec {{ container_name }} getent hosts {{ item }}
|
||||
loop:
|
||||
- "{{ db_host }}"
|
||||
- "{{ postgres_container }}"
|
||||
- "{{ redis_host }}"
|
||||
- "{{ valkey_container }}"
|
||||
register: nextcloud_lookup
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Report database and cache resolution
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ item.item }} resolves as {{ item.stdout | default('(no answer)') }}
|
||||
loop: "{{ nextcloud_lookup.results }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
# The Docker-era playbook added a *root* crontab entry running `php cron.php`
|
||||
# every five minutes. Root cannot `podman exec` into another user's rootless
|
||||
# containers (docs/podman-migration.md §8(b)), so it becomes a systemd user timer
|
||||
# next to the Quadlet units — which is also what makes it survive a reboot and
|
||||
# report its failures in the journal.
|
||||
|
||||
- name: Ensure the systemd user unit directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_unit_dir }}"
|
||||
state: directory
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Install the Nextcloud background-job service
|
||||
ansible.builtin.template:
|
||||
src: nextcloud-cron.service.j2
|
||||
dest: "{{ user_unit_dir }}/{{ cron_unit }}.service"
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0644'
|
||||
|
||||
- name: Install the Nextcloud background-job timer
|
||||
ansible.builtin.template:
|
||||
src: nextcloud-cron.timer.j2
|
||||
dest: "{{ user_unit_dir }}/{{ cron_unit }}.timer"
|
||||
owner: "{{ podman_user }}"
|
||||
group: "{{ podman_user }}"
|
||||
mode: '0644'
|
||||
|
||||
# Unlike the Quadlet units, a plain .timer is not generated, so it does need
|
||||
# enabling — and `enabled: true` is what brings it back after a reboot, the same
|
||||
# way `[Install] WantedBy=default.target` does for the container units.
|
||||
- name: Enable and start the Nextcloud background-job timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ cron_unit }}.timer"
|
||||
scope: user
|
||||
enabled: true
|
||||
state: started
|
||||
restart_policy: "unless-stopped"
|
||||
env:
|
||||
POSTGRES_DB: "{{ db_name }}"
|
||||
POSTGRES_USER: "{{ db_user }}"
|
||||
POSTGRES_PASSWORD: "{{ db_pass }}"
|
||||
POSTGRES_HOST: "{{ db_host }}"
|
||||
REDIS_HOST: "redis"
|
||||
networks:
|
||||
- name: "{{ docker_network }}"
|
||||
volumes:
|
||||
- "/var/lib/{{ host_directory }}:/var/www/html"
|
||||
daemon_reload: true
|
||||
|
||||
- name: Add cron job
|
||||
cron:
|
||||
name: Nextcloud cron
|
||||
minute: "*/5"
|
||||
job: "docker exec -u www-data {{ container_name }} php cron.php"
|
||||
- name: Report the background-job timer
|
||||
ansible.builtin.command: "systemctl --user list-timers {{ cron_unit }}.timer --no-pager"
|
||||
register: nextcloud_timer
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show the background-job timer
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ nextcloud_timer.stdout_lines | default(['(no output)']) }}"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Nextcloud background jobs — systemd user service.
|
||||
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
|
||||
#
|
||||
# This replaces the Docker-era root crontab entry:
|
||||
#
|
||||
# */5 * * * * docker exec -u www-data nextcloud php cron.php
|
||||
#
|
||||
# Root cannot `podman exec` into another user's rootless containers, and a
|
||||
# container user's cron is not a thing. A user service started by the timer next
|
||||
# to it is the idiomatic replacement, and it is what comes back after a reboot
|
||||
# with the rest of the stack (docs/podman-migration.md §8(b), §10).
|
||||
#
|
||||
# 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
|
||||
# connection user rather than through become.
|
||||
|
||||
[Unit]
|
||||
Description=Nextcloud background jobs (cron.php)
|
||||
After={{ unit_name }}
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# `-u www-data` matters: cron.php must run as the user that owns config.php, and
|
||||
# under keep-id that is uid {{ app_uid }}.
|
||||
ExecStart=/usr/bin/podman exec -u www-data {{ container_name }} php {{ webroot }}/cron.php
|
||||
# A long-running job must not pile up behind itself; the next timer tick would
|
||||
# start a second copy. Generous, because a first run after an upgrade can have a
|
||||
# lot of queued work.
|
||||
TimeoutStartSec=900
|
||||
@@ -0,0 +1,19 @@
|
||||
# Nextcloud background jobs — systemd user timer.
|
||||
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
|
||||
#
|
||||
# Fires nextcloud-cron.service every five minutes, which is what Nextcloud's own
|
||||
# installation guide asks for and what the Docker-era root crontab did.
|
||||
|
||||
[Unit]
|
||||
Description=Run Nextcloud background jobs every five minutes
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ cron_schedule }}
|
||||
# Catch up once if a slot was missed while the host was down, so a reboot does
|
||||
# not delay the first run by five minutes. It is not a queue: one catch-up run,
|
||||
# never one per missed slot.
|
||||
Persistent=true
|
||||
Unit={{ cron_unit }}.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,56 @@
|
||||
# Nextcloud — rootless Podman Quadlet unit.
|
||||
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
|
||||
#
|
||||
# This unit joins {{ network_name }}.network, which is created by
|
||||
# caddy/roles/register (the same include that attaches Caddy to it). The
|
||||
# postgres container is joined to that network by postgres/roles/attach and the
|
||||
# valkey container by valkey/roles/attach, which is what makes config.php's `db`
|
||||
# and `redis` hosts resolve.
|
||||
|
||||
[Unit]
|
||||
Description=Nextcloud
|
||||
Documentation=https://github.com/nextcloud/docker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName={{ container_name }}
|
||||
Image={{ image }}
|
||||
|
||||
# Map the container's www-data account (uid {{ app_uid }}) onto {{ podman_user }}
|
||||
# on the host. The entrypoint starts as container-root — a subuid under keep-id —
|
||||
# but it rsyncs the code in with `--chown www-data:www-data` and Apache drops to
|
||||
# www-data, so everything it writes lands on {{ podman_user }} and no subuid-owned
|
||||
# files appear to break backups.
|
||||
#
|
||||
# 33 is also the uid the data on monoco is owned by, so the copied tree needs no
|
||||
# chown pass: extracting it as {{ podman_user }} is already the right ownership.
|
||||
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
|
||||
|
||||
# POSTGRES_* and REDIS_HOST live here rather than as Environment= lines above:
|
||||
# this unit is world-readable in ~/.config, and POSTGRES_PASSWORD is in it. Read
|
||||
# by systemd, not by Ansible, so the file has to exist before this unit starts.
|
||||
#
|
||||
# These are not an install step any more — config.php is copied from monoco, not
|
||||
# generated. REDIS_HOST is still load-bearing on every start, because the
|
||||
# entrypoint rewrites PHP's session-handler config from it.
|
||||
EnvironmentFile={{ env_file }}
|
||||
|
||||
Network={{ network_name }}.network
|
||||
|
||||
# The image's VOLUME and Apache document root. This is the whole installation:
|
||||
# the code (version.php, core/, lib/, apps/), config/config.php, data/ and
|
||||
# custom_apps/. The Docker-era playbook bind-mounted exactly this, and the
|
||||
# entrypoint's version check reads version.php from here — which is why the
|
||||
# migration copies the tree rather than only the state. See readme.md.
|
||||
Volume={{ host_directory }}:{{ webroot }}
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
# The entrypoint rsyncs the image's code into the webroot and, if the copied
|
||||
# version.php is older than the image, runs `occ upgrade` before Apache answers.
|
||||
# On a 320 MB database that is minutes, not seconds.
|
||||
TimeoutStartSec=600
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,19 @@
|
||||
# Environment for {{ container_name }}.container.
|
||||
# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
|
||||
#
|
||||
# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable,
|
||||
# and POSTGRES_PASSWORD is in it. Note that `podman inspect` still prints the
|
||||
# resulting environment to anyone who can run podman as this user.
|
||||
#
|
||||
# config.php is copied from monoco, not generated, so the POSTGRES_* values are
|
||||
# not an install step any more — they only have to agree with what config.php
|
||||
# already carries, which is exactly what the Docker-era container passed.
|
||||
#
|
||||
# REDIS_HOST is different: the entrypoint reads it on *every* start and rewrites
|
||||
# PHP's session handler from it (/usr/local/etc/php/conf.d/redis-session.ini), so
|
||||
# a wrong value here shows up as failed logins rather than as a missing cache.
|
||||
POSTGRES_DB={{ pg_database }}
|
||||
POSTGRES_USER={{ pg_user }}
|
||||
POSTGRES_PASSWORD={{ pg_password }}
|
||||
POSTGRES_HOST={{ db_host }}
|
||||
REDIS_HOST={{ redis_host }}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
|
||||
# unit there. This handler runs as the connection user, so `systemctl --user`
|
||||
# works without setting XDG_RUNTIME_DIR.
|
||||
#
|
||||
# Required by postgres/roles/attach, which notifies it whenever this app's
|
||||
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
|
||||
# recreated to join the network — Quadlet has no live equivalent of
|
||||
# `podman network connect`. That is a few seconds of downtime for every attached
|
||||
# app, not just this one. See postgres/readme.md, "The trade-off".
|
||||
- name: Restart Postgres
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ postgres_unit }}"
|
||||
scope: user
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
listen: Restart Postgres
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
# Attach this app's network to the shared Postgres unit.
|
||||
#
|
||||
# Postgres knows nothing about its clients, exactly like Caddy: a service joins
|
||||
# by dropping 50-nextcloud.conf and the shared 10-network-aliases.conf next to
|
||||
# postgres.container. The canonical way to do that is the shared include below —
|
||||
# it writes the same bytes every service writes, asserts that nextcloud.network
|
||||
# already exists, and notifies the "Restart Postgres" handler defined in this
|
||||
# role.
|
||||
#
|
||||
# This runs in the roles phase, which is after the register include in
|
||||
# playbook.yml's pre_tasks has created nextcloud.network.
|
||||
|
||||
- name: Attach Nextcloud to Postgres
|
||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
|
||||
|
||||
# On a first attach both drop-ins are new, so Postgres has to be recreated to
|
||||
# join nextcloud_net — and that restart is a *handler*, which by default runs at
|
||||
# the end of the play, after the nextcloud role has already started the app. The
|
||||
# app would then come up before `db` resolves, fail its own readiness check, and
|
||||
# fail the play on a machine where nothing is actually wrong.
|
||||
#
|
||||
# Flushing applies the new drop-in before the app starts. It also flushes the
|
||||
# "Restart Caddy" that the register pre_task queued, which is what attaches Caddy
|
||||
# to nextcloud_net — also wanted before the app is expected to serve. On later
|
||||
# runs nothing is pending and this is a no-op.
|
||||
- name: Apply the Postgres network change before starting Nextcloud
|
||||
ansible.builtin.meta: flush_handlers
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
|
||||
# there. This handler runs as the connection user, so `systemctl --user` works
|
||||
# without setting XDG_RUNTIME_DIR.
|
||||
#
|
||||
# Required by valkey/roles/attach, which notifies it whenever this app's drop-in
|
||||
# changes: a new drop-in changes the generated unit, so Valkey must be recreated
|
||||
# to join the network — Quadlet has no live equivalent of
|
||||
# `podman network connect`.
|
||||
#
|
||||
# Data survives the recreate: RDB persistence is on by default, the stop is
|
||||
# graceful, and the new container loads the snapshot back off the bind mount. What
|
||||
# does not survive is availability — for the seconds the container is down,
|
||||
# Nextcloud's PHP sessions and `memcache.locking` are unreachable. See
|
||||
# valkey/readme.md, "The trade-off".
|
||||
- name: Restart Valkey
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ valkey_unit }}"
|
||||
scope: user
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
listen: Restart Valkey
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
# Attach this app's network to the shared Valkey unit.
|
||||
#
|
||||
# Valkey knows nothing about its clients, exactly like Caddy and Postgres: a
|
||||
# service joins by dropping 50-nextcloud.conf and the shared
|
||||
# 10-network-aliases.conf next to valkey.container. The canonical way to do that
|
||||
# is the shared include below — it writes the same bytes every service writes,
|
||||
# asserts that nextcloud.network already exists, and notifies the "Restart
|
||||
# Valkey" handler defined in this role.
|
||||
#
|
||||
# `redis` is the alias config.php connects to, not the container name: the image
|
||||
# writes the host from REDIS_HOST, which the Docker-era `env:` block set to
|
||||
# `redis`, and the same variable configures PHP's session handler on every start.
|
||||
# Renaming it here would mean editing config.php on a live instance for nothing.
|
||||
|
||||
- name: Attach Nextcloud to Valkey
|
||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
|
||||
|
||||
# On a first attach the drop-in is new, so Valkey has to be recreated to join
|
||||
# nextcloud_net — and that restart is a *handler*, which by default runs at the
|
||||
# end of the play, after the nextcloud role has already started the app. The app
|
||||
# would then come up before `redis` resolves; with `memcache.locking` on Redis it
|
||||
# would fail on the first write rather than at startup, which is a worse failure
|
||||
# to debug.
|
||||
#
|
||||
# Flushing applies the new drop-in before the app starts. On later runs the
|
||||
# drop-in is unchanged, nothing is pending, and this is a no-op.
|
||||
- name: Apply the Valkey network change before starting Nextcloud
|
||||
ansible.builtin.meta: flush_handlers
|
||||
+101
-17
@@ -1,28 +1,112 @@
|
||||
---
|
||||
container_name: nextcloud
|
||||
container_image: nextcloud:latest
|
||||
|
||||
docker_network: nextcloud_net
|
||||
|
||||
postgres_container: postgres
|
||||
caddy_container: caddy
|
||||
redis_container: valkey
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rootless Podman / Quadlet
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
|
||||
# file wholesale, because it also sets `host_directory` — to the Caddy data
|
||||
# directory — and every playbook defines that for itself. Keep these three in
|
||||
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
|
||||
# belongs to the podman tasks, which this play does not run: Nextcloud listens on
|
||||
# 80 inside the container and publishes nothing host-side.)
|
||||
#
|
||||
# podman_user is derived from the connection: this play carries no become, so the
|
||||
# Quadlet units belong to whoever Ansible connects as. Requires gather_facts (set
|
||||
# in playbook.yml).
|
||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nextcloud
|
||||
# ---------------------------------------------------------------------------
|
||||
host: cloud.hopsngrains.com
|
||||
|
||||
caddyfile_path: /var/lib/caddy/conf/Caddyfile
|
||||
container_name: nextcloud
|
||||
unit_name: "{{ container_name }}.service"
|
||||
|
||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
||||
# unqualified-search-registries, and there is no TTY to answer a prompt when the
|
||||
# systemd unit starts. The Docker-era playbook said `nextcloud:latest`.
|
||||
image: docker.io/library/nextcloud:latest
|
||||
|
||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
||||
# Podman network name to <network_name>_net (so: nextcloud.network,
|
||||
# nextcloud_net).
|
||||
network_name: "{{ container_name }}"
|
||||
|
||||
# The image's VOLUME and Apache document root — and, unlike the other services
|
||||
# here, it holds the *code* as well as the state: config.php, data/,
|
||||
# custom_apps/ and every app's PHP files live under it. The Docker-era playbook
|
||||
# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that
|
||||
# shape on purpose. See readme.md, "What is state, and what is not".
|
||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||
webroot: /var/www/html
|
||||
|
||||
# The uid the *server* writes as: the image's entrypoint starts as container-root,
|
||||
# rsyncs the code in with --chown www-data:www-data, and Apache then drops to
|
||||
# www-data. `docker exec nextcloud id -u` reports 0, which is misleading
|
||||
# (docs/podman-compat-audit.md §3.2).
|
||||
app_uid: 33
|
||||
app_gid: 33
|
||||
|
||||
# The port Apache listens on inside the container, and the one Caddy proxies to.
|
||||
container_port: 80
|
||||
|
||||
# POSTGRES_* and REDIS_HOST are read by the entrypoint on every start — REDIS_HOST
|
||||
# in particular rewrites PHP's session handler config — and POSTGRES_PASSWORD is in
|
||||
# there, so it goes in this 0600 file rather than in the world-readable unit.
|
||||
env_file: "{{ host_directory }}/nextcloud.env"
|
||||
|
||||
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
|
||||
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
|
||||
# handler that applies them.
|
||||
postgres_container: postgres
|
||||
postgres_unit: "{{ postgres_container }}.service"
|
||||
|
||||
# The credentials and database name the Docker-era playbook embedded in its
|
||||
# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB,
|
||||
# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree.
|
||||
pg_user: postgres
|
||||
pg_password: '123456'
|
||||
pg_database: nextcloud
|
||||
|
||||
# `db` is the host config.php actually connects to — the Docker-era container set
|
||||
# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared
|
||||
# Postgres unit.
|
||||
db_host: db
|
||||
|
||||
# The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and
|
||||
# the alias it is reached by. REDIS_HOST is not cosmetic: config.php's
|
||||
# `memcache.locking` is Redis, so an unresolvable `redis` breaks uploads rather
|
||||
# than merely slowing them down.
|
||||
valkey_container: valkey
|
||||
valkey_unit: "{{ valkey_container }}.service"
|
||||
redis_host: redis
|
||||
|
||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
|
||||
caddy_name: caddy
|
||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in
|
||||
# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root
|
||||
# cannot `podman exec` into another user's rootless containers, so it becomes a
|
||||
# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)).
|
||||
cron_unit: "{{ container_name }}-cron"
|
||||
cron_schedule: "*:0/5"
|
||||
user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user"
|
||||
|
||||
# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and
|
||||
# Nextcloud has always asked for HSTS. There is no request-body limit to carry
|
||||
# over — Caddy streams bodies and imposes none by default, whereas the dead nginx
|
||||
# front end needed client_max_body_size spelled out.
|
||||
snippet_block: |
|
||||
{{ host }} {
|
||||
reverse_proxy {{ container_name }}:80
|
||||
reverse_proxy {{ container_name }}:{{ container_port }}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=15552000; includeSubDomains; preload"
|
||||
}
|
||||
}
|
||||
|
||||
host_directory: nextcloud
|
||||
|
||||
db_name: "nextcloud"
|
||||
db_user: "postgres"
|
||||
db_pass: "123456"
|
||||
db_host: "db"
|
||||
Reference in new issue
Block a user