Mount PHP's session config, the one path the entrypoint cannot write

The real first run of this play did not fail on a transition. The journal shows
the entrypoint dying on every attempt:

    nextcloud[302063]: => Configuring PHP session handler...
    nextcloud[302063]: /entrypoint.sh: 121: cannot create
      /usr/local/etc/php/conf.d/redis-session.ini: Permission denied

With REDIS_HOST set, the entrypoint generates PHP's session-handler config on
every start, and the target directory belongs to the image's root: the php base
image does `mkdir -p "$PHP_INI_DIR/conf.d"` with no chown. The uid this container
runs as has no write bit on it, the entrypoint exits 2 before Apache is reached,
Restart=always retries that, and systemd's start limit turns it into a `failed`
unit. Nothing in that shape says "permissions": the symptom is a unit state and a
message that only exists in journalctl.

The container now mounts a host-owned file over that exact path. A bind-mounted
file replaces the directory entry, so the directory's ownership stops mattering
and the write lands on podman_user. The role templates the five lines the
entrypoint would have written, so a refused write is no longer fatal either --
PHP still gets Redis sessions instead of a crash loop.

This also corrects a reading of the previous run that this play got wrong and
documented wrongly: `activating` is not evidence of a healthy start. A
crash-looping container spends its time `activating` between restarts, so the
earlier "reports a healthy start as a failure" comment and commit message were
describing a start that was in fact failing. The wait is still right -- it stops
promptly at a terminal state and hands over to a rescue that has the logs -- but
its comment now says what it actually buys.

Apache's own runtime directories need none of this, and the php base image is why:
it chowns /var/run/apache2, /var/lock/apache2 and /var/log/apache2 to www-data and
chmods them 1777, so only the session file was ever going to bite. The readme
gains a section on it, the diagnostic commands the play now collects, and a note
that the uid mapping is not to be derived from a `docker exec id -u` of 0.
This commit is contained in:
tanshu committed 2026-10-16 11:45:37 +05:30
1 parent 90b1149413
commit 1038037e45
5 files changed
+209 -31

No files matched your search

+51 -14
View File
@@ -77,6 +77,43 @@
mode: '0600'
notify: Restart Nextcloud
# The second path the entrypoint writes, and the one that made this play fail for
# real: with REDIS_HOST set, the entrypoint generates PHP's session-handler config
# at {{ php_session_ini_mount }} on every start, and that path's directory in the
# php base image is
#
# RUN mkdir -p "$PHP_INI_DIR/conf.d"
#
# — created by root, never chowned. The uid this container runs as therefore has
# no write bit on the directory, the entrypoint dies with
#
# /entrypoint.sh: 121: cannot create
# /usr/local/etc/php/conf.d/redis-session.ini: Permission denied
#
# and `Restart=always` turns that into a crash loop that systemd eventually marks
# `failed`. The Quadlet unit mounts this file over that exact path, which is what
# gives the write somewhere it is allowed: the mount replaces the directory entry,
# so the directory's ownership stops mattering.
#
# The file is templated rather than left empty so that a start in which the
# entrypoint's write is refused still has working Redis sessions.
- name: Ensure the PHP session config directory exists
ansible.builtin.file:
path: "{{ php_conf_dir }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: Write the PHP session handler config
ansible.builtin.template:
src: redis-session.ini.j2
dest: "{{ php_session_ini }}"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: Restart Nextcloud
- name: Install the Nextcloud Quadlet unit
ansible.builtin.template:
src: nextcloud.container.j2
@@ -101,22 +138,22 @@
# `systemctl --user enable` is neither needed nor possible on a generated
# unit.
#
# This waits rather than checking once. `systemctl start` returns while the
# unit is still `activating` — the start job is done before the container has
# settled — and `is-active` exits 3 for `activating`, so a single check
# reports a healthy start as a failure. That is what this play's first real
# run did, stopping with `stdout: "activating"`.
# This waits rather than checking once, because `systemctl start` returns
# while the unit is still `activating` and `is-active` exits 3 for that state.
#
# It retries only through that transition. A unit that is `failed` or
# `inactive` is not going to become `active` by waiting, so it stops there
# and lets the rescue below get the reason — which is how the second run
# found out the container was crash-looping rather than merely slow. The case
# that genuinely needs patience, the app not answering yet, is the readiness
# gate further down, which has its own retries.
# `activating` is NOT evidence that a start is healthy, which this play got
# wrong once and should not again: a container that crash-loops spends its
# time `activating` between `Restart=always` attempts, and the first run here
# was read as a healthy start mid-transition when the entrypoint was in fact
# dying on every attempt (it could not write
# /usr/local/etc/php/conf.d/redis-session.ini — see the task that mounts that
# file). What this wait actually buys is a prompt, bounded stop: it retries
# only while the state can still change on its own, and a terminal `failed`
# or `inactive` falls straight through to the rescue below, which has the logs.
#
# Keeping this inside the block matters: an is-active check outside it fails
# with a bare state and no diagnostics, which is exactly the dead end the
# second run hit.
# Keeping it inside the block matters: an is-active check outside it fails with
# a bare state and no diagnostics, which is the dead end the second run hit
# before this was moved in.
- name: Wait for the Nextcloud unit to become active
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: nextcloud_active
@@ -45,6 +45,19 @@ Network={{ network_name }}.network
# migration copies the tree rather than only the state. See readme.md.
Volume={{ host_directory }}:{{ webroot }}
# PHP's session-handler config. The entrypoint writes this file itself on every
# start, from REDIS_HOST, but it cannot create it: `/usr/local/etc/php/conf.d` is
# created by root in the php base image with no chown, so the uid this container
# runs as has no write bit on it. Mounting a host-owned file over the path is what
# makes that write land somewhere allowed — and because the mounted file replaces
# the directory entry, the directory's ownership never comes into it.
#
# This is the one place the entrypoint writes outside the webroot. Apache's own
# runtime directories (/var/run/apache2, /var/lock/apache2, /var/log/apache2) do
# not need the same treatment: the php base image chowns them to www-data and
# chmods them 1777.
Volume={{ php_session_ini }}:{{ php_session_ini_mount }}
[Service]
Restart=always
# The entrypoint rsyncs the image's code into the webroot and, if the copied
@@ -0,0 +1,18 @@
; PHP session handler for Redis.
; Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand.
;
; The nextcloud image writes this file itself on every start — it is how
; REDIS_HOST reaches PHP — and this file exists because that write has nowhere to
; land on its own: the image's /usr/local/etc/php/conf.d belongs to root and is
; not writable by the uid this container runs as, which makes the entrypoint exit
; before Apache ever starts. See readme.md, "The PHP session file".
;
; The contents are what the entrypoint generates for a Redis host with no
; password, so a start in which the entrypoint's own write is refused still gets
; working Redis sessions — and the entrypoint overwrites this file with its own
; version whenever it can.
session.save_handler = redis
session.save_path = "tcp://{{ redis_host }}:6379"
redis.session.locking_enabled = 1
redis.session.lock_retries = -1
redis.session.lock_wait_time = 10000