Files
Sovran_SystemsOS/modules/nextcloud.nix
T
Sovran Contributor 09d4cc9b83 nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
Nextcloud 35's Database checks flag three Performance issues out of the
box: buffer cache hit ratio ~96% (wants 99%+), 100k+ dead tuples, and
million-plus sequential scans on oc_mail_tags / oc_guests_users.

Root causes in Sovran: stock 128MB shared_buffers, stock 60s autovacuum
naptime, APCu file locking, and db:add-missing-indices running exactly
once at install time (never on upgrades or app installs).

Size Postgres for the README's Server + Desktop recommendation (32 GB
RAM, NVMe): 2GB shared_buffers, 12GB effective_cache_size, 512MB
maintenance_work_mem, 32MB work_mem, 4GB max_wal_size, 30s autovacuum
naptime with 4 workers. shared_buffers stays below the 25% rule because
Postgres shares the box with bitcoind, Electrs, LND, MariaDB and PHP-FPM.

Scope the aggressive autovacuum to nextclouddb via ALTER DATABASE so the
shared matrix-synapse DB keeps the milder cluster defaults.

Add a local Redis (127.0.0.1:6379, Nextcloud only) and move
memcache.distributed/locking to Redis; migrate existing installs with a
one-shot since nextcloud-init never re-runs.

Add a weekly nextcloud-db-maintenance timer (VACUUM ANALYZE +
db:add-missing-*) so upgrades and later app installs can't regress the
checks again.

Note: shared_buffers needs one 'systemctl restart postgresql', which
briefly takes down both Nextcloud and Matrix. Everything else is
reload-only or scoped to nextclouddb.
2026-09-21 13:59:52 -05:00

394 lines
15 KiB
Nix
Executable File

