Port to NixOS: replace Helm chart with flake-based NixOS config

Replaces the Helm/k3s setup with a declarative NixOS configuration targeting
a Raspberry Pi 4. Services run as podman containers under systemd, with data
on an external HD at /mnt/data. Key components:

- flake.nix: multi-host flake with pi-main (aarch64) and a placeholder for a
  second machine
- modules/common.nix: shared system config (nix, podman, sops, SSH)
- modules/storage.nix: external HD mount with per-service subdirs
- modules/caddy.nix: Caddy with cloudflare DNS-01 ACME + authelia forward_auth
- modules/cloudflared.nix: Cloudflare tunnel for remote access
- modules/backup.nix: restic daily backups with NC maintenance mode pre-hook
- modules/services/{openldap,authelia,gitea,nextcloud,phpldapadmin}.nix: core services
- modules/services/{jellyfin,transmission}.nix: media services (disabled by default)
- secrets/: sops-nix scaffold with .sops.yaml age key config
- hosts/pi-main/: hardware config + service selection for the Pi
- PORTING.md: step-by-step migration guide (SD card → data restore → verify)
This commit is contained in:
Aner Zakobar
2026-04-15 17:18:12 +03:00
parent d1948df47e
commit 2f0d0b5e4c
59 changed files with 2173 additions and 4666 deletions
+150
View File
@@ -0,0 +1,150 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Restic backup module.
#
# Backs up all service data directories from the external HD.
# Schedule: daily at 03:00, keep 7 daily / 4 weekly / 6 monthly snapshots.
#
# Before a backup, Nextcloud is put into maintenance mode and postgres is
# pg_dump'd to a file. This ensures consistent DB backups.
#
# Secrets consumed from sops:
# restic/password
#
# The backup repository URL is set per-host in default.nix:
# homey.backup.repository = "sftp:user@nas:/backups/homey";
#
# Restore:
# restic -r <repo> restore latest --target /mnt/data
# (or restore a single path: --include /mnt/data/openldap)
let
cfg = config.homey.backup;
dataDir = config.homey.storage.mountPoint;
in
{
options.homey.backup = {
enable = lib.mkEnableOption "Restic backup jobs";
repository = lib.mkOption {
type = lib.types.str;
example = "sftp:user@nas.local:/backups/homey";
description = ''
Restic repository URL. Examples:
sftp:user@host:/path
b2:bucket-name:prefix
rclone:remote:path
/local/path (for testing)
'';
};
schedule = lib.mkOption {
type = lib.types.str;
default = "03:00";
description = "systemd OnCalendar expression for the daily backup.";
};
pruneRetention = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {
daily = "7";
weekly = "4";
monthly = "6";
};
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."restic/password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Pre-backup hook: pg_dump + nextcloud maintenance mode
# -----------------------------------------------------------------------
systemd.services."homey-backup-pre" = {
description = "Pre-backup hooks (pg_dump, NC maintenance mode)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "backup-pre" ''
set -euo pipefail
# Put Nextcloud into maintenance mode (if running)
if systemctl is-active --quiet podman-nextcloud.service; then
podman exec nextcloud php occ maintenance:mode --on || true
fi
# Dump postgres (if running)
if systemctl is-active --quiet podman-nextcloud-postgres.service; then
install -d -m 700 ${dataDir}/nextcloud/db-dump
podman exec nextcloud-postgres \
pg_dump -U postgres nextcloud_db \
> ${dataDir}/nextcloud/db-dump/nextcloud.sql
fi
'';
};
};
systemd.services."homey-backup-post" = {
description = "Post-backup hooks (take NC out of maintenance mode)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "backup-post" ''
set -euo pipefail
if systemctl is-active --quiet podman-nextcloud.service; then
podman exec nextcloud php occ maintenance:mode --off || true
fi
'';
};
};
# -----------------------------------------------------------------------
# Restic backup service
# -----------------------------------------------------------------------
services.restic.backups.homey = {
repository = cfg.repository;
passwordFile = config.sops.secrets."restic/password".path;
cacheDir = "${dataDir}/restic-cache";
paths = [
"${dataDir}/openldap"
"${dataDir}/authelia"
"${dataDir}/gitea"
"${dataDir}/nextcloud"
# media and transmission config included when those services are enabled:
"${dataDir}/jellyfin"
"${dataDir}/transmission"
# Deliberately excluded: media/* (large, can be re-downloaded)
];
# Exclude Nextcloud's raw DB directory in favour of the pg_dump file
exclude = [
"${dataDir}/nextcloud/db"
"${dataDir}/restic-cache"
];
timerConfig = {
OnCalendar = cfg.schedule;
Persistent = true; # run on next boot if missed
};
pruneOpts = [
"--keep-daily ${cfg.pruneRetention.daily}"
"--keep-weekly ${cfg.pruneRetention.weekly}"
"--keep-monthly ${cfg.pruneRetention.monthly}"
];
};
# Wire the pre/post hooks around the restic job
systemd.services."restic-backups-homey" = {
requires = [ "homey-backup-pre.service" ];
after = [ "homey-backup-pre.service" ];
};
systemd.services."homey-backup-post" = {
after = [ "restic-backups-homey.service" ];
wantedBy = [ "restic-backups-homey.service" ];
};
};
}
+185
View File
@@ -0,0 +1,185 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Caddy reverse proxy.
#
# Features:
# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.home.zakobar.com
# - forward_auth to Authelia for protected vhosts
# - Plain reverse_proxy for public vhosts (authelia itself, nextcloud)
# - Listens on :80 (redirect) and :443 (TLS)
#
# Because nixpkgs ships Caddy without the cloudflare DNS plugin by default,
# we build a custom Caddy with it using the xcaddy wrapper from nixpkgs.
#
# Secrets consumed from sops:
# cloudflare/api_token
let
cfg = config.homey.caddy;
domain = homeyConfig.domain;
# Build Caddy with the Cloudflare DNS plugin.
# This compiles on the Pi (slow once, cached after).
caddyWithCloudflare = pkgs.caddy.override {
externalPlugins = [
{
name = "github.com/caddy-dns/cloudflare";
version = "89f16b99c18ef49c8bb470a82f895bce01cbaece";
}
];
vendorHash = lib.fakeHash; # replace with real hash after first build
};
# Reusable Authelia forward_auth snippet
# Returns a Caddyfile snippet block that applies forward_auth.
# copy_headers makes Authelia's Remote-* headers available downstream.
autheliaForwardAuth = ''
forward_auth localhost:9091 {
uri /api/verify?rd=https://auth.${domain}
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
# On auth failure, redirect to the authelia login page
@goauth status 401
handle_response @goauth {
redir https://auth.${domain}?rm={method} 302
}
}
'';
in
{
options.homey.caddy = {
enable = lib.mkEnableOption "Caddy reverse proxy";
acmeEmail = lib.mkOption {
type = lib.types.str;
default = "admin@zakobar.com";
description = "Email for Let's Encrypt ACME registration.";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."cloudflare/api_token" = {
owner = config.services.caddy.user;
};
# -----------------------------------------------------------------------
# Caddy service
# -----------------------------------------------------------------------
services.caddy = {
enable = true;
package = caddyWithCloudflare;
# Global options
globalConfig = ''
email ${cfg.acmeEmail}
# Use Cloudflare DNS-01 challenge for wildcard cert
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
'';
# Each virtual host
virtualHosts = {
# ------------------------------------------------------------------
# Authelia — public, no auth gate (it IS the auth gate)
# ------------------------------------------------------------------
"auth.${domain}" = {
extraConfig = ''
reverse_proxy localhost:9091
'';
};
# ------------------------------------------------------------------
# Gitea — protected behind one_factor Authelia
# ------------------------------------------------------------------
"git.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:3000
'';
};
# ------------------------------------------------------------------
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
# Authelia is not gating nextcloud directly because NC has its own
# login flow. We still want HTTPS.
# ------------------------------------------------------------------
"nextcloud.${domain}" = {
extraConfig = ''
# Redirect CardDAV/CalDAV discovery
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
# Large uploads (5 GB)
request_body {
max_size 5GB
}
reverse_proxy localhost:8080
'';
};
# ------------------------------------------------------------------
# phpLDAPadmin — two_factor, admins only (enforced by authelia policy)
# ------------------------------------------------------------------
"ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:8081
'';
};
# ------------------------------------------------------------------
# Jellyfin — one_factor (added when enabled)
# ------------------------------------------------------------------
"jellyfin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:8096
'';
};
# ------------------------------------------------------------------
# Transmission — two_factor, admins only (enforced by authelia policy)
# ------------------------------------------------------------------
"torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:9091_transmission
'';
# NOTE: transmission uses 9091 too; we'll bind it to 9092 in its
# module to avoid a clash with authelia.
};
};
};
# -----------------------------------------------------------------------
# Pass Cloudflare token as env var to the caddy systemd unit
# -----------------------------------------------------------------------
systemd.services.caddy = {
serviceConfig = {
EnvironmentFile = pkgs.writeText "caddy-cf-env"
"CLOUDFLARE_API_TOKEN_FILE=${config.sops.secrets."cloudflare/api_token".path}";
# Caddy supports _FILE suffix for env vars via its secret file reader,
# but cloudflare plugin reads CLOUDFLARE_API_TOKEN directly.
# We write a wrapper ExecStartPre to populate the env var from the file:
ExecStartPre = [
(pkgs.writeShellScript "caddy-inject-cf-token" ''
export CLOUDFLARE_API_TOKEN=$(cat ${config.sops.secrets."cloudflare/api_token".path})
systemctl set-environment CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN"
'')
];
};
after = lib.mkAfter [ "podman-authelia.service" ];
wants = lib.mkAfter [ "podman-authelia.service" ];
};
# -----------------------------------------------------------------------
# Firewall — open HTTP + HTTPS (already in common.nix, explicit here too)
# -----------------------------------------------------------------------
networking.firewall.allowedTCPPorts = [ 80 443 ];
};
}
+77
View File
@@ -0,0 +1,77 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Cloudflare Tunnel (cloudflared) — remote access without open inbound ports.
#
# Architecture:
# Internet → Cloudflare edge → cloudflared tunnel (outbound from Pi)
# → Caddy on localhost → service containers
#
# The tunnel is configured to route each hostname to Caddy's HTTPS listener.
# Caddy handles TLS and forward_auth; cloudflared just carries the traffic.
#
# Setup steps (one-time, done from the Cloudflare dashboard):
# 1. Go to Zero Trust → Networks → Tunnels → Create a tunnel
# 2. Name it (e.g. "pi-main")
# 3. Copy the tunnel token — add it to secrets.yaml as cloudflare/tunnel_token
# 4. In the tunnel's "Public Hostnames" config, add routes:
# auth.home.zakobar.com → http://localhost:80 (or https://localhost:443)
# git.home.zakobar.com → https://localhost:443
# nextcloud.home.zakobar.com → https://localhost:443
# ldapadmin.home.zakobar.com → https://localhost:443
# jellyfin.home.zakobar.com → https://localhost:443
# torrent.home.zakobar.com → https://localhost:443
# Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but
# the hostname seen by cloudflared is localhost, so hostname verification
# would fail without this flag).
#
# The tunnel_token approach (--token) is the simplest: one secret, no config
# file needed on the Pi.
#
# Secrets consumed from sops:
# cloudflare/tunnel_token
let
cfg = config.homey.cloudflared;
in
{
options.homey.cloudflared = {
enable = lib.mkEnableOption "Cloudflare Tunnel for remote access";
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."cloudflare/tunnel_token" = { owner = "cloudflared"; };
# -----------------------------------------------------------------------
# cloudflared service
# NixOS 24.11 ships services.cloudflared natively.
# -----------------------------------------------------------------------
services.cloudflared = {
enable = true;
tunnels = {
"pi-main" = {
# credentialsFile is not used with token-based auth;
# the token is passed via environment variable instead.
# We override the systemd unit below to inject it.
default = "http_status:404";
};
};
};
# Inject the tunnel token from the sops secret file
systemd.services."cloudflared-tunnel-pi-main" = {
serviceConfig = {
ExecStart = lib.mkForce (pkgs.writeShellScript "cloudflared-start" ''
exec ${pkgs.cloudflared}/bin/cloudflared tunnel \
--no-autoupdate \
run \
--token "$(cat ${config.sops.secrets."cloudflare/tunnel_token".path})"
'');
};
after = lib.mkAfter [ "caddy.service" ];
wants = lib.mkAfter [ "caddy.service" ];
};
};
}
+117
View File
@@ -0,0 +1,117 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Common configuration shared by every host in the homey ecosystem.
# Hardware-specific settings (disk layout, device trees, etc.) go in
# hosts/<name>/hardware.nix instead.
{
# -------------------------------------------------------------------------
# Nix / flakes
# -------------------------------------------------------------------------
nix = {
settings = {
experimental-features = [ "nix-command" "flakes" ];
# Save disk space on Pi
auto-optimise-store = true;
};
# Weekly garbage collection — keeps the system from filling the SD card
gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 14d";
};
};
# Allow unfree packages (e.g. cloudflared binary)
nixpkgs.config.allowUnfree = true;
# -------------------------------------------------------------------------
# Boot — set in hardware.nix; this is just a safe default
# -------------------------------------------------------------------------
# boot.loader is intentionally left to hardware.nix
# -------------------------------------------------------------------------
# Locale / timezone
# -------------------------------------------------------------------------
time.timeZone = homeyConfig.timezone;
i18n.defaultLocale = "en_US.UTF-8";
# -------------------------------------------------------------------------
# Networking
# -------------------------------------------------------------------------
networking = {
# hostname is set per-host in default.nix
firewall = {
enable = true;
allowedTCPPorts = [
22 # SSH
80 # Caddy HTTP (redirect to HTTPS or ACME challenge)
443 # Caddy HTTPS
];
};
# Use systemd-resolved for DNS — supports mDNS and local overrides
nameservers = [ "1.1.1.1" "8.8.8.8" ];
};
# -------------------------------------------------------------------------
# SSH
# -------------------------------------------------------------------------
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
# -------------------------------------------------------------------------
# Container runtime — podman (rootless-capable, no daemon needed)
# -------------------------------------------------------------------------
virtualisation.podman = {
enable = true;
dockerCompat = true; # allow `docker` CLI commands against podman
defaultNetwork.settings.dns_enabled = true;
};
# -------------------------------------------------------------------------
# Core packages available on every host
# -------------------------------------------------------------------------
environment.systemPackages = with pkgs; [
git
vim
htop
curl
wget
rsync
lsof
sops # secret editing
age # key generation for sops
restic # backup (CLI, also used by services.restic)
podman-compose
];
# -------------------------------------------------------------------------
# sops-nix global config — point at the secrets file and the host's age key
# -------------------------------------------------------------------------
sops = {
defaultSopsFile = ../secrets/secrets.yaml;
# The age private key must be present on the host at this path.
# Generate on the Pi with: age-keygen -o /var/lib/sops-nix/key.txt
# Then add the PUBLIC key to secrets/.sops.yaml before encrypting.
age.keyFile = "/var/lib/sops-nix/key.txt";
};
# -------------------------------------------------------------------------
# Admin user — adjust username / SSH key in hosts/<name>/default.nix
# -------------------------------------------------------------------------
users.mutableUsers = false; # all user config must be declared here
# The actual admin user is declared in hosts/<name>/default.nix so the
# SSH authorized key can be host-specific.
# -------------------------------------------------------------------------
# System state version — do not change after first install
# (tracks NixOS backwards-compat markers)
# -------------------------------------------------------------------------
system.stateVersion = "24.11";
}
+200
View File
@@ -0,0 +1,200 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Authelia — SSO gateway.
#
# Connects to OpenLDAP on 127.0.0.1:389.
# Exposes port 9091 on localhost; Caddy reverse-proxies it and provides
# the forward_auth endpoint for protected vhosts.
#
# Volume layout:
# <dataDir>/authelia/config/ → /config (sqlite db, notification log, etc.)
#
# The configuration file is rendered by Nix (no Go templates) and written
# to a NixOS-managed path, then bind-mounted read-only into the container.
#
# Secrets consumed from sops:
# authelia/jwt_secret
# authelia/session_secret
# authelia/storage_encryption_key
# openldap/ro_password (shared with openldap module)
let
cfg = config.homey.authelia;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# LDAP base DN derived from domain: home.zakobar.com → dc=home,dc=zakobar,dc=com
ldapBaseDN = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
# The authelia config is written as a Nix string so all values are
# resolved at build time except for secrets, which are injected at
# runtime via a wrapper script (same pattern as openldap).
autheliaConfig = ''
###############################################################
# Authelia configuration #
# Generated by NixOS do not edit by hand #
###############################################################
theme: "light"
log:
level: "info"
# jwt_secret injected at runtime via env var AUTHELIA_JWT_SECRET_FILE
authentication_backend:
ldap:
implementation: "custom"
url: "ldap://127.0.0.1:389"
timeout: "5s"
start_tls: false
base_dn: "${ldapBaseDN}"
users_filter: "({username_attribute}={input})"
username_attribute: "uid"
additional_users_dn: "ou=users"
groups_filter: "(&(uniquemember=uid={input},ou=users,${ldapBaseDN})(objectclass=groupOfUniqueNames))"
group_name_attribute: "cn"
additional_groups_dn: "ou=groups"
mail_attribute: "mail"
display_name_attribute: "uid"
permit_referrals: false
permit_unauthenticated_bind: false
user: "cn=readonly,${ldapBaseDN}"
# password injected at runtime via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
totp:
issuer: "${domain}"
disable: false
session:
name: authelia_session
# secret injected at runtime via AUTHELIA_SESSION_SECRET_FILE
expiration: 3600
inactivity: 7200
domain: "${domain}"
storage:
local:
path: "/config/db.sqlite3"
# encryption_key injected at runtime via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
access_control:
default_policy: "deny"
rules:
- domain:
- "auth.${domain}"
policy: "bypass"
- domain:
- "ldapadmin.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "ldapadmin.${domain}"
policy: "deny"
- domain:
- "torrent.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "torrent.${domain}"
policy: "deny"
- domain:
- "git.${domain}"
policy: "one_factor"
- domain:
- "nextcloud.${domain}"
policy: "one_factor"
- domain:
- "jellyfin.${domain}"
policy: "one_factor"
notifier:
filesystem:
filename: "/config/emails.txt"
ntp:
address: "udp://time.cloudflare.com:123"
version: 3
max_desync: "3s"
disable_startup_check: false
disable_failure: true
'';
in
{
options.homey.authelia = {
enable = lib.mkEnableOption "Authelia SSO gateway";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/authelia/authelia:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 9091;
description = "Host port Authelia listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."authelia/jwt_secret" = { owner = "root"; };
sops.secrets."authelia/session_secret" = { owner = "root"; };
sops.secrets."authelia/storage_encryption_key" = { owner = "root"; };
# openldap/ro_password is declared in openldap.nix; reference it here too
# (sops-nix deduplicates identical declarations)
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Write the config file into /etc (read-only in the container)
# -----------------------------------------------------------------------
environment.etc."authelia/configuration.yml" = {
text = autheliaConfig;
mode = "0444";
};
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.authelia = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
environment = {
TZ = homeyConfig.timezone;
# Tell authelia to read secrets from files (its native mechanism)
AUTHELIA_JWT_SECRET_FILE = "/run/secrets/jwt_secret";
AUTHELIA_SESSION_SECRET_FILE = "/run/secrets/session_secret";
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = "/run/secrets/storage_encryption_key";
AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = "/run/secrets/ldap_ro_password";
};
volumes = [
"/etc/authelia/configuration.yml:/config/configuration.yml:ro"
"${dataDir}/authelia/config:/config"
# Mount sops secret files into the container under /run/secrets/
"${config.sops.secrets."authelia/jwt_secret".path}:/run/secrets/jwt_secret:ro"
"${config.sops.secrets."authelia/session_secret".path}:/run/secrets/session_secret:ro"
"${config.sops.secrets."authelia/storage_encryption_key".path}:/run/secrets/storage_encryption_key:ro"
"${config.sops.secrets."openldap/ro_password".path}:/run/secrets/ldap_ro_password:ro"
];
extraOptions = [
"--network=host"
"--hostname=authelia"
];
};
# -----------------------------------------------------------------------
# Systemd — wait for openldap and external HD
# -----------------------------------------------------------------------
systemd.services."podman-authelia" = {
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
};
};
}
+198
View File
@@ -0,0 +1,198 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Gitea — self-hosted Git service.
#
# Auth model: LDAP authentication is configured through Gitea's admin UI
# (or CLI) after first start. Reverse proxy auth headers from Caddy/Authelia
# handle transparent login.
#
# Volume layout:
# <dataDir>/gitea/data/ → /data (repos, sqlite db, avatars, lfs, etc.)
#
# The app.ini is rendered by Nix and bind-mounted read-only.
#
# Secrets consumed from sops:
# gitea/admin_password
# gitea/lfs_jwt_secret
# gitea/oauth2_jwt_secret
# gitea/internal_token
let
cfg = config.homey.gitea;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# Gitea app.ini — generated at build time.
# Secrets that Gitea reads from env vars are referenced as env var names here.
# The actual values are injected by the ExecStartPre wrapper below.
giteaAppIni = ''
APP_NAME = ${homeyConfig.organization}
RUN_MODE = prod
RUN_USER = git
WORK_PATH = /data/gitea
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = git.${domain}
HTTP_PORT = 3000
ROOT_URL = https://git.${domain}/
DISABLE_SSH = true
LFS_START_SERVER = true
; LFS_JWT_SECRET injected at container start via env var / startup script
LFS_JWT_SECRET = __GITEA_LFS_JWT_SECRET__
OFFLINE_MODE = false
[lfs]
PATH = /data/git/lfs
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
LOG_SQL = false
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
DISABLE_GRAVATAR = false
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROUTER = console
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
; INTERNAL_TOKEN injected at container start
INTERNAL_TOKEN = __GITEA_INTERNAL_TOKEN__
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
ENABLE_CAPTCHA = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[oauth2]
ENABLE = false
; JWT_SECRET injected at container start
JWT_SECRET = __GITEA_OAUTH2_JWT_SECRET__
'';
in
{
options.homey.gitea = {
enable = lib.mkEnableOption "Gitea Git server";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/gitea/gitea:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Host port Gitea listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."gitea/admin_password" = { owner = "root"; };
sops.secrets."gitea/lfs_jwt_secret" = { owner = "root"; };
sops.secrets."gitea/oauth2_jwt_secret" = { owner = "root"; };
sops.secrets."gitea/internal_token" = { owner = "root"; };
# -----------------------------------------------------------------------
# Write the app.ini template to /etc (will be processed by ExecStartPre)
# -----------------------------------------------------------------------
environment.etc."gitea/app.ini.tpl" = {
text = giteaAppIni;
mode = "0444";
};
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.gitea = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:3000" ];
environment = {
USER_UID = "1000";
USER_GID = "1000";
# Tell gitea where to look for the config
GITEA_CUSTOM = "/data/gitea";
};
volumes = [
"${dataDir}/gitea/data:/data"
# The processed app.ini is written by ExecStartPre into /run/gitea-conf/
"/run/gitea-conf/app.ini:/data/gitea/conf/app.ini:ro"
];
extraOptions = [ "--network=host" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: substitute secret placeholders into the ini template
# -----------------------------------------------------------------------
systemd.services."podman-gitea" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "gitea-build-config" ''
set -euo pipefail
install -d -m 700 /run/gitea-conf
LFS=$(cat ${config.sops.secrets."gitea/lfs_jwt_secret".path})
OAUTH=$(cat ${config.sops.secrets."gitea/oauth2_jwt_secret".path})
TOKEN=$(cat ${config.sops.secrets."gitea/internal_token".path})
sed \
-e "s|__GITEA_LFS_JWT_SECRET__|$LFS|g" \
-e "s|__GITEA_OAUTH2_JWT_SECRET__|$OAUTH|g" \
-e "s|__GITEA_INTERNAL_TOKEN__|$TOKEN|g" \
/etc/gitea/app.ini.tpl > /run/gitea-conf/app.ini
chmod 444 /run/gitea-conf/app.ini
'')
];
};
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
};
}
+55
View File
@@ -0,0 +1,55 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Jellyfin — media server. (Deferred — enable when ready.)
#
# Volume layout:
# <dataDir>/jellyfin/config/ → /config
# <dataDir>/media/movies/ → /data/movies
# <dataDir>/media/tvshows/ → /data/tvshows
let
cfg = config.homey.jellyfin;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.jellyfin = {
enable = lib.mkEnableOption "Jellyfin media server";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/jellyfin/jellyfin:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 8096;
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.jellyfin = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
environment = {
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
PUID = "1000";
PGID = "1000";
};
volumes = [
"${dataDir}/jellyfin/config:/config"
"${dataDir}/media/movies:/data/movies:ro"
"${dataDir}/media/tvshows:/data/tvshows:ro"
];
extraOptions = [ "--network=host" ];
};
systemd.services."podman-jellyfin" = {
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
};
}
+135
View File
@@ -0,0 +1,135 @@
{ 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;
in
{
options.homey.nextcloud = {
enable = lib.mkEnableOption "Nextcloud file server";
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;
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=host" ];
};
systemd.services."podman-nextcloud-postgres" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "nc-postgres-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-postgres-secrets.env
echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" \
>> /run/nc-postgres-secrets.env
'')
];
EnvironmentFile = "/run/nc-postgres-secrets.env";
};
postStop = "rm -f /run/nc-postgres-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
# -----------------------------------------------------------------------
# Nextcloud container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
environment = {
POSTGRES_HOST = "127.0.0.1";
POSTGRES_DB = "nextcloud_db";
POSTGRES_USER = "postgres";
NEXTCLOUD_ADMIN_USER = "admin";
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
OVERWRITEPROTOCOL = "https";
OVERWRITECLIURL = "https://nextcloud.${domain}";
# Passwords injected via env file
};
volumes = [
"${dataDir}/nextcloud/html:/var/www/html"
];
extraOptions = [ "--network=host" ];
};
systemd.services."podman-nextcloud" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "nc-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-secrets.env
echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" >> /run/nc-secrets.env
echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat ${config.sops.secrets."nextcloud/admin_password".path})" >> /run/nc-secrets.env
'')
];
EnvironmentFile = "/run/nc-secrets.env";
};
postStop = "rm -f /run/nc-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
};
};
}
+116
View File
@@ -0,0 +1,116 @@
{ config, lib, pkgs, homeyConfig, ... }:
# OpenLDAP — central identity provider.
#
# Runs as a podman container (osixia/openldap).
# Listens on localhost:389 only — not exposed to the outside world.
# Authelia and other services connect to it over the container network (127.0.0.1).
#
# Volume layout on host:
# <dataDir>/openldap/etc-ldap-slapd.d/ → /etc/ldap/slapd.d (config DB)
# <dataDir>/openldap/var-lib-ldap/ → /var/lib/ldap (data)
#
# Secrets consumed from sops:
# openldap/admin_password
# openldap/config_password
# openldap/ro_password
let
cfg = config.homey.openldap;
dataDir = config.homey.storage.mountPoint;
in
{
options.homey.openldap = {
enable = lib.mkEnableOption "OpenLDAP identity provider";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/osixia/openldap:latest";
description = "Container image to use for OpenLDAP.";
};
port = lib.mkOption {
type = lib.types.port;
default = 389;
description = "Host port OpenLDAP listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."openldap/admin_password" = { owner = "root"; };
sops.secrets."openldap/config_password" = { owner = "root"; };
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.openldap = {
image = cfg.image;
# Bind only to localhost — no external exposure
ports = [ "127.0.0.1:${toString cfg.port}:389" ];
environment = {
LDAP_ORGANISATION = homeyConfig.organization;
LDAP_DOMAIN = homeyConfig.domain;
LDAP_ADMIN_USERNAME = "admin";
LDAP_READONLY_USER = "true";
# TLS disabled — traffic stays on localhost
LDAP_TLS = "false";
};
# Inject passwords from sops-managed secret files
environmentFiles = []; # we use secretFiles below instead
# sops writes secret values to files; we read them into env vars
# via a wrapper script run as ExecStartPre (see systemd override below).
# Podman's --env-file doesn't support arbitrary paths, so we use
# a secrets tmpfile approach via the systemd unit override.
volumes = [
"${dataDir}/openldap/etc-ldap-slapd.d:/etc/ldap/slapd.d"
"${dataDir}/openldap/var-lib-ldap:/var/lib/ldap"
];
extraOptions = [
"--network=host" # simplest for single-host: services talk on 127.0.0.1
"--hostname=openldap"
];
};
# -----------------------------------------------------------------------
# Systemd override to inject sops secrets as env vars
# -----------------------------------------------------------------------
# podman containers are managed by systemd units named
# podman-<container-name>.service
systemd.services."podman-openldap" = {
serviceConfig = {
# Write an env file with secret values before the container starts,
# then pass it to podman run via EnvironmentFile.
ExecStartPre = [
(pkgs.writeShellScript "openldap-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/openldap-secrets.env
echo "LDAP_ADMIN_PASSWORD=$(cat ${config.sops.secrets."openldap/admin_password".path})" >> /run/openldap-secrets.env
echo "LDAP_CONFIG_PASSWORD=$(cat ${config.sops.secrets."openldap/config_password".path})" >> /run/openldap-secrets.env
echo "LDAP_READONLY_USER_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" >> /run/openldap-secrets.env
'')
];
EnvironmentFile = "/run/openldap-secrets.env";
};
# Clean up the env file on stop
postStop = "rm -f /run/openldap-secrets.env";
# Wait for the external HD to be mounted before starting
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
# -----------------------------------------------------------------------
# Firewall — openldap port is NOT opened externally (localhost only)
# -----------------------------------------------------------------------
# No firewall rule needed; bound to 127.0.0.1.
};
}
+46
View File
@@ -0,0 +1,46 @@
{ config, lib, pkgs, homeyConfig, ... }:
# phpLDAPadmin — web UI for OpenLDAP management.
#
# Stateless container (no persistent volumes needed).
# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix).
# Bound to localhost:8081; Caddy reverse-proxies it.
let
cfg = config.homey.phpldapadmin;
in
{
options.homey.phpldapadmin = {
enable = lib.mkEnableOption "phpLDAPadmin web interface";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/osixia/phpldapadmin:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 8081;
description = "Host port phpLDAPadmin listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.phpldapadmin = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
environment = {
PHPLDAPADMIN_HTTPS = "false";
PHPLDAPADMIN_LDAP_HOSTS = "127.0.0.1"; # openldap on host network
};
extraOptions = [ "--network=host" ];
};
systemd.services."podman-phpldapadmin" = {
after = lib.mkAfter [ "podman-openldap.service" ];
wants = lib.mkAfter [ "podman-openldap.service" ];
};
};
}
+61
View File
@@ -0,0 +1,61 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Transmission — BitTorrent client. (Deferred — enable when ready.)
#
# NOTE: Transmission's web UI also runs on port 9091. To avoid clashing
# with Authelia (also 9091), this module binds Transmission to 9092.
#
# Volume layout:
# <dataDir>/transmission/config/ → /config
# <dataDir>/media/movies/ → /downloads/movies
# <dataDir>/media/tvshows/ → /downloads/tvshows
# <dataDir>/media/general/ → /downloads/general
# <dataDir>/media/complete/ → /downloads/complete
let
cfg = config.homey.transmission;
dataDir = config.homey.storage.mountPoint;
in
{
options.homey.transmission = {
enable = lib.mkEnableOption "Transmission torrent client";
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/linuxserver/transmission:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 9092;
description = "Host port for Transmission web UI (9092 to avoid clash with authelia@9091).";
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.transmission = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
environment = {
PUID = "1000";
PGID = "1000";
};
volumes = [
"${dataDir}/transmission/config:/config"
"${dataDir}/media/movies:/downloads/movies"
"${dataDir}/media/tvshows:/downloads/tvshows"
"${dataDir}/media/general:/downloads/general"
"${dataDir}/media/complete:/downloads/complete"
];
extraOptions = [ "--network=host" ];
};
systemd.services."podman-transmission" = {
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
};
}
+105
View File
@@ -0,0 +1,105 @@
{ config, lib, pkgs, ... }:
# External hard-drive storage module.
#
# Each host sets:
# homey.storage.device = "/dev/disk/by-id/usb-WD_..."; (by-id is stable across reboots)
# homey.storage.mountPoint = "/mnt/data"; (default)
#
# All service data lives under <mountPoint>/<service-name>/, so the whole
# dataset can be browsed, backed up, or restored with plain filesystem tools.
#
# Directory layout under mountPoint:
# openldap/
# etc-ldap-slapd.d/ ← /etc/ldap/slapd.d in container
# var-lib-ldap/ ← /var/lib/ldap in container
# authelia/
# config/ ← /config in container (sqlite db etc.)
# gitea/
# data/ ← /data in container
# nextcloud/
# html/ ← /var/www/html in container
# db/ ← /var/lib/postgresql/data in postgres container
# jellyfin/
# config/
# media/
# movies/
# tvshows/
# general/
# complete/
# transmission/
# config/
# restic-cache/ ← restic local cache (not the backup destination)
let
cfg = config.homey.storage;
in
{
options.homey.storage = {
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/usb-WD_Elements_12345-0:0";
description = ''
Block device for the external hard drive.
Use /dev/disk/by-id/ paths for stable identification across reboots.
Leave empty to skip automount (useful during initial setup).
'';
default = "";
};
mountPoint = lib.mkOption {
type = lib.types.str;
default = "/mnt/data";
description = "Where the external HD is mounted. All service data lives here.";
};
fsType = lib.mkOption {
type = lib.types.str;
default = "ext4";
description = "Filesystem type of the external drive.";
};
};
config = lib.mkIf (cfg.device != "") {
# Mount the external drive
fileSystems."${cfg.mountPoint}" = {
device = cfg.device;
fsType = cfg.fsType;
options = [
"defaults"
"nofail" # Don't block boot if drive is absent
"noatime" # Better performance / less SD wear
"x-systemd.automount"
"x-systemd.idle-timeout=0"
];
};
# Ensure the mount point directory exists
systemd.tmpfiles.rules = [
"d ${cfg.mountPoint} 0755 root root -"
# Service subdirectories — created on boot so containers can start
# even before any data is restored into them.
"d ${cfg.mountPoint}/openldap 0750 root root -"
"d ${cfg.mountPoint}/openldap/etc-ldap-slapd.d 0750 root root -"
"d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -"
"d ${cfg.mountPoint}/authelia 0750 root root -"
"d ${cfg.mountPoint}/authelia/config 0750 root root -"
"d ${cfg.mountPoint}/gitea 0750 root root -"
"d ${cfg.mountPoint}/gitea/data 0750 root root -"
"d ${cfg.mountPoint}/nextcloud 0750 root root -"
"d ${cfg.mountPoint}/nextcloud/html 0750 root root -"
"d ${cfg.mountPoint}/nextcloud/db 0750 root root -"
"d ${cfg.mountPoint}/jellyfin 0750 root root -"
"d ${cfg.mountPoint}/jellyfin/config 0750 root root -"
"d ${cfg.mountPoint}/media 0755 root root -"
"d ${cfg.mountPoint}/media/movies 0755 root root -"
"d ${cfg.mountPoint}/media/tvshows 0755 root root -"
"d ${cfg.mountPoint}/media/general 0755 root root -"
"d ${cfg.mountPoint}/media/complete 0755 root root -"
"d ${cfg.mountPoint}/transmission 0750 root root -"
"d ${cfg.mountPoint}/transmission/config 0750 root root -"
"d ${cfg.mountPoint}/restic-cache 0700 root root -"
];
};
}