Add storage keepalive (upholds) across services, Nextcloud cron, disable Nextcloud PHP JIT

Requires= doesn't restart a service once its required mount comes back after
a USB disconnect/remount, so a dedicated storage-keepalive unit now Upholds=
every storage-dependent service to bring it back automatically. Also adds the
missing Nextcloud cron.php timer (background jobs were never triggered), and
disables opcache.jit in the Nextcloud container — PHP 8.4's JIT was
miscompiling OC\Config\UserConfig::getValueBool(), crashing every
session-bootstrapping request with a bogus strtolower() TypeError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Aner Zakobar
2026-10-08 09:47:20 +03:00
parent da020a8dfe
commit c3115026e9
18 changed files with 110 additions and 0 deletions
+24
View File
@@ -222,6 +222,30 @@
};
};
# Nextcloud's "cron" backgroundjobs_mode (config:app:get core backgroundjobs_mode)
# doesn't schedule itself — something external must call cron.php periodically.
# Nothing did, so background jobs (including the News app's feed fetcher)
# went stale. Nextcloud recommends every 5 minutes.
systemd.services.nextcloud-cron = {
description = "Nextcloud background job runner (cron.php)";
after = [ "podman-nextcloud.service" ];
requires = [ "podman-nextcloud.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.podman}/bin/podman exec -u www-data nextcloud php -f cron.php";
};
};
systemd.timers.nextcloud-cron = {
description = "Run Nextcloud cron.php every 5 minutes";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnUnitActiveSec = "5min";
Persistent = true;
};
};
# -------------------------------------------------------------------------
# Local DNS overrides (optional — makes LAN clients hit the Pi directly
# instead of going through Cloudflare for *.zakobar.com)
+2
View File
@@ -103,6 +103,8 @@ in
# -------------------------------------------------------------------------
# Storage directory
# -------------------------------------------------------------------------
homey.storage.upholds = [ "podman-aim-server.service" "podman-aim-ui.service" ];
homey.storage.extraDirs = [
{ path = "aim"; mode = "0755"; }
];
+2
View File
@@ -123,6 +123,8 @@ in
# -----------------------------------------------------------------------
# Storage directories (not backed up — no backup.extraPaths entry)
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-attic.service" ];
homey.storage.extraDirs = [
{ path = "attic"; }
{ path = "attic/cache"; mode = "0755"; }
+2
View File
@@ -252,6 +252,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-authelia.service" ];
homey.storage.extraDirs = [
{ path = "authelia"; }
{ path = "authelia/config"; }
+2
View File
@@ -108,6 +108,8 @@ in
# -------------------------------------------------------------------------
# Storage directories (owned by the service user)
# -------------------------------------------------------------------------
homey.storage.upholds = [ "emulatorjs.service" ];
homey.storage.extraDirs = [
{ path = "emulatorjs"; user = "emulatorjs"; group = "emulatorjs"; }
{ path = "emulatorjs/saves"; user = "emulatorjs"; group = "emulatorjs"; }
+2
View File
@@ -211,6 +211,8 @@ in
# -----------------------------------------------------------------------
# Storage directories (UID 1000 = Gitea's internal user)
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-gitea.service" ];
homey.storage.extraDirs = [
{ path = "gitea"; user = "1000"; group = "1000"; }
{ path = "gitea/data"; user = "1000"; group = "1000"; }
+2
View File
@@ -157,6 +157,8 @@ in
# -------------------------------------------------------------------------
# Storage directories
# -------------------------------------------------------------------------
homey.storage.upholds = [ "podman-home-assistant.service" ];
homey.storage.extraDirs = [
{ path = "home-assistant"; }
{ path = "home-assistant/config"; mode = "0755"; }
+2
View File
@@ -145,6 +145,8 @@ EOF
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-jellyfin.service" ];
homey.storage.extraDirs = [
{ path = "jellyfin"; }
{ path = "jellyfin/config"; }
+2
View File
@@ -113,6 +113,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-mealie.service" ];
homey.storage.extraDirs = [
{ path = "mealie"; }
{ path = "mealie/data"; mode = "0755"; }
+2
View File
@@ -118,6 +118,8 @@ in
# -------------------------------------------------------------------------
# Storage directories on external HD
# -------------------------------------------------------------------------
homey.storage.upholds = [ "podman-mlflow.service" ];
homey.storage.extraDirs = [
{ path = "mlflow"; }
{ path = "mlflow/data"; mode = "0750"; }
+17
View File
@@ -49,6 +49,19 @@ let
MaxConnectionsPerChild 500
</IfModule>
'';
# PHP 8.4's JIT miscompiles OC\Config\UserConfig::getValueBool() on this
# image, corrupting the type of the value passed to strtolower() and
# throwing "Argument #1 ($string) must be of type string, string given"
# on every request that bootstraps a user session (confirmed by disabling
# JIT live on 2026-10-08: the crash stopped immediately). Not an app or
# data bug — getTypedValue()'s `: string` return type and
# decryptSensitiveValue()'s `string &$value` by-ref param both independently
# guarantee the value is already a real string under strict_types=1 by the
# time strtolower() rejects it.
phpDisableJitConfig = pkgs.writeText "zzz-disable-jit.ini" ''
opcache.jit=0
'';
in
{
options.homey.nextcloud = {
@@ -158,6 +171,8 @@ in
"${nextcloudCustomConfig}:/var/www/html/config/zakobar.config.php:ro"
# Apache MPM limits (caps concurrent PHP processes / disk writers)
"${apacheMpmConfig}:/etc/apache2/mods-available/mpm_prefork.conf:ro"
# Disable JIT — see phpDisableJitConfig comment above for why.
"${phpDisableJitConfig}:/usr/local/etc/php/conf.d/zzz-disable-jit.ini:ro"
];
extraOptions = [
@@ -210,6 +225,8 @@ in
# UID 33 = www-data in the Nextcloud container
# UID 999 = postgres — must own the db dir (creates files directly in it)
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-nextcloud-postgres.service" "podman-nextcloud.service" ];
homey.storage.extraDirs = [
{ path = "nextcloud"; }
{ path = "nextcloud/html"; user = "33"; group = "33"; }
+2
View File
@@ -198,6 +198,8 @@ in
# -----------------------------------------------------------------------
# Storage directories (owned by the ntfy-sh system user)
# -----------------------------------------------------------------------
homey.storage.upholds = [ "ntfy-sh.service" ];
homey.storage.extraDirs = [
{ path = "ntfy"; user = "ntfy-sh"; group = "ntfy-sh"; }
{ path = "ntfy/attachments"; user = "ntfy-sh"; group = "ntfy-sh"; }
+2
View File
@@ -117,6 +117,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-openldap.service" ];
homey.storage.extraDirs = [
{ path = "openldap"; }
{ path = "openldap/etc-ldap-slapd.d"; }
+2
View File
@@ -152,6 +152,8 @@ in
# -----------------------------------------------------------------------
# Storage directories (UID 1000 = USERMAP_UID in container)
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-paperless-redis.service" "podman-paperless.service" ];
homey.storage.extraDirs = [
{ path = "paperless"; }
{ path = "paperless/data"; user = "1000"; group = "1000"; }
+2
View File
@@ -72,6 +72,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-stirling-pdf.service" ];
homey.storage.extraDirs = [
{ path = "stirling-pdf"; }
{ path = "stirling-pdf/configs"; mode = "0755"; }
+2
View File
@@ -87,6 +87,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-transmission.service" ];
homey.storage.extraDirs = [
{ path = "transmission"; user = "1000"; group = "1000"; mode = "0755"; }
{ path = "transmission/config"; user = "1000"; group = "1000"; mode = "0755"; }
+2
View File
@@ -305,6 +305,8 @@ in
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-uptime-kuma.service" ];
homey.storage.extraDirs = [
{ path = "uptime-kuma"; }
];
+39
View File
@@ -79,6 +79,28 @@ in
default = [];
description = "Per-service directories to create under mountPoint. Each service module contributes its own entries.";
};
upholds = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = ''
Systemd unit names (e.g. "podman-openldap.service") that must come
back up on their own after the external drive remounts.
These units already have Requires=mnt-data.mount, so they correctly
stop when the drive disappears. The problem is the reverse direction:
a plain Requires= dependency does NOT restart when the required unit
becomes available again — systemd treats a Requires-propagated stop
the same as a deliberate "systemctl stop", so Restart= policies on
the unit itself never fire either. Verified empirically on 2026-10-02
after a USB disconnect/remount left 11 services dead for ~4 hours.
The fix is a dedicated keepalive unit bound to the mount (below) that
Upholds= every unit listed here — Upholds continuously (re)starts its
targets for as long as the upholding unit is active, which is exactly
the "come back when the mount comes back" behavior Requires= lacks.
'';
};
};
config = lib.mkIf (cfg.device != "") {
@@ -95,6 +117,23 @@ in
];
};
# See the `upholds` option doc above for why this unit exists.
systemd.services.storage-keepalive =
let
mountUnit = "${lib.replaceStrings [ "/" ] [ "-" ] (lib.removePrefix "/" cfg.mountPoint)}.mount";
in {
description = "Keeps storage-dependent services running whenever ${cfg.mountPoint} is mounted";
bindsTo = [ mountUnit ];
after = [ mountUnit ];
wantedBy = [ mountUnit ];
unitConfig.Upholds = cfg.upholds;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pkgs.coreutils}/bin/true";
};
};
# Mount point root + shared infrastructure dirs (restic cache, shared media).
# Per-service dirs are contributed via homey.storage.extraDirs by each
# service module, so adding a new service only requires editing that module.