{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.services.nextcloud {
# ── PostgreSQL database ───────────────────────────────────
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
# configuration.nix so it is shared with Matrix Synapse.
services.postgresql = {
enable = true;
};
# ── Redis for Nextcloud distributed cache + file locking ───
# Nextcloud does not recommend APCu for memcache.locking in production.
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
services.redis.servers.nextcloud = {
enable = true;
bind = "127.0.0.1";
port = 6379;
};
# ── Auto-generate DB password and initialize ──────────────
systemd.services.nextcloud-db-init = {
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
before = [ "nextcloud-init.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ config.services.postgresql.package pkgs.pwgen pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/nextclouddb"
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
pwgen -s 64 1 > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
fi
DB_PASS=$(cat "$SECRET_FILE")
psql -U postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'ncusr') THEN
CREATE ROLE "ncusr" WITH LOGIN PASSWORD '$DB_PASS';
ELSE
ALTER ROLE "ncusr" WITH LOGIN PASSWORD '$DB_PASS';
END IF;
END
\$\$;
SQL
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
fi
# Per-database autovacuum, scoped to nextclouddb only.
# The shared matrix-synapse DB keeps the milder cluster defaults.
# Fixes Nextcloud 35 pg.dead_tuples warning. Idempotent.
psql -U postgres -d nextclouddb -c "ALTER DATABASE nextclouddb SET autovacuum_vacuum_scale_factor = '0.05';"
psql -U postgres -d nextclouddb -c "ALTER DATABASE nextclouddb SET autovacuum_analyze_scale_factor = '0.025';"
'';
};
# ── Fully automated Nextcloud setup ───────────────────────
systemd.services.nextcloud-init = {
description = "Download, extract, and fully configure Nextcloud";
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
wants = [ "network-online.target" "redis-nextcloud.service" ];
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = "!/var/lib/www/nextcloud/config/config.php";
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ curl unzip php pwgen coreutils shadow util-linux ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
DATA_DIR="/var/lib/nextcloud"
DOMAIN=$(cat /var/lib/domains/nextcloud)
DB_NAME="nextclouddb"
DB_USER="ncusr"
DB_PASS=$(cat /var/lib/secrets/nextclouddb)
DB_HOST="localhost"
ADMIN_USER=$(pwgen -s 16 1)
ADMIN_PASS=$(pwgen -s 24 1)
SERVER_ID=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
if [ -z "$SERVER_ID" ]; then
echo "Failed to generate Nextcloud server_id"
exit 1
fi
echo "══════════════════════════════════════════════"
echo " Nextcloud Automated Installation"
echo "══════════════════════════════════════════════"
if [ ! -f "$INSTALL_DIR/occ" ]; then
echo "Downloading Nextcloud..."
TEMP_DIR=$(mktemp -d)
curl -L -o "$TEMP_DIR/nextcloud.zip" "https://download.nextcloud.com/server/releases/latest.zip"
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
mkdir -p "$INSTALL_DIR"
cp -a "$TEMP_DIR/nextcloud/." "$INSTALL_DIR/"
rm -rf "$TEMP_DIR"
echo "Download complete."
fi
chown -R caddy:php "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
chmod -R 770 "$INSTALL_DIR/apps"
chmod -R 770 "$INSTALL_DIR/config"
if [ ! -d "$DATA_DIR" ]; then
mkdir -p "$DATA_DIR"
chown -R caddy:php "$DATA_DIR"
chmod -R 770 "$DATA_DIR"
fi
echo "Waiting for PostgreSQL..."
for i in $(seq 1 30); do
if /run/wrappers/bin/su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then
echo "Database ready."
break
fi
sleep 2
done
echo "Running Nextcloud installation..."
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ maintenance:install \
--database 'pgsql' \
--database-name '$DB_NAME' \
--database-user '$DB_USER' \
--database-pass '$DB_PASS' \
--database-host '$DB_HOST' \
--admin-user '$ADMIN_USER' \
--admin-pass '$ADMIN_PASS' \
--data-dir '$DATA_DIR'
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwritehost --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https'
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_proxies 0 --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set trusted_proxies 1 --value='::1'
php $INSTALL_DIR/occ config:system:set forwarded_for_headers 0 --value='HTTP_X_FORWARDED_FOR'
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
php $INSTALL_DIR/occ background:cron
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ integrity:check-core
php $INSTALL_DIR/occ maintenance:repair
php $INSTALL_DIR/occ db:add-missing-indices
php $INSTALL_DIR/occ db:add-missing-columns
php $INSTALL_DIR/occ db:add-missing-primary-keys
php $INSTALL_DIR/occ maintenance:repair --include-expensive
# AppAPI deploy daemon warnings are avoided by disabling app_api when present.
if php $INSTALL_DIR/occ app:info app_api >/dev/null 2>&1; then
php $INSTALL_DIR/occ app:disable app_api
fi
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ app:install calendar || true
php $INSTALL_DIR/occ app:install contacts || true
php $INSTALL_DIR/occ app:install tasks || true
php $INSTALL_DIR/occ app:install notes || true
php $INSTALL_DIR/occ app:install deck || true
php $INSTALL_DIR/occ app:enable calendar || true
php $INSTALL_DIR/occ app:enable contacts || true
php $INSTALL_DIR/occ app:enable tasks || true
php $INSTALL_DIR/occ app:enable notes || true
php $INSTALL_DIR/occ app:enable deck || true
"
CREDS_FILE="/var/lib/secrets/nextcloud-admin"
cat > "$CREDS_FILE" << CREDS
Nextcloud Admin Credentials
═══════════════════════════
URL: https://$DOMAIN/
Username: $ADMIN_USER
Password: $ADMIN_PASS
CREDS
chmod 600 "$CREDS_FILE"
echo ""
echo "══════════════════════════════════════════════"
echo " Nextcloud installation complete!"
echo " Credentials saved to: $CREDS_FILE"
echo "══════════════════════════════════════════════"
'';
};
systemd.services.nextcloud-detect-existing = {
description = "Detect pre-existing Nextcloud installation and populate hub credentials";
after = [ "postgresql.service" ];
wants = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/config/config.php"
"!/var/lib/secrets/nextcloud-admin"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils gnused ];
script = ''
set -euo pipefail
CREDS_FILE="/var/lib/secrets/nextcloud-admin"
DOMAIN_FILE="/var/lib/domains/nextcloud"
DOMAIN="your-domain"
if [ -f "$DOMAIN_FILE" ]; then
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
if [ -n "$FILE_DOMAIN" ]; then
DOMAIN="$FILE_DOMAIN"
fi
fi
mkdir -p /var/lib/secrets
cat > "$CREDS_FILE" << CREDS
Nextcloud (Pre-existing Installation)
═══════════════════════════════════════
URL: https://$DOMAIN/
Note: This Nextcloud was installed before Sovran_SystemsOS.
Use your existing admin credentials to log in.
Reset: sudo -u caddy php /var/lib/www/nextcloud/occ user:resetpassword <username>
CREDS
chmod 600 "$CREDS_FILE"
'';
};
# ── Migrate existing installs to Redis locking ────────────
# nextcloud-init only runs on fresh installs (ConditionPathExists
# !config.php), so pre-existing / pre-Sovran installs would keep
# APCu locking forever. This one-shot is idempotent and safe to
# re-run on every boot — occ just overwrites the same values.
systemd.services.nextcloud-redis-migrate = {
description = "Point existing Nextcloud installs at Redis locking";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "redis-nextcloud.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
# Wait briefly for Redis (TCP localhost:6379).
for i in $(seq 1 15); do
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
break
fi
sleep 2
done
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
"
'';
};
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
# Scoped to nextclouddb only — matrix-synapse is untouched.
systemd.services.nextcloud-db-maintenance = {
description = "Nextcloud DB maintenance: VACUUM + missing indices";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "postgresql.service" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
};
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
echo "Vacuuming nextclouddb..."
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
echo "Backfilling Nextcloud indices..."
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ db:add-missing-indices
php $INSTALL_DIR/occ db:add-missing-columns
php $INSTALL_DIR/occ db:add-missing-primary-keys
"
echo "Nextcloud DB maintenance complete."
'';
};
systemd.timers.nextcloud-db-maintenance = {
description = "Weekly Nextcloud DB maintenance";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Sun 03:30";
Persistent = true;
RandomizedDelaySec = "30m";
};
};
services.cron.systemCronJobs = [
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
];
systemd.tmpfiles.rules = [
"d /var/lib/www 0755 caddy php -"
"d /var/lib/www/nextcloud 0750 caddy php -"
"d /var/lib/nextcloud 0770 caddy php -"
];
services.phpfpm.pools.nextcloud = {
user = "caddy";
group = "php";
phpPackage = config.sovran_systemsOS.phpPackage;
phpOptions = lib.mkAfter ''
output_buffering = 0
'';
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
"listen" = "/run/phpfpm/nextcloud.sock";
};
};
environment.systemPackages = with pkgs; [ unzip ];
sovran_systemsOS.domainRequirements = [
{ name = "nextcloud"; label = "Nextcloud"; example = "cloud.yourdomain.com"; }
];
}