Files
homey/modules/services/nextcloud.nix
T
Aner Zakobar c3115026e9 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>
2026-10-08 09:47:20 +03:00

277 lines
11 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{ config, lib, pkgs, homeyConfig, ... }:
# Nextcloud + PostgreSQL.
#
# Two containers:
# nextcloud-postgres — PostgreSQL, bound to localhost:5432
# nextcloud — Nextcloud PHP-FPM + Apache, bound to localhost:8080
#
# Volume layout:
# <dataDir>/nextcloud/db/ → /var/lib/postgresql/data (postgres)
# <dataDir>/nextcloud/html/ → /var/www/html (nextcloud)
#
# Secrets consumed from sops:
# nextcloud/admin_password
# nextcloud/postgres_password
let
cfg = config.homey.nextcloud;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# Custom Nextcloud config mounted into the container as an extra config file.
# Nextcloud auto-loads all *.config.php files in /var/www/html/config/.
nextcloudCustomConfig = pkgs.writeText "zakobar.config.php" ''
<?php
$CONFIG = [
// Throttle preview generation during bulk uploads.
// Generating thumbnails re-reads every uploaded file and writes preview
// files, roughly doubling disk I/O. Limiting concurrency to 1 prevents
// the drive from being hit by simultaneous read+write storms.
'preview_concurrency_new' => 1,
'preview_concurrency_all' => 1,
// Cap preview dimensions to reduce per-preview write size.
'preview_max_x' => 1024,
'preview_max_y' => 1024,
'jpeg_quality' => 75,
];
'';
# Limit Apache's prefork MPM so at most 4 PHP processes write to the USB
# drive simultaneously. Default is often 150, which causes an I/O storm
# on slow USB HDDs. Lower = fewer concurrent writers = more stable I/O.
apacheMpmConfig = pkgs.writeText "mpm_prefork.conf" ''
<IfModule mpm_prefork_module>
StartServers 2
MinSpareServers 1
MaxSpareServers 3
MaxRequestWorkers 4
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 = {
enable = lib.mkEnableOption "Nextcloud file server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/nextcloud:latest";
};
postgresImage = lib.mkOption {
type = lib.types.str;
default = "docker.io/postgres:16";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Host port Nextcloud listens on (bound to 127.0.0.1).";
};
postgresPort = lib.mkOption {
type = lib.types.port;
default = 5432;
description = "Host port PostgreSQL listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."nextcloud/admin_password" = { owner = "root"; };
sops.secrets."nextcloud/postgres_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# PostgreSQL container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud-postgres = {
image = cfg.postgresImage;
# Exposed on localhost for debugging; nextcloud reaches it via the
# container name "nextcloud-postgres" on the homey network.
ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ];
environment = {
POSTGRES_DB = "nextcloud_db";
POSTGRES_USER = "postgres";
# Password injected via env file
};
volumes = [
"${dataDir}/nextcloud/db:/var/lib/postgresql/data"
];
extraOptions = [
"--network=homey"
"--env-file=/run/nc-postgres-secrets.env"
];
};
systemd.services."podman-nextcloud-postgres" = {
serviceConfig = {
LoadCredential = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-postgres-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-postgres-secrets.env
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" \
>> /run/nc-postgres-secrets.env
'')
];
};
postStop = "rm -f /run/nc-postgres-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Nextcloud container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud = {
image = cfg.image;
# Apache inside the container listens on port 80; map it to cfg.port on
# the host so Caddy can reach it. Postgres is reachable by container name.
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
environment = {
POSTGRES_HOST = "nextcloud-postgres";
POSTGRES_DB = "nextcloud_db";
POSTGRES_USER = "postgres";
NEXTCLOUD_ADMIN_USER = "admin";
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
OVERWRITEPROTOCOL = "https";
OVERWRITECLIURL = "https://nextcloud.${domain}";
OVERWRITEHOST = "nextcloud.${domain}";
# Trust the reverse proxy (Caddy on the host reaches the container
# via the podman bridge; cover all RFC-1918 ranges to be robust).
TRUSTED_PROXIES = "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.1 ::1";
# Passwords injected via env file
};
volumes = [
"${dataDir}/nextcloud/html:/var/www/html"
# Extra config auto-loaded by Nextcloud (throttles preview generation)
"${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 = [
"--network=homey"
"--env-file=/run/nc-secrets.env"
];
};
# -----------------------------------------------------------------------
# Authelia access control — one_factor; Nextcloud manages its own login UI.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 55;
domain = [ "nextcloud.${domain}" ];
policy = "one_factor";
}];
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Nextcloud manages its own auth
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "nextcloud";
port = cfg.port;
auth = false;
extraConfig = ''
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
request_body {
max_size 5GB
}
reverse_proxy localhost:${toString cfg.port} {
header_up X-Forwarded-For {remote_host}
}
'';
extraHttpConfig = ''
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
request_body {
max_size 5GB
}
reverse_proxy localhost:${toString cfg.port} {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
}
'';
}];
# -----------------------------------------------------------------------
# Storage directories
# 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"; }
{ path = "nextcloud/db"; mode = "0700"; user = "999"; group = "999"; }
{ path = "nextcloud/db-dump"; mode = "0700"; }
];
# -----------------------------------------------------------------------
# Backup — exclude raw DB dir (pg_dump file in db-dump/ is used instead)
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/nextcloud" ];
homey.backup.extraExcludePaths = [ "${dataDir}/nextcloud/db" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Nextcloud";
url = "https://nextcloud.${domain}/status.php";
interval = 60;
keyword = "\"maintenance\":false";
# Nightly maintenance is expected — only alert if stuck for 4+ hours.
# 240 retries × 60s = 4 hours of consecutive failures before notifying.
maxretries = 240;
}];
systemd.services."podman-nextcloud" = {
serviceConfig = {
LoadCredential = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
"nextcloud_admin_password:${config.sops.secrets."nextcloud/admin_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-secrets.env
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" >> /run/nc-secrets.env
echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_admin_password")" >> /run/nc-secrets.env
'')
];
};
postStop = "rm -f /run/nc-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
};
};
}