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/<uid> 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.
This commit is contained in:
tanshu committed 2026-10-16 15:24:02 +05:30
1 parent 05452127ec
commit 5d63f3f0e0
9 files changed
+46 -118

No files matched your search

+21 -22
View File
@@ -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): [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 - **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 starts the container. A fresh webroot then serves the setup wizard — or installs
itself unattended, if you set the two optional credentials first. itself unattended, if you set the two optional credentials first.
- **Every run after that** converges the same things. Quadlet recreates the - **Every run after that** converges the same things. Quadlet recreates the
@@ -37,7 +37,7 @@ nextcloud/
└── roles/ └── roles/
├── postgres/ # the database, and this app's network on the cluster ├── postgres/ # the database, and this app's network on the cluster
├── valkey/ # this app's network on the cache ├── 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 └── caddy/ # the site block in the shared Caddyfile
``` ```
@@ -47,7 +47,6 @@ nextcloud/
| `~/.config/containers/systemd/nextcloud.network` | the app network | | `~/.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/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/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/` | the whole `/var/www/html` |
| `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600` | | `~/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) | | `~/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 ## Background jobs
Nextcloud expects `cron.php` every five minutes. **It cannot be a crontab entry:** Nextcloud expects `cron.php` every five minutes. The job goes in **this user's
root cannot `podman exec` into another user's rootless containers, and a container crontab**, not root's: root cannot `podman exec` into another user's rootless
has no crontab of its own. A root crontab would fail; a *user* crontab would need containers, which is the one real constraint here. Nothing else is needed — the
`XDG_RUNTIME_DIR` faked into every line and a cron daemon present. The role caddy playbook enables lingering, so `/run/user/<uid>` exists whether or not
installs a systemd user pair instead, next to the Quadlet units: anyone is logged in, which is where podman keeps the state `exec` reads.
| Unit | What it does | ```
|---|---| */5 * * * * /usr/bin/podman exec -u www-data nextcloud php /var/www/html/cron.php
| `~/.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 — It is a plain `ansible.builtin.cron` entry, so it is one task and no unit files,
that is what brings the timer back after a reboot — and and `crontab -l` shows it. The trade-off is that output goes to local mail rather
`journalctl --user -u nextcloud-cron` shows every run. 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 ## 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 | | `pg_*` / `db_host` | The database, its credentials, and the `db` alias |
| `valkey_container` / `redis_host` | The cache unit, and the `redis` 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 | | `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 | | `caddy_*` | The shared Caddy unit and its Caddyfile |
The three `podman_*` / `quadlet_dir` vars are duplicated from The three `podman_*` / `quadlet_dir` vars are duplicated from
@@ -225,7 +224,7 @@ systemctl --user status nextcloud
journalctl --user -u nextcloud -f journalctl --user -u nextcloud -f
podman exec -u www-data nextcloud php occ status podman exec -u www-data nextcloud php occ status
podman exec nextcloud getent hosts db redis podman exec nextcloud getent hosts db redis
systemctl --user list-timers nextcloud-cron.timer crontab -l
curl -sSI https://cloud.hopsngrains.com 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 find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu
``` ```
Finally reboot and confirm the container and the timer come back unattended — Finally reboot and confirm the container comes back unattended — without
without `loginctl enable-linger tanshu` neither will, and the failure is silent. `loginctl enable-linger tanshu` it will not, and the failure is silent.
### Debugging a unit that will not start ### 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 [caddy/readme.md](../caddy/readme.md), "Client IPs", including whether the
original address survives the rootless port forward. original address survives the rootless port forward.
- **Backups are not this playbook's business.** Whatever dumps the database and - **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 archives the webroot belongs in the same user crontab, for the same reason as
`cron.php` does, and should dump in place rather than through a helper container `cron.php`, and should dump in place rather than through a helper container
(`--link` does not exist in Podman): (`--link` does not exist in Podman):
```bash ```bash
@@ -294,4 +293,4 @@ systemctl --user reset-failed nextcloud.service
- Rotating the secrets that are committed in plaintext. - Rotating the secrets that are committed in plaintext.
- Pinning the image to a digest, so a `latest` bump is deliberate. - 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.
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit # 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 # there. These handlers run as the connection user, which is what makes
# `podman exec` work without any XDG_RUNTIME_DIR juggling. # `systemctl --user` and `podman exec` work here.
# Notified by this role when the Caddyfile changes. A reload keeps existing # Notified by this role when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site. # connections; a restart would briefly drop every hosted site.
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
# All handlers run as the connection user ({{ podman_user }}), so systemctl # Handlers run as the connection user, which is what makes `systemctl --user`
# --user works without setting XDG_RUNTIME_DIR. # work here.
- name: Reload systemd user units - name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload ansible.builtin.command: systemctl --user daemon-reload
+14 -40
View File
@@ -1,9 +1,8 @@
--- ---
# Nextcloud as a rootless Podman Quadlet unit. # Nextcloud as a rootless Podman Quadlet unit.
# #
# Everything runs as the connection user: the units belong to it, so # Everything runs as the connection user; requires gather_facts (set in
# `systemctl --user` works through the SSH session's own PAM session — no become, # playbook.yml), which is where podman_user and the home directory come from.
# no XDG_RUNTIME_DIR juggling. Requires gather_facts (set in playbook.yml).
# #
# A fresh webroot is a valid state. The image initialises the code and serves the # 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 # 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`, denied" under /usr/local/etc/php/conf.d, anything about `db` or `redis`,
and "downgrading is not supported" each point somewhere different. and "downgrading is not supported" each point somewhere different.
# Nextcloud wants `cron.php` every five minutes, and it cannot be a crontab entry: # Nextcloud wants `cron.php` every five minutes. This has to be *this user's*
# root cannot `podman exec` into another user's rootless containers, and a # crontab, not root's: root cannot `podman exec` into another user's rootless
# container has no crontab of its own. A systemd user timer is the equivalent, and # containers. It works without any environment plumbing because the caddy
# it comes back after a reboot with the rest of the stack. # playbook enables lingering, so /run/user/<uid> exists whether or not anyone is
- name: Ensure the systemd user unit directory exists # logged in — which is where podman keeps the state `exec` needs.
ansible.builtin.file: - name: Schedule Nextcloud's background jobs
path: "{{ user_unit_dir }}" ansible.builtin.cron:
state: directory name: Nextcloud background jobs
owner: "{{ podman_user }}" minute: "{{ cron_minute }}"
group: "{{ podman_user }}" job: >-
mode: '0755' /usr/bin/podman exec -u www-data {{ container_name }}
php {{ webroot }}/cron.php
- 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
@@ -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
@@ -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
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet # Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, so `systemctl --user` # unit there. This handler runs as the connection user, which is what makes
# works without setting XDG_RUNTIME_DIR. # `systemctl --user` work here.
# #
# Required by postgres/roles/attach, which notifies it whenever this app's # 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 # drop-in changes: a new drop-in changes the generated unit, so Postgres must be
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit # 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 # there. This handler runs as the connection user, which is what makes
# without setting XDG_RUNTIME_DIR. # `systemctl --user` work here.
# #
# Required by valkey/roles/attach, which notifies it whenever this app's drop-in # 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 # changes: a new drop-in changes the generated unit, so Valkey must be recreated
+3 -3
View File
@@ -80,9 +80,9 @@ nextcloud_admin_password: ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Background jobs # Background jobs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
cron_unit: "{{ container_name }}-cron" # Nextcloud wants cron.php every five minutes. Not a variable worth exposing
cron_schedule: "*:0/5" # beyond the interval, since the schedule is the part someone may change.
user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user" cron_minute: "*/5"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Caddy site block # Caddy site block