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
+511 -672

No files matched your search

+45 -29
View File
@@ -19,6 +19,8 @@ quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Nextcloud
# ---------------------------------------------------------------------------
# The one site-specific value: the hostname Caddy serves this instance on and the
# hostname that goes into trusted_domains when the install is unattended.
host: cloud.hopsngrains.com
container_name: nextcloud
@@ -26,7 +28,7 @@ unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when the
# systemd unit starts. The Docker-era playbook said `nextcloud:latest`.
# systemd unit starts.
image: docker.io/library/nextcloud:latest
# The Quadlet file is <network_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
# here, it holds the *code* as well as the state: config.php, data/,
# custom_apps/ and every app's PHP files live under it. The Docker-era playbook
# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that
# shape on purpose. See readme.md, "What is state, and what is not".
# custom_apps/ and every app's PHP file live under it. See readme.md, "What is
# state, and what is not".
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
webroot: /var/www/html
# The uid the *server* writes as: 33 is www-data, which owns the webroot, because
# the entrypoint rsyncs the code in with --chown www-data:www-data and Apache runs
# its workers as that account. `docker exec nextcloud id -u` reported 0 on the old
# host, because the entrypoint drops privileges after starting — do not derive the
# mapping from that number (docs/podman-compat-audit.md §3.2).
# its workers as that account. `docker exec nextcloud id -u` reports 0 in the
# upstream image, because the entrypoint drops privileges after starting — do not
# derive the mapping from that number (docs/podman-compat-audit.md §3.2).
app_uid: 33
app_gid: 33
# The port Apache listens on inside the container, and the one Caddy proxies to.
container_port: 80
# PHP's session-handler config, and the one path in the image this deployment has
# to write to. The entrypoint generates the file from REDIS_HOST on every start,
# but its target directory (`/usr/local/etc/php/conf.d`) is created by root in the
# php base image with no chown, so the uid this container runs as cannot create a
# file inside it. Mounting a host-owned file over that exact path is what gives
# the write somewhere it is allowed to land, and the template below keeps the
# image's own content in it so a refused write is not fatal either. See readme.md,
# "The PHP session file".
# PHP's session-handler config, and the one path in the image the entrypoint has
# to write to. It generates the file from REDIS_HOST on every start, but its
# target directory (`/usr/local/etc/php/conf.d`) is created by root in the php
# base image with no chown, so the uid this container runs as cannot create a file
# inside it. Mounting a host-owned file over that exact path is what gives the
# write somewhere it is allowed to land, and the template keeps the image's own
# content in it so a refused write is not fatal either. See readme.md, "The PHP
# session file".
php_conf_dir: "{{ host_directory }}/php-conf.d"
php_session_ini: "{{ php_conf_dir }}/redis-session.ini"
php_session_ini_mount: /usr/local/etc/php/conf.d/redis-session.ini
@@ -76,16 +77,16 @@ env_file: "{{ host_directory }}/nextcloud.env"
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The credentials and database name the Docker-era playbook embedded in its
# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB,
# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree.
# The database this instance uses, and the credentials for it. They have to match
# whatever the shared Postgres unit was initialised with (postgres/vars/default.yml)
# — this play attaches to a database, it does not create one, so the database has
# to exist before the first run.
pg_user: postgres
pg_password: '123456'
pg_database: nextcloud
# `db` is the host config.php actually connects to — the Docker-era container set
# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared
# Postgres unit.
# `db` is the alias postgres/roles/attach adds to the shared Postgres unit, and
# the host this instance's config.php connects to.
db_host: db
# The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and
@@ -100,21 +101,36 @@ redis_host: redis
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# ---------------------------------------------------------------------------
# First-run install (optional)
# ---------------------------------------------------------------------------
# Leave both empty to install through the web wizard at https://{{ host }} — the
# play reports that this is what is left to do and succeeds. Set both to have the
# image install unattended on a fresh webroot instead: it runs
# `occ maintenance:install` with these credentials and sets trusted_domains to
# `host` in the same pass, so the instance is usable immediately.
#
# They are only consumed while there is no config.php, so they are inert on an
# existing instance, and the environment template omits them entirely when they
# are empty (an empty NEXTCLOUD_ADMIN_USER would be treated as a value and fail
# the install).
nextcloud_admin_user: ""
nextcloud_admin_password: ""
# ---------------------------------------------------------------------------
# Background jobs
# ---------------------------------------------------------------------------
# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in
# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root
# cannot `podman exec` into another user's rootless containers, so it becomes a
# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)).
# Nextcloud wants `cron.php` every five minutes. It cannot be a crontab entry:
# root cannot `podman exec` into another user's rootless containers, and there is
# no container user to own a crontab. A systemd user timer next to the Quadlet
# units is the equivalent.
cron_unit: "{{ container_name }}-cron"
cron_schedule: "*:0/5"
user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user"
# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and
# Nextcloud has always asked for HSTS. There is no request-body limit to carry
# over — Caddy streams bodies and imposes none by default, whereas the dead nginx
# front end needed client_max_body_size spelled out.
# The site block. Caddy terminates TLS, and Nextcloud asks for HSTS. There is no
# request-body limit to carry over: Caddy streams bodies and imposes none by
# default.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}