From 5d63f3f0e03658a6c81fadae2de93162f577de6b Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Fri, 16 Oct 2026 15:24:02 +0530 Subject: [PATCH] Schedule Nextcloud's cron.php in the user's crontab, not a systemd timer The timer was justified partly on XDG_RUNTIME_DIR: a user crontab "would need it faked into every line". That is wrong here. The caddy playbook enables lingering, so /run/user/ exists whether or not anyone is logged in, and that is where podman keeps the state `exec` reads. Nothing needs to be set. The one real constraint is that the job cannot go in root's crontab: root cannot `podman exec` into another user's rootless containers. This user's crontab is fine, and the play runs as that user, so `ansible.builtin.cron` with no `user:` writes exactly there. That replaces four tasks and two template files with one task and no files, and it is what the service had before the Quadlet conversion. The cost is that cron sends its output to local mail instead of the journal; the readme says so and names the timer as the alternative. While here, the XDG_RUNTIME_DIR asides come out of the comments that carried them: the play runs over SSH, so `systemctl --user` works because systemd's user manager is reachable over that connection, and that is all a reader needs to know. --- nextcloud/readme.md | 43 ++++++++------- nextcloud/roles/caddy/handlers/main.yaml | 4 +- nextcloud/roles/nextcloud/handlers/main.yaml | 4 +- nextcloud/roles/nextcloud/tasks/main.yaml | 54 +++++-------------- .../templates/nextcloud-cron.service.j2 | 26 --------- .../templates/nextcloud-cron.timer.j2 | 19 ------- nextcloud/roles/postgres/handlers/main.yaml | 4 +- nextcloud/roles/valkey/handlers/main.yaml | 4 +- nextcloud/vars/default.yml | 6 +-- 9 files changed, 46 insertions(+), 118 deletions(-) delete mode 100644 nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 delete mode 100644 nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 diff --git a/nextcloud/readme.md b/nextcloud/readme.md index 4f45a9e..6c66fb8 100644 --- a/nextcloud/readme.md +++ b/nextcloud/readme.md @@ -9,7 +9,7 @@ Standalone and idempotent, and host-agnostic apart from `hosts:` in [playbook.yml](playbook.yml) and `host` in [vars/default.yml](vars/default.yml): - **First run** creates the webroot, the environment file, the unit and the - background-job timer, creates the database if the cluster does not have it, and + background job, creates the database if the cluster does not have it, and starts the container. A fresh webroot then serves the setup wizard — or installs itself unattended, if you set the two optional credentials first. - **Every run after that** converges the same things. Quadlet recreates the @@ -37,7 +37,7 @@ nextcloud/ └── roles/ ├── postgres/ # the database, and this app's network on the cluster ├── valkey/ # this app's network on the cache - ├── nextcloud/ # webroot, env file, unit, session config, cron timer + ├── nextcloud/ # webroot, env file, unit, session config, cron └── caddy/ # the site block in the shared Caddyfile ``` @@ -47,7 +47,6 @@ nextcloud/ | `~/.config/containers/systemd/nextcloud.network` | the app network | | `~/.config/containers/systemd/*.container.d/50-nextcloud.conf` | attaches Caddy, Postgres and Valkey to it | | `~/.config/containers/systemd/*.container.d/10-network-aliases.conf` | the shared `db` and `redis` aliases | -| `~/.config/systemd/user/nextcloud-cron.{service,timer}` | the background-job timer | | `~/podman-data/nextcloud/` | the whole `/var/www/html` | | `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600` | | `~/podman-data/nextcloud/redis-session.ini` | mounted into the image; see [below](#the-php-session-file) | @@ -98,20 +97,20 @@ the keys survive it (see [valkey/readme.md](../valkey/readme.md)). ## Background jobs -Nextcloud expects `cron.php` every five minutes. **It cannot be a crontab entry:** -root cannot `podman exec` into another user's rootless containers, and a container -has no crontab of its own. A root crontab would fail; a *user* crontab would need -`XDG_RUNTIME_DIR` faked into every line and a cron daemon present. The role -installs a systemd user pair instead, next to the Quadlet units: +Nextcloud expects `cron.php` every five minutes. The job goes in **this user's +crontab**, not root's: root cannot `podman exec` into another user's rootless +containers, which is the one real constraint here. Nothing else is needed — the +caddy playbook enables lingering, so `/run/user/` exists whether or not +anyone is logged in, which is where podman keeps the state `exec` reads. -| 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` | +``` +*/5 * * * * /usr/bin/podman exec -u www-data nextcloud php /var/www/html/cron.php +``` -Unlike the Quadlet units these are plain units, so they really are `enable`d — -that is what brings the timer back after a reboot — and -`journalctl --user -u nextcloud-cron` shows every run. +It is a plain `ansible.builtin.cron` entry, so it is one task and no unit files, +and `crontab -l` shows it. The trade-off is that output goes to local mail rather +than to a journal: if you would rather have that, a systemd user timer next to the +Quadlet units is the alternative, and it is two small files. ## Settings @@ -130,7 +129,7 @@ that is what brings the timer back after a reboot — and | `pg_*` / `db_host` | The database, its credentials, and the `db` alias | | `valkey_container` / `redis_host` | The cache unit, and the `redis` alias | | `nextcloud_admin_user` / `nextcloud_admin_password` | Empty by default; set both to install unattended | -| `cron_*` / `user_unit_dir` | The background-job timer | +| `cron_minute` | The `cron.php` interval | | `caddy_*` | The shared Caddy unit and its Caddyfile | The three `podman_*` / `quadlet_dir` vars are duplicated from @@ -225,7 +224,7 @@ systemctl --user status nextcloud journalctl --user -u nextcloud -f podman exec -u www-data nextcloud php occ status podman exec nextcloud getent hosts db redis -systemctl --user list-timers nextcloud-cron.timer +crontab -l curl -sSI https://cloud.hopsngrains.com ``` @@ -237,8 +236,8 @@ in `config.php`, and PHP sessions. Confirm the new file is owned by `tanshu`: find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu ``` -Finally reboot and confirm the container and the timer come back unattended — -without `loginctl enable-linger tanshu` neither will, and the failure is silent. +Finally reboot and confirm the container comes back unattended — without +`loginctl enable-linger tanshu` it will not, and the failure is silent. ### Debugging a unit that will not start @@ -282,8 +281,8 @@ systemctl --user reset-failed nextcloud.service [caddy/readme.md](../caddy/readme.md), "Client IPs", including whether the original address survives the rootless port forward. - **Backups are not this playbook's business.** Whatever dumps the database and - archives the webroot has to run as a **systemd user timer** for the same reason - `cron.php` does, and should dump in place rather than through a helper container + archives the webroot belongs in the same user crontab, for the same reason as + `cron.php`, and should dump in place rather than through a helper container (`--link` does not exist in Podman): ```bash @@ -294,4 +293,4 @@ systemctl --user reset-failed nextcloud.service - Rotating the secrets that are committed in plaintext. - Pinning the image to a digest, so a `latest` bump is deliberate. -- A backup timer for the webroot and the database. +- A backup job for the webroot and the database. diff --git a/nextcloud/roles/caddy/handlers/main.yaml b/nextcloud/roles/caddy/handlers/main.yaml index 5fe87df..2f899d7 100644 --- a/nextcloud/roles/caddy/handlers/main.yaml +++ b/nextcloud/roles/caddy/handlers/main.yaml @@ -1,7 +1,7 @@ --- # 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. +# there. These handlers run as the connection user, which is what makes +# `systemctl --user` and `podman exec` work here. # Notified by this role when the Caddyfile changes. A reload keeps existing # connections; a restart would briefly drop every hosted site. diff --git a/nextcloud/roles/nextcloud/handlers/main.yaml b/nextcloud/roles/nextcloud/handlers/main.yaml index 91063e6..c3f837a 100644 --- a/nextcloud/roles/nextcloud/handlers/main.yaml +++ b/nextcloud/roles/nextcloud/handlers/main.yaml @@ -1,6 +1,6 @@ --- -# All handlers run as the connection user ({{ podman_user }}), so systemctl -# --user works without setting XDG_RUNTIME_DIR. +# Handlers run as the connection user, which is what makes `systemctl --user` +# work here. - name: Reload systemd user units ansible.builtin.command: systemctl --user daemon-reload diff --git a/nextcloud/roles/nextcloud/tasks/main.yaml b/nextcloud/roles/nextcloud/tasks/main.yaml index 06905da..64e1ebc 100644 --- a/nextcloud/roles/nextcloud/tasks/main.yaml +++ b/nextcloud/roles/nextcloud/tasks/main.yaml @@ -1,9 +1,8 @@ --- # Nextcloud as a rootless Podman Quadlet unit. # -# Everything runs as the connection user: the units belong to it, so -# `systemctl --user` works through the SSH session's own PAM session — no become, -# no XDG_RUNTIME_DIR juggling. Requires gather_facts (set in playbook.yml). +# Everything runs as the connection user; requires gather_facts (set in +# playbook.yml), which is where podman_user and the home directory come from. # # A fresh webroot is a valid state. The image initialises the code and serves the # setup wizard, and this play succeeds. Set nextcloud_admin_user and @@ -110,40 +109,15 @@ denied" under /usr/local/etc/php/conf.d, anything about `db` or `redis`, and "downgrading is not supported" each point somewhere different. -# Nextcloud wants `cron.php` every five minutes, and it cannot be a crontab entry: -# root cannot `podman exec` into another user's rootless containers, and a -# container has no crontab of its own. A systemd user timer is the equivalent, and -# it comes back after a reboot with the rest of the stack. -- 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 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 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' - -# A plain .timer is not generated, so unlike the Quadlet units it does have to be -# enabled — that is what brings it back after a reboot. -- name: Enable and start the background-job timer - ansible.builtin.systemd_service: - name: "{{ cron_unit }}.timer" - scope: user - enabled: true - state: started - daemon_reload: true +# Nextcloud wants `cron.php` every five minutes. This has to be *this user's* +# crontab, not root's: root cannot `podman exec` into another user's rootless +# containers. It works without any environment plumbing because the caddy +# playbook enables lingering, so /run/user/ exists whether or not anyone is +# logged in — which is where podman keeps the state `exec` needs. +- name: Schedule Nextcloud's background jobs + ansible.builtin.cron: + name: Nextcloud background jobs + minute: "{{ cron_minute }}" + job: >- + /usr/bin/podman exec -u www-data {{ container_name }} + php {{ webroot }}/cron.php diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 deleted file mode 100644 index 71ee093..0000000 --- a/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 +++ /dev/null @@ -1,26 +0,0 @@ -# Nextcloud background jobs — systemd user service. -# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. -# -# Nextcloud expects `cron.php` every five minutes. It cannot be a crontab entry: -# root cannot `podman exec` into another user's rootless containers, and there is -# no container user to own a crontab. A user service started by the timer next to -# it is the equivalent, and it is what comes back after a reboot with the rest of -# the stack. -# -# No XDG_RUNTIME_DIR juggling is needed: systemd's *user* manager sets it for -# every unit it starts, which is also why the whole playbook runs as the -# 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 diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 deleted file mode 100644 index 352298b..0000000 --- a/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 +++ /dev/null @@ -1,19 +0,0 @@ -# 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 the interval -# Nextcloud's own installation guide asks for. - -[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 diff --git a/nextcloud/roles/postgres/handlers/main.yaml b/nextcloud/roles/postgres/handlers/main.yaml index 31a5d44..98f8953 100644 --- a/nextcloud/roles/postgres/handlers/main.yaml +++ b/nextcloud/roles/postgres/handlers/main.yaml @@ -1,7 +1,7 @@ --- # 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. +# unit there. This handler runs as the connection user, which is what makes +# `systemctl --user` work here. # # 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 diff --git a/nextcloud/roles/valkey/handlers/main.yaml b/nextcloud/roles/valkey/handlers/main.yaml index 71e55fe..256927e 100644 --- a/nextcloud/roles/valkey/handlers/main.yaml +++ b/nextcloud/roles/valkey/handlers/main.yaml @@ -1,7 +1,7 @@ --- # 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. +# there. This handler runs as the connection user, which is what makes +# `systemctl --user` work here. # # 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 diff --git a/nextcloud/vars/default.yml b/nextcloud/vars/default.yml index b2df7dd..f4964b4 100644 --- a/nextcloud/vars/default.yml +++ b/nextcloud/vars/default.yml @@ -80,9 +80,9 @@ nextcloud_admin_password: "" # --------------------------------------------------------------------------- # Background jobs # --------------------------------------------------------------------------- -cron_unit: "{{ container_name }}-cron" -cron_schedule: "*:0/5" -user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user" +# Nextcloud wants cron.php every five minutes. Not a variable worth exposing +# beyond the interval, since the schedule is the part someone may change. +cron_minute: "*/5" # --------------------------------------------------------------------------- # Caddy site block