Compare commits

...

10 Commits

Author SHA1 Message Date
Raphaël Numbus 5b5dbd1c0a Updated misc. 2026-02-23 23:15:36 +01:00
Raphaël Numbus 4bbd62a93e Services are ready 2026-02-23 23:05:54 +01:00
Raphaël Numbus 944ffcea85 Added mail notifications on failure. Needs more work on lib.nix and the services/*.nix. 2026-02-23 16:36:40 +01:00
Raphaël Numbus f445bd8659 Added the rest of the configuration. Still some things to add. 2026-02-22 20:34:44 +01:00
Raphaël Numbus 2e16ac3711 Services are ready. 2026-02-22 12:04:19 +01:00
Raphaël Numbus 40265e8c81 Updated container networking. 2026-02-20 11:04:09 +01:00
Raphaël Numbus 61d0fbd339 Added the correct options to multiple containers. 2026-02-20 10:43:11 +01:00
Raphaël Numbus c90169f242 Created a helper to create the container configurations. Updated all containers files to use it. 2026-02-20 10:23:24 +01:00
Raphaël Numbus 3b130bc2e9 Traefik and Frigate updated to new module format. Frigate needs some more testing (hardening, devices). 2026-02-19 14:03:03 +01:00
Raphaël Numbus 583963c7dc First commit 2026-02-18 22:22:31 +01:00
39 changed files with 2191 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
.DS_STORE
.env
*test*
+3
View File
@@ -0,0 +1,3 @@
.DS_STORE
.env
*test*
+29 -1
View File
@@ -1,2 +1,30 @@
# numbus-server-module
# ☁️ Numbus Server: Your Personal Cloud, Simplified 🚀
Welcome to the **Numbus Server** project!
⚠️ This repository contains the NixOS module that configures the numbus server. **Please head to https://gittea.dev/numbus/numbus-server to get more information and installation instructions.**
This repository provides a complete NixOS configuration to deploy a personal home server with a rich set of services in minutes. Our goal is to make self-hosting accessible to everyone, allowing you to take back control of your data with a solution that is easy to manage and highly reliable.
## 🛠️ Key Technologies
- **NixOS:** A declarative Linux distribution that makes system management a breeze.
- **Nix Flakes:** For reproducible builds and dependency management.
- **Numbus Server NixOS module:** To make user's configuration simpler.
- **Podman:** To run containerized services with ease.
- **Traefik:** A modern reverse proxy to access services securely.
- **Sops-nix:** For a secure and convenient way of managing secrets.
- **NixOS-anywhere:** For a seamless initial deployment to any machine.
- **Disko:** For a declarative and predictable disk partitioning.
## 🔧 Numbus Server NixOS Module
This repository contains the code of this module. This modules contains the **complex NixOS configuration** : networking, user creation, container management, secrets provisioning, power settings, and much more. Since all the **complexity** of the configuration is contained in this module, this allows the end user configuration to be very **simple and clean**.
## 🤝 Contributing
Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request.
## 📄 License
This project is licensed under the AGPLv3. See the [LICENSE](LICENSE) file for details.
+17
View File
@@ -0,0 +1,17 @@
{
description = "Numbus Server - Your Personal Cloud, Simplified";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs, ... }: {
nixosModules = {
default = { config, pkgs, lib, ... }: {
imports = [
./modules/default.nix
];
};
};
};
}
+13
View File
@@ -0,0 +1,13 @@
{ ... }:
{
imports = [
./hardware/default.nix
./mail/default.nix
./misc/default.nix
./networking/default.nix
./packages/default.nix
./services/default.nix
./global.nix
];
}
+59
View File
@@ -0,0 +1,59 @@
{ lib, ... }:
with lib;
{
options.numbus = {
owner = {
type = type.str;
example = "Alex";
default = "Numbus";
description = "The name of the person who owns this server";
};
language = {
type = type.str;
example = "FR";
default = "FR";
description = "The language for this server";
};
locale = {
type = type.str;
example = "fr_FR";
default = "fr_FR";
description = "The default locale for this server";
};
services = {
domain = mkOption {
type = types.str;
example = "numbus.eu";
description = "The root domain name (i.e. example.com) that your services will use";
};
dns = mkOption {
type = types.enum [ "pi-hole" "adguard" ];
default = "pi-hole";
example = "pi-hole";
description = "The preferred DNS resolver service (pi-hole or adguard) that other services should depend on";
};
};
traefikDynamicConfigDir = mkOption {
type = types.str;
default = "/etc/traefik/rules";
example = "/etc/traefik/rules";
description = "! Choosing a directory outside of /etc/ will break things ! The directory where Traefik's dynamic configuration files will be stored";
};
email = {
administratorEmail = mkOption {
type = types.str;
example = "admin@your-domain.com";
description = "The email that will be used to send critical notifications such as hardware failures, services errors, ACME updates, etc";
};
userEmail = mkOption {
type = types.str;
example = "user@your-domain.com";
description = "The email that will be used by services to send notifications";
};
};
};
}
+11
View File
@@ -0,0 +1,11 @@
{ config, ... }:
{
boot.initrd.systemd.enable = true;
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernel.sysctl = {
"vm.overcommit_memory" = 1;
};
}
+7
View File
@@ -0,0 +1,7 @@
{ config, ... }:
{
hardware.enableRedistributableFirmware = true;
hardware.cpu.intel.updateMicrocode = true;
hardware.cpu.amd.updateMicrocode = true;
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports = [
./boot.nix
./cpu.nix
./disks.nix
];
}
+322
View File
@@ -0,0 +1,322 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus.hardware;
mkDataDisk = idx: device: {
name = "content-${toString idx}";
value = {
type = "disk";
device = device;
content = {
type = "gpt";
partitions = {
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted-content-${toString idx}";
initrdUnlock = false;
settings = {
keyFile = "/etc/secrets/disks/content-${toString idx}";
allowDiscards = true;
crypttabExtraOpts = [ "nofail" ];
};
content = {
type = "filesystem";
format = cfg.dataDisksFilesystem;
mountpoint = "/mnt/content-${toString idx}";
mountOptions = [ "nofail" "noauto" ];
};
};
};
};
};
};
};
mkParityDisk = idx: device: {
name = "parity-${toString idx}";
value = {
type = "disk";
device = device;
content = {
type = "gpt";
partitions = {
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted-parity-${toString idx}";
initrdUnlock = false;
settings = {
keyFile = "/etc/secrets/disks/parity-${toString idx}";
allowDiscards = true;
crypttabExtraOpts = [ "nofail" ];
};
content = {
type = "filesystem";
format = cfg.parityDisksFilesystem;
mountpoint = "/mnt/parity-${toString idx}";
mountOptions = [ "nofail" "noauto" ];
};
};
};
};
};
};
};
isMirror = length cfg.bootDisksList > 1;
bootDisksConfig = if isMirror then {
mdadm = {
boot = {
type = "mdadm";
level = 1;
metadata = "1.2";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
};
disk = listToAttrs (imap1 (i: device: {
name = "boot-${toString i}";
value = {
type = "disk";
device = device;
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "mdraid";
name = "boot";
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted-boot-${toString i}";
settings = {
allowDiscards = true;
keyFile = "/etc/secrets/disks/boot-${toString i}";
};
content = {
type = "lvm_pv";
vg = "pool";
};
};
};
};
};
};
}) cfg.bootDisksList);
} else {
disk = {
"boot-1" = {
type = "disk";
device = head cfg.bootDisksList;
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted-boot-1";
settings = {
keyFile = "/etc/secrets/disks/boot-1";
allowDiscards = true;
};
content = {
type = "lvm_pv";
vg = "pool";
};
};
};
};
};
};
};
};
lvmConfig = {
lvm_vg = {
pool = {
type = "lvm_vg";
lvs = {
swap = {
size = cfg.swapSize;
content = {
type = "swap";
};
} // optionalAttrs isMirror { lvm_type = "mirror"; };
snapraid = {
size = "1G";
content = {
type = "filesystem";
format = "btrfs";
mountpoint = "/mnt/content-0";
};
} // optionalAttrs isMirror { lvm_type = "mirror"; };
root = {
size = "100%";
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"/rootfs" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/home" = {
mountpoint = "/home";
mountOptions = [ "compress=zstd" ];
};
"/nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
};
};
} // optionalAttrs isMirror { lvm_type = "mirror"; };
};
};
};
};
in
{
options.numbus.hardware = {
dataDisksList = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/disk/by-id/WD_Blue_ATO431_159Ejz224G0000382b" "/dev/disk/by-id/Seagate_Barracuda_159Ejz224G" ];
description = "List by-id path of devices for data disks";
};
parityDisksList = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/disk/by-id/WD_Blue_ATO431_159Ejz224G0000382b" "/dev/disk/by-id/Seagate_Barracuda_159Ejz224G" ];
description = "List of by-id path of devices for parity disks";
};
bootDisksList = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/disk/by-id/nvme_SAMSUNG_MZVPYEHCO_159Ejz224G0000" "/dev/disk/by-id/ata-San_Disk_159Ejz224G" ];
description = "List of by-id path of devices for boot disks";
};
swapSize = mkOption {
type = types.str;
default = "16G";
example = "16G";
description = "Size of the swap partition";
};
dataDisksFilesystem = mkOption {
type = types.enum [ "xfs" "ext4" "btrfs" ];
default = "xfs";
example = "xfs";
description = "Filesystem for data disks. Available filesystem options : xfs, ext4, btrfs";
};
parityDisksFilesystem = mkOption {
type = types.enum [ "xfs" "ext4" "btrfs" ];
default = "xfs";
example = "xfs";
description = "Filesystem for parity disks. Available filesystem options : xfs, ext4, btrfs";
};
};
config = mkIf (cfg.bootDisksList != []) {
disko.devices = mkMerge [
bootDisksConfig
lvmConfig
{
disk = listToAttrs (imap1 mkDataDisk cfg.dataDisksList);
}
{
disk = listToAttrs (imap1 mkParityDisk cfg.parityDisksList);
}
];
services.snapraid = {
enable = true;
contentFiles = [ "/mnt/content-0/snapraid.content" ] ++
(map (i: "/mnt/content-${toString i}/snapraid.content") (range 1 (length cfg.dataDisksList)));
parityFiles = map (i: "/mnt/parity-${toString i}/snapraid.parity") (range 1 (length cfg.parityDisksList));
dataDisks = listToAttrs (imap1 (i: _: nameValuePair "d${toString i}" "/mnt/content-${toString i}") cfg.dataDisksList);
};
fileSystems."/mnt/data" = {
device = "/mnt/content-*";
fsType = "fuse.mergerfs";
options = [
"category.create=ff"
"cache.files=partial"
"dropcacheonclose=true"
"defaults"
"noauto"
"nofail"
"allow_other"
"moveonenospc=1"
"minfreespace=50G"
"func.getattr=newest"
"fsname=mergerfs_data"
"x-mount.mkdir"
"x-systemd.automount"
];
};
systemd.services.mount-disks = {
description = "Mount data and parity disks";
before = [ "mnt-data.mount" ];
requiredBy = [ "mnt-data.mount" ];
path = [ pkgs.cryptsetup pkgs.util-linux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = let
mountDataDisk = i: ''
if [ ! -e /dev/mapper/crypted-content-${toString i} ]; then
cryptsetup luksOpen --key-file /etc/secrets/disks/content-${toString i} /dev/disk/by-partlabel/disk-content-${toString i}-luks crypted-content-${toString i}
fi
mkdir -p /mnt/content-${toString i}
if ! mountpoint -q /mnt/content-${toString i}; then
mount -t ${cfg.dataDisksFilesystem} /dev/mapper/crypted-content-${toString i} /mnt/content-${toString i}
fi
'';
mountParityDisk = i: ''
if [ ! -e /dev/mapper/crypted-parity-${toString i} ]; then
cryptsetup luksOpen --key-file /etc/secrets/disks/parity-${toString i} /dev/disk/by-partlabel/disk-parity-${toString i}-luks crypted-parity-${toString i}
fi
mkdir -p /mnt/parity-${toString i}
if ! mountpoint -q /mnt/parity-${toString i}; then
mount -t ${cfg.parityDisksFilesystem} /dev/mapper/crypted-parity-${toString i} /mnt/parity-${toString i}
fi
'';
in ''
${concatMapStrings mountDataDisk (range 1 (length cfg.dataDisksList))}
${concatMapStrings mountParityDisk (range 1 (length cfg.parityDisksList))}
'';
};
};
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports = [
./smart.nix
./systemd.nix
./smtp.nix
];
}
+61
View File
@@ -0,0 +1,61 @@
{ config, pkgs, ... }:
let
smartd_notifier = pkgs.writeScript "smartd-notify.sh" ''
#!${pkgs.bash}/bin/bash
# 1. Send Technical Email to Admin
ADMIN_EMAIL="${config.numbus.mail.adminAddress}"
SUBJECT="Numbus Server Alert: $SMARTD_FAILTYPE on $SMARTD_DEVICE"
TECH_BODY="
SMARTD Alert Details:
Server owner: $OWNER_NAME
Device: $SMARTD_DEVICE
Type: $SMARTD_DEVICETYPE
Failure Type: $SMARTD_FAILTYPE
Message: $SMARTD_MESSAGE
Full Message:
$SMARTD_FULLMESSAGE
"
printf "Subject: [ADMIN] $SUBJECT\n\n$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
# 2. Send Friendly Email to Owner
USER_EMAIL="${config.numbus.mail.userAddress}"
OWNER_NAME="${config.numbus.owner}"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
Votre serveur a automatiquement détecté une panne matérielle de disque dur.
Ce genre de panne est tout à fait normal selon l'âge de votre matériel et n'entraîne
dans la grande majorité des cas aucune perte de données grâce au système de
stockage redondant préventif.
Votre administrateur a été notifié de cette panne. Il vous recontactera dans de très
brefs délais afin de procéder au remplacement, si nécessaire, du disque dur défaillant.
Merci de votre confiance,
L'équipe de support,
Numbus-Server."
printf "Subject: [Alerte] Défaillance matérielle sur votre serveur Numbus\n\n$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
in
{
services.smartd = {
enable = true;
defaults.autodetected = "-a -o on -S on -s (S/../.././00|L/../../6/01) -n standby,q -M exec ${smartd_notifier}";
notifications = {
wall = {
enable = true;
};
mail = {
enable = true;
sender = config.numbus.mail.fromAddress;
recipient = "${config.numbus.mail.userAddress},${config.numbus.mail.adminAddress}";
};
};
};
}
+84
View File
@@ -0,0 +1,84 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus.mail;
in
{
options.numbus.mail = {
enable = mkEnableOption "Email sending functionality";
userAddress = mkOption {
description = "The address of the user this server will send emails to";
type = types.str;
example = "user@your-domain.com";
};
adminAddress = mkOption {
description = "The address of the admin this server will send emails to";
type = types.str;
example = "admin@your-domain.com";
};
smtpUsername = mkOption {
description = "The username/email that will be use to authenticate to the SMTP server";
type = types.str;
example = "your-smtp-enabled-address@your-domain.com";
};
smtpPasswordPath = mkOption {
description = "The path to a file containing the password that will be use to authenticate to the SMTP server";
type = types.path;
example = /run/secrets/smtp-password;
};
fromAddress = mkOption {
description = "This server will send emails from this address";
type = types.str;
default = "numbus-server-noreply@${config.numbus.services.domain}";
example = "numbus-server-noreply@your-domain.com";
};
smtpServer = mkOption {
description = "The SMTP server address your server will use to send emails";
type = types.str;
default = "smtp.gmail.com";
example = "smtp.your-provider.com";
};
smtpPort = mkOption {
description = "The SMTP port your server will connect to to send emails";
type = types.port;
default = 587;
example = 587;
};
};
config = mkIf cfg.enable {
environment.etc."aliases".text = ''
root: ${cfg.userAddress}, ${cfg.adminAddress}
default: ${cfg.userAddress}, ${cfg.adminAddress}
'';
programs.msmtp = {
enable = true;
defaults = {
aliases = "/etc/aliases";
timeout = 60;
syslog = "on";
};
accounts.default = {
auth = true;
host = cfg.smtpServer;
port = cfg.smtpPort;
from = cfg.fromAddress;
user = cfg.smtpUsername;
tls = true;
tls_starttls = true;
passwordeval = "${pkgs.coreutils}/bin/cat ${cfg.smtpPasswordPath}";
};
};
};
}
+55
View File
@@ -0,0 +1,55 @@
{ config, pkgs, ... }:
let
systemd_notifier = pkgs.writeScript "systemd-email-notify.sh" ''
#!${pkgs.bash}/bin/bash
# The failing service name is passed as the first argument
UNIT=$1
# 1. Send Technical Email to Admin
ADMIN_EMAIL="${config.numbus.mail.adminAddress}"
SUBJECT="Numbus Server Alert: Service $UNIT Failed"
# Retrieve recent logs for context
LOGS=$(journalctl -u "$UNIT" -n 20 --no-pager)
TECH_BODY="
Systemd Service Failure Alert:
Server owner: ${config.numbus.owner}
Service: $UNIT
Recent Logs:
$LOGS
"
printf "Subject: [ADMIN] $SUBJECT\n\n$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
# 2. Send Friendly Email to Owner
USER_EMAIL="${config.numbus.mail.userAddress}"
OWNER_NAME="${config.numbus.owner}"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
Votre serveur a détecté une défaillance du service $UNIT.
Le système a tenté de gérer l'erreur, mais une intervention peut être nécessaire.
Votre administrateur a été notifié de cet incident avec les détails techniques nécessaires.
Il interviendra si une action manuelle est requise.
Merci de votre confiance,
L'équipe de support,
Numbus-Server."
printf "Subject: [Alerte] Erreur sur votre serveur Numbus\n\n$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
in
{
systemd.services."service-failure-notify@" = {
description = "Email notification for failed service %i";
onFailure = [ ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${systemd_notifier} %i";
};
};
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
./internationalisation.nix
./power.nix
./update.nix
./users.nix
];
}
+22
View File
@@ -0,0 +1,22 @@
{ config, lib, ... }:
{
i18n.defaultLocale = "${config.numbus.locale}.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "${config.numbus.locale}.UTF-8";
LC_IDENTIFICATION = "${config.numbus.locale}.UTF-8";
LC_MEASUREMENT = "${config.numbus.locale}.UTF-8";
LC_MONETARY = "${config.numbus.locale}.UTF-8";
LC_NAME = "${config.numbus.locale}.UTF-8";
LC_NUMERIC = "${config.numbus.locale}.UTF-8";
LC_PAPER = "${config.numbus.locale}.UTF-8";
LC_TELEPHONE = "${config.numbus.locale}.UTF-8";
LC_TIME = "${config.numbus.locale}.UTF-8";
};
console.keyMap = lib.toLower config.numbus.language;
services.xserver.xkb = {
layout = lib.toLower config.numbus.language;
variant = "";
};
}
+28
View File
@@ -0,0 +1,28 @@
{ config, lib, pkgs, ... }:
let
hardDrives = config.numbus.hardware.dataDisksList ++ config.numbus.hardware.parityDisksList;
in
{
systemd.services.hd-idle = {
description = "External HD spin down daemon";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart =
let
idleTime = toString 1800;
hardDriveParameter = lib.strings.concatMapStringsSep " " (x: "-a ${x} -i ${idleTime}") hardDrives;
in
"${pkgs.hd-idle}/bin/hd-idle -i 0 ${hardDriveParameter}";
};
};
services.autoaspm.enable = true;
powerManagement.powertop.enable = true;
boot.kernelParams = [
"pcie_aspm=force"
"consoleblank=60"
];
}
+21
View File
@@ -0,0 +1,21 @@
{ config, ... }:
{
system.autoUpgrade = {
enable = true;
allowReboot = false;
flake = inputs.self.outPath;
flags = [ "--print-build-logs" ];
dates = "02:00";
randomizedDelaySec = "45min";
};
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nix.settings.auto-optimise-store = true;
}
+16
View File
@@ -0,0 +1,16 @@
{ config, ... }:
{
users.users.numbus-admin = {
shell = pkgs.fish;
isNormalUser = true;
description = "Numbus Admin";
extraGroups = [ "wheel" ];
uid = 1000;
initialPassword = "changeMe!";
# required for auto start before user login
linger = true;
# required for rootless container with multiple users
autoSubUidGidRange = true;
};
}
+8
View File
@@ -0,0 +1,8 @@
{ ... }:
{
imports = [
./firewall.nix
./networking.nix
];
}
+11
View File
@@ -0,0 +1,11 @@
{ config, ... }:
{
networking.nftables.enable = true;
networking.firewall = {
enable = true;
allowPing = true;
allowedTCPPorts = [ 53 80 443 ];
allowedUDPPorts = [ 53 443 ];
};
}
+58
View File
@@ -0,0 +1,58 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus.networking;
in
{
options.numbus.networking = {
ipAddress = mkOption {
description = "The IP address that this server will use";
type = types.str;
example = "192.168.1.100";
};
interface = mkOption {
description = "The interface that this server will use to connect to the network";
type = types.str;
example = "enp1s0";
};
routerIpAddress = mkOption {
description = "The IP address of the router of your network";
type = types.str;
example = "192.168.1.1";
};
networkSubnet = mkOption {
description = "The subnet of your network";
type = types.str;
default = "";
example = "192.168.1.0/24";
};
dnsServers = mkOption {
description = "The list of DNS servers that this server will use";
type = types.listOf types.str;
default = [ "${cfg.ipAddress}" "9.9.9.9" ];
example = [ "${cfg.ipAddress}" "9.9.9.9" ];
};
};
networking.hostName = "numbus-server";
networking.networkmanager.enable = false;
# Allow rootless containers to bind to port 53 and up
boot.kernel.sysctl."net.ipv4.ip_unprivileged_port_start" = 53;
networking.bridges.br0.interfaces = [ "${cfg.interface}" ];
networking.interfaces."${cfg.interface}".useDHCP = false;
networking.interfaces.br0.useDHCP = false;
networking.nameservers = ${cfg.dnsServers};
networking.interfaces.br0.ipv4.addresses = [{
address = "${cfg.ipAddress}";
prefixLength = 24;
}];
networking.defaultGateway = {
address = "${cfg.routerIpAddress}";
interface = "br0";
};
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
./packages.nix
./podman.nix
./ssh.nix
./terminal.nix
];
}
+25
View File
@@ -0,0 +1,25 @@
{ config, pkgs, ... }:
{
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
git
ncdu
fastfetch
tpm2-tss
sops
age
powertop
pciutils
hdparm
hd-idle
hddtemp
smartmontools
cpufrequtils
intel-gpu-tools
snapraid
mergerfs
mergerfs-tools
];
}
+13
View File
@@ -0,0 +1,13 @@
{ config, pkgs, ...}:
{
virtualisation.podman.enable = true;
virtualisation.podman.defaultNetwork.settings.dns_enabled = true;
environment.systemPackages = with pkgs; [
podman
podman-compose
podman-tui
slirp4netns
];
}
+5
View File
@@ -0,0 +1,5 @@
{ config, ... }:
{
services.openssh.enable = true;
}
+24
View File
@@ -0,0 +1,24 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
fish
fishPlugins.fzf-fish
fishPlugins.grc
grc
fzf
];
programs.fish = {
enable = true;
interactiveShellInit = ''
set fish_greeting # Disable greeting
fastfetch
echo -e "\n\nWelcome to your Numbus-Server !\n\n- This system is managed by NixOS\n- All changes are futile\n- Please consider buying support if you can't get your server running\n- Have a nice day and enjoy !"
'';
shellAliases = {
nixup = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch --upgrade && cd -";
nixwitch = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch && cd -";
};
};
}
View File
+16
View File
@@ -0,0 +1,16 @@
{ ... }:
{
imports = [
# ./adguard.nix
./frigate.nix
./gitea.nix
./home-assistant.nix
./immich.nix
./it-tools.nix
./nextcloud.nix
./passbolt.nix
./pi-hole.nix
./traefik.nix
];
}
+67
View File
@@ -0,0 +1,67 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
frigateVersion = "0.16.4";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.frigate;
in
helper.mkPodmanService {
description = "Frigate, your fully-local NVR (Network Video Recorder)";
name = "frigate";
pod = "home-assistant";
defaultPort = "8971";
scheme = "https";
dependencies = [ "traefik.service" "${config.numbus.services.dns}.service" "home-assistant.service" ];
envFile = "/var/lib/numbus-server/home-assistant/.env";
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/dri:/dev/dri" "/dev/bus/usb:/dev/bus/usb" "/dev/apex_0:/dev/apex_0" ];
description = "List of devices to map into the container. /dev/dri is used for graphics acceleration, /dev/bus/usb for USB Coral TPUs, and /dev/apex_0 for PCI coral TPUs";
};
};
composeText = ''
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:${frigateVersion}
container_name: frigate
hostname: frigate
shm_size: "256mb"
networks:
home-assistant:
ports:
- "${cfg.port}:8971/tcp"
volumes:
- ${cfg.configDir}:/config
- ${cfg.dataDir}:/media/frigate
- /etc/localtime:/etc/localtime:ro
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
- FRIGATE_MQTT_USER=$HOME_ASSISTANT_MQTT_USER
- FRIGATE_MQTT_PASSWORD=$HOME_ASSISTANT_MQTT_PASSWORD
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
stop_grace_period: 30s
restart: unless-stopped
networks:
home-assistant:
external: true
'';
}
+85
View File
@@ -0,0 +1,85 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
giteaVersion = "1.25.4-rootless";
databaseVersion = "18-alpine";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.gitea;
in
helper.mkPodmanService {
description = "Gitea, your own self-hosted git platform";
name = "gitea";
pod = "gitea";
defaultPort = "3000";
dataDirEnabled = false;
generatedSecrets = {
DB_NAME = "xkcdpass -n 2 -d -";
DB_USERNAME = "xkcdpass -n 2 -d -";
DB_PASSWORD = "xkcdpass -n 8 -d -";
};
dirPermissions = [
"100999:users ${cfg.configDir}"
];
composeText = ''
services:
gitea-server:
image: docker.gitea.com/gitea:${giteaVersion}
container_name: gitea-server
hostname: gitea-server
user: '1000:1000'
networks:
gitea:
ports:
- "${cfg.port}:3000/tcp"
volumes:
- ${cfg.configDir}/data:/var/lib/gitea
- ${cfg.configDir}/config:/etc/gitea
- /etc/localtime:/etc/localtime:ro
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-database:5432
- GITEA__database__NAME=$DB_NAME
- GITEA__database__USER=$DB_USERNAME
- GITEA__database__PASSWD=$DB_PASSWORD
- GITEA__server__SSH_PORT=2424
- GITEA__server__ROOT_URL=${cfg.subdomain}.${config.numbus.services.domain}
depends_on:
- gitea-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
gitea-database:
image: docker.io/library/postgres:${databaseVersion}
container_name: gitea-database
hostname: gitea-database
user: '1000:1000'
networks:
gitea:
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql
environment:
- POSTGRES_USER=$DB_USERNAME
- POSTGRES_PASSWORD=$DB_PASSWORD
- POSTGRES_DB=$DB_NAME
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
volumes:
gitea_database:
name: gitea_database
networks:
gitea:
name: gitea
driver: bridge
'';
}
+148
View File
@@ -0,0 +1,148 @@
{ config, pkgs, lib, ... }:
with lib;
let
homeAssistantVersion = "2026.2.3";
mqttVersion = "2.1-alpine";
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.home-assistant;
in
helper.mkPodmanService {
description = "Home Assistant, libre house control and much more";
name = "home-assistant";
pod = "home-assistant";
defaultPort = "8123";
dataDirEnabled = false;
generatedSecrets = {
HOME_ASSISTANT_MQTT_USER = "xkcdpass -n 2 -d -";
HOME_ASSISTANT_MQTT_PASSWORD = "xkcdpass -n 8 -d -";
};
dirPermissions = [
"numbus-admin:users ${cfg.configDir}/home-assistant"
"100999:users ${cfg.configDir}/mqtt"
];
# Compose file good
composeText = ''
services:
home-assistant:
image: ghcr.io/home-assistant/home-assistant:${homeAssistantVersion}
container_name: home-assistant
hostname: home-assistant
networks:
home-assistant:
ports:
- "${cfg.port}:8123/tcp"
volumes:
- ${cfg.configDir}/home-assistant:/config
- /etc/localtime:/etc/localtime:ro
- /run/dbus:/run/dbus:ro
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
home-assistant-mqtt:
image: docker.io/library/eclipse-mosquitto:${mqttVersion}
container_name: home-assistant-mqtt
hostname: home-assistant-mqtt
user: '1000:1000'
networks:
home-assistant:
volumes:
- ${cfg.configDir}/mqtt:/mosquitto
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
home-assistant:
name: home-assistant
driver: bridge
'';
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/serial/by-id/Sonoff_Zigbee_3.0-id-port0:/dev/ttyUSB0" ];
description = "List of devices to map into the container. /dev/ttyUSB0 is used for Zigbee dongles";
};
};
extraConfig = {
systemd.services."${name}-quirk-1" = {
description = "Podman container quirk 1 : ${name}";
wantedBy = [ "multi-user.target" ];
after = [ "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.systemd ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
if [[ -e /var/lib/numbus-server/${name}/quirk-1.true ]]; then
exit 0
fi
until [[ -e ${cfg.configDir}/home-assistant/configuration.yaml ]]; do
sleep 15
done
cat << 'EOF' >> ${cfg.configDir}/home-assistant/configuration.yaml
http:
use_x_forwarded_for: true
trusted_proxies: ${config.numbus.networking.ipAddress}/24
zha:
EOF
systemctl restart ${name}.service
touch /var/lib/numbus-server/${name}/quirk-1.true
'';
};
};
systemd.services."${name}-quirk-2" = {
description = "Podman container quirk 2 : ${name}";
wantedBy = [ "multi-user.target" "${name}.service" ];
after = [ "${name}-secrets.service" ];
before = [ "${name}.service" "${name}-permissions.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.mosquitto ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
if [[ -e /var/lib/numbus-server/${name}/quirk-2.true ]]; then
exit 0
fi
cat << EOF >> ${cfg.configDir}/mqtt/mosquitto.conf
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
listener 1883
## Authentication ##
allow_anonymous false
password_file /mosquitto/password.txt
EOF
source /var/lib/numbus-server/${name}/.env
mosquitto_passwd -b ${cfg.configDir}/mqtt/password.txt "$HOME_ASSISTANT_MQTT_USER" "$HOME_ASSISTANT_MQTT_PASSWORD"
chmod 600 ${cfg.configDir}/mqtt/password.txt
touch /var/lib/numbus-server/${name}/quirk-2.true
'';
};
}
+123
View File
@@ -0,0 +1,123 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
immichVersion = "v2.5.6";
redisVersion = "9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63";
databaseVersion = "14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.${name};
# Container configuration
name = "immich";
in
helper.mkPodmanService {
description = "Immich, Google Photos but better";
inherit name;
pod = "immich";
defaultPort = "2283";
generatedSecrets = {
DB_NAME = "xkcdpass -n 2 -d -";
DB_USERNAME = "xkcdpass -n 2 -d -";
DB_PASSWORD = "xkcdpass -n 8 -d -";
};
importedSecrets = {
REDIS_HOSTNAME = "immich-redis";
DB_HOSTNAME = "immich-database";
UPLOAD_LOCATION = "${cfg.dataDir}";
DB_DATA_LOCATION = "${cfg.configDir}/database";
TZ = "${time.timeZone}";
};
dirPermissions = [
"100999:users ${cfg.dataDir}"
"100999:users ${cfg.configDir}"
];
# Compose file good
composeText = ''
services:
immich-server:
container_name: immich-server
hostname: immich-server
image: ghcr.io/immich-app/immich-server:${immichVersion}
user: '1000:1000'
networks:
immich:
ports:
- "${cfg.port}:2283/tcp"
volumes:
- $UPLOAD_LOCATION:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
- immich-redis
- immich-database
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-machine-learning:
container_name: immich-machine-learning
hostname: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:${immichVersion}
user: '1000:1000'
networks:
immich:
volumes:
- ${cfg.configDir}/model-cache:/cache
- ${cfg.configDir}/machine-learning-config:/usr/src/.config
- ${cfg.configDir}/machine-learning-cache:/usr/src/.cache/
env_file:
- .env
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-redis:
container_name: immich-redis
hostname: immich-redis
image: docker.io/valkey/valkey:${redisVersion}
user: '1000:1000'
networks:
immich:
healthcheck:
test: redis-cli ping || exit 1
restart: unless-stopped
immich-database:
container_name: immich-database
hostname: immich-database
image: ghcr.io/immich-app/postgres:${databaseVersion}
user: '1000:1000'
networks:
immich:
environment:
POSTGRES_PASSWORD: $DB_PASSWORD
POSTGRES_USER: $DB_USERNAME
POSTGRES_DB: $DB_NAME
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- $DB_DATA_LOCATION:/var/lib/postgresql/data
shm_size: 128mb
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
immich:
name: immich
driver: bridge
'';
}
+36
View File
@@ -0,0 +1,36 @@
{ config, pkgs, lib, ... }:
with lib;
let
it-toolsVersion = "2024.10.22-7ca5933";
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.it-tools;
in
helper.mkPodmanService {
description = "IT-tools, useful tools when doing IT";
name = "it-tools";
pod = "false";
defaultPort = "8880";
configDir = false;
dataDir = false;
# Compose file good
composeText = ''
services:
it-tools:
image: docker.io/corentinth/it-tools:${it-toolsVersion}
container_name: it-tools
hostname: it-tools
networks:
it-tools:
ports:
- "${cfg.port}:80/tcp"
restart: unless-stopped
networks:
it-tools:
name: it-tools
driver: bridge
'';
}
+199
View File
@@ -0,0 +1,199 @@
{ lib, config, pkgs }:
with lib;
{
mkPodmanService = {
name,
description,
defaultPort ? "0",
defaultSubdomain ? name,
pod ? name,
reverseProxied ? true,
composeText,
scheme ? "http",
middlewares ? [ "secureHeaders" ],
dependencies ? [ "traefik.service" "${config.numbus.services.dns}.service" ],
extraOptions ? {},
extraConfig ? {},
configDirEnabled ? true,
dataDirEnabled ? true,
startDelay ? 180,
dirPermissions ? [],
generatedSecrets ? {},
importedSecrets ? {},
envFile ? null,
}:
let
cfg = config.numbus.services.${name};
hasSecrets = (generatedSecrets != {}) || (importedSecrets != {});
envFilePath = if envFile == null then "/var/lib/numbus-server/${name}/.env" else envFile;
envFileArg = if hasSecrets || envFile != null then "--env-file ${envFilePath}" else "";
in
{
options.numbus.services.${name} = recursiveUpdate ({
enable = mkEnableOption description;
subdomain = mkOption {
type = types.str;
default = defaultSubdomain;
example = defaultSubdomain;
description = "The subdomain that ${name} will use";
};
port = mkOption {
type = types.str;
default = defaultPort;
example = defaultPort;
description = "The port that ${name} will use.";
};
reverseProxied = mkOption {
type = types.bool;
default = reverseProxied;
example = reverseProxied;
description = "Whether to create a Traefik reverse proxy configuration for this service.";
};
} // (optionalAttrs configDirEnabled {
configDir = mkOption {
type = types.str;
default = "/mnt/config/${name}";
example = "/mnt/config/${name}";
description = "The directory where ${name}'s configuration files will be stored";
};
}) // (optionalAttrs dataDirEnabled {
dataDir = mkOption {
type = types.str;
default = "/mnt/data/${name}";
example = "/mnt/data/${name}";
description = "The directory where ${name}'s data will be stored";
};
})) extraOptions;
config = mkIf cfg.enable (mkMerge [
{
environment.etc."podman/${name}/compose.yaml".text = composeText;
environment.etc."${config.numbus.traefikDynamicConfigDir}/${name}.yaml" = mkIf cfg.reverseProxied {
text = ''
http:
routers:
${name}:
rule: "Host(`${cfg.subdomain}.${config.numbus.services.domain}`)"
entrypoints:
- "websecure"
service: ${name}
middlewares:
${concatStringsSep "\n" (map (m: " - ${m}") middlewares)}
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
${name}:
loadBalancer:
servers:
- url: "${scheme}://host.containers.internal:${cfg.port}"
'';
};
systemd.services."${name}" = {
description = "Podman container : ${name}";
requires = dependencies;
after = dependencies;
wantedBy = [ "multi-user.target" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.podman pkgs.podman-compose pkgs.coreutils pkgs.sudo ];
serviceConfig = {
Type = "exec";
ExecStartPre = [
"bash -c 'sleep $((RANDOM % ${toString startDelay}))'"
"- sudo -u numbus-admin podman-compose ${envFileArg} -f /etc/podman/${name}/compose.yaml pull"
];
ExecStart = "sudo -u numbus-admin podman-compose ${envFileArg} --in-pod ${toString pod} -f /etc/podman/${name}/compose.yaml up --remove-orphans";
ExecStop = "sudo -u numbus-admin podman-compose ${envFileArg} --in-pod ${toString pod} -f /etc/podman/${name}/compose.yaml down";
Restart = "on-failure";
RestartSec = "1m";
};
};
systemd.services."${name}-permissions" = mkIf dirPermissions != [] {
description = "Podman container : ${name} : check and fix permissions";
before = [ "${name}.service" ];
wantedBy = [ "multi-user.target" "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "1m";
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
${concatStringsSep "\n" (map (perm: ''
set -- ${perm}
MARKER="/var/lib/numbus-server/${name}/.perm-fixed-$(echo "$1:$2" | md5sum | cut -d' ' -f1)"
if [ ! -f "$MARKER" ]; then
rm -f /var/lib/numbus-server/${name}/.perm-fixed-*
mkdir -p "$2"
chown -R "$1" "$2"
touch "$MARKER"
fi
'') dirPermissions)}
exit 0
'';
};
systemd.services."${name}-secrets" = mkIf hasSecrets {
description = "Podman container create secrets : ${name}";
before = [ "${name}.service" ];
wantedBy = [ "multi-user.target" "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.xkcdpass pkgs.gnugrep ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "1m";
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
SECRETS_FILE="${envFilePath}"
if [[ ! -f "$SECRETS_FILE" ]]; then
touch "$SECRETS_FILE"
fi
# Generated Secrets (only if missing)
${concatStringsSep "\n" (mapAttrsToList (k: v: ''
if ! grep -q "^${k}=" "$SECRETS_FILE"; then
echo "${k}=$(${v})" >> "$SECRETS_FILE"
fi
'') generatedSecrets)}
# Imported Secrets (update or append)
${concatStringsSep "\n" (mapAttrsToList (k: v: ''
if grep -q "^${k}=" "$SECRETS_FILE"; then
grep -v "^${k}=" "$SECRETS_FILE" > "$SECRETS_FILE.tmp"
mv "$SECRETS_FILE.tmp" "$SECRETS_FILE"
fi
echo "${k}=${lib.escapeShellArg v}" >> "$SECRETS_FILE"
'') importedSecrets)}
chown numbus-admin:users "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
'';
};
}
extraConfig
]);
};
}
+261
View File
@@ -0,0 +1,261 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
nextcloudVersion = "32.0.6";
redisVersion = "8.6-alpine";
databaseVersion = "11.8";
onlyofficeVersion = "9.2";
whiteboardVersion = "v1.5.6";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.nextcloud;
in
helper.mkPodmanService {
description = "Nextcloud, your own online office suite";
name = "nextcloud";
pod = "nextcloud";
defaultPort = "1100";
generatedSecrets = {
DB_NAME = "xkcdpass -n 2 -d -";
DB_USERNAME = "xkcdpass -n 2 -d -";
DB_PASSWORD = "xkcdpass -n 10 -d -";
REDIS_PASSWORD = "xkcdpass -n 10 -d -";
ONLYOFFICE_PASSWORD = "xkcdpass -n 10 -d -";
WHITEBOARD_PASSWORD = "xkcdpass -n 10 -d -";
SMTP_PASSWORD = "cat ${config.numbus.mail.smtpPasswordPath}";
};
dirPermissions = [
"100032:users ${cfg.configDir}/web"
"100999:users ${cfg.configDir}/redis"
"100999:users ${cfg.configDir}/database"
"100999:users ${cfg.configDir}/onlyoffice"
"100032:users ${cfg.dataDir}"
];
# Compose file good
composeText = ''
services:
nextcloud-server:
image: docker.io/library/nextcloud:${nextcloudVersion}
container_name: nextcloud-server
hostname: nextcloud-server
networks:
nextcloud:
ports:
- "${cfg.port}:80/tcp"
volumes:
- ${cfg.configDir}/web:/var/www/html
- ${cfg.dataDir}:/mnt/ncdata
environment:
MYSQL_HOST: nextcloud-database
MYSQL_DATABASE: $DB_NAME
MYSQL_USER: $DB_USERNAME
MYSQL_PASSWORD: $DB_PASSWORD
REDIS_HOST: nextcloud-redis
REDIS_HOST_PASSWORD: $REDIS_PASSWORD
NEXTCLOUD_TRUSTED_DOMAINS: ${cfg.subdomain}.${config.numbus.services.domain}
NEXTCLOUD_DATA_DIR: /mnt/ncdata
SMTP_SECURE: tls
SMTP_HOST: ${config.numbus.mail.smtpServer}
SMTP_PORT: ${config.numbus.mail.smtpPort}
SMTP_NAME: ${config.numbus.mail.smtpUsername}
SMTP_PASSWORD: $SMTP_PASSWORD
MAIL_FROM_ADDRESS: nextcloud-noreply
MAIL_DOMAIN: ${config.numbus.services.domain}
APACHE_DISABLE_REWRITE_IP: 1
TRUSTED_PROXIES: ${config.numbus.networking.ipAddress}
OVERWRITEPROTOCOL: https
NC_default_phone_region: "${config.numbus.language}"
NC_default_language: "${config.numbus.language}"
NC_default_locale: "${config.numbus.locale}"
NC_default_timezone: "${time.timeZone}"
NC_maintenance_window_start: "1"
depends_on:
- nextcloud-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-redis:
image: docker.io/library/redis:${redisVersion}
container_name: nextcloud-redis
hostname: nextcloud-redis
user: '1000:1000'
networks:
nextcloud:
volumes:
- ${cfg.configDir}/redis:/data
command: redis-server --requirepass $REDIS_PASSWORD --save 60 1 --loglevel warning
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: nextcloud-database
hostname: nextcloud-database
user: '1000:1000'
networks:
nextcloud:
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MARIADB_DATABASE: $MYSQL_DATABASE
MARIADB_USER: $MYSQL_USER
MARIADB_PASSWORD: $MYSQL_PASSWORD
MARIADB_RANDOM_ROOT_PASSWORD: true
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
restart: unless-stopped
nextcloud-onlyoffice:
container_name: nextcloud-onlyoffice
hostname: nextcloud-onlyoffice
image: docker.io/onlyoffice/documentserver:${onlyofficeVersion}
environment:
- JWT_SECRET=$ONLYOFFICE_PASSWORD
ports:
- "9980:80/tcp"
volumes:
- ${cfg.configDir}/onlyoffice/log:/var/log/onlyoffice
- ${cfg.configDir}/onlyoffice/cache:/var/lib/onlyoffice
- ${cfg.configDir}/onlyoffice/database:/var/lib/postgresql
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-whiteboard:
image: ghcr.io/nextcloud-releases/whiteboard:${whiteboardVersion}
container_name: nextcloud-whiteboard
hostname: nextcloud-whiteboard
user: '1000:1000'
ports:
- "3002:3002/tcp"
environment:
NEXTCLOUD_URL: https://${cfg.subdomain}.${config.numbus.services.domain}
JWT_SECRET_KEY: $WHITEBOARD_PASSWORD
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
nextcloud:
name: nextcloud
driver: bridge
'';
extraConfig = {
environment.etc."${config.numbus.traefikDynamicConfigDir}/nextcloud-onlyoffice.yaml".text = ''
http:
routers:
nextcloud-onlyoffice:
rule: "Host(`onlyoffice.${config.numbus.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-onlyoffice
middlewares:
- "secureHeaders"
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-onlyoffice:
loadBalancer:
servers:
- url: "http://host.containers.internal:9980"
'';
environment.etc."${config.numbus.traefikDynamicConfigDir}/nextcloud-whiteboard.yaml".text = ''
http:
routers:
nextcloud-whiteboard:
rule: "Host(`whiteboard.${config.numbus.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-whiteboard
middlewares:
- "secureHeaders"
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-whiteboard:
loadBalancer:
servers:
- url: "http://host.containers.internal:3002"
'';
systemd.services."${name}-quirk" = {
description = "Podman container quirk : ${name}";
wantedBy = [ "multi-user.target" ];
after = [ "${name}.service" "${name}-secrets.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.sudo pkgs.podman ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
if [[ -e /var/lib/numbus-server/${name}/quirk.true ]]; then
exit 0
fi
source /var/lib/numbus-server/${name}/.env
sleep 300
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ background:cron
sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ db:add-missing-indices
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ maintenance:repair --include-expensive
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ files:scan --all
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ files:repair-tree
for app in calendar contacts mail note onlyoffice cookbook whiteboard; do
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ app:install $app
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ app:enable $app
done
for app in activity app_api federatedfilesharing federation webhook_listeners photos recommendations sharebymail teams support richdocumentscode; do
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ app:disable $app
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ app:remove $app
done
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ config:system:set onlyoffice DocumentServerInternalUrl --value="https://onlyoffice.${config.numbus.services.domain}/"
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ config:system:set onlyoffice DocumentServerUrl --value="https://onlyoffice.${config.numbus.services.domain}/"
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ config:system:set onlyoffice jwt_secret --value="$ONLYOFFICE_PASSWORD"
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ config:app:set whiteboard collabBackendUrl --value="https://whiteboard.${config.numbus.services.domain}"
sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ config:app:set whiteboard jwt_secret_key --value="$WHITEBOARD_PASSWORD"
touch /var/lib/numbus-server/${name}/quirk.true
'';
};
systemd.services."${name}-cron" = {
description = "Podman container crontab : ${name}";
after = [ "${name}.service" "${name}-quirk.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
path = [ pkgs.coreutils pkgs.sudo pkgs.podman ];
serviceConfig = {
Type = "oneshot";
ExecStart = "sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php";
};
};
systemd.timers."${name}-cron" = {
description = "Timer for Nextcloud cron";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5m";
OnUnitActiveSec = "5m";
Unit = "${name}-cron.service";
};
};
};
}
+103
View File
@@ -0,0 +1,103 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
passboltVersion = "5.9.0-1-ce-non-root";
databaseVersion = "12.2";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.passbolt;
in
helper.mkPodmanService {
description = "Passbolt, your password manager";
name = "passbolt";
pod = "passbolt";
defaultPort = "4433";
scheme = "https";
dataDirEnabled = false;
generatedSecrets = {
DB_NAME = "xkcdpass -n 2 -d -";
DB_USERNAME = "xkcdpass -n 2 -d -";
DB_PASSWORD = "xkcdpass -n 10 -d -";
SMTP_PASSWORD = "cat ${config.numbus.mail.smtpPasswordPath}";
};
dirPermissions = [
"100032:users ${cfg.configDir}/gpg"
"100032:users ${cfg.configDir}/jwt"
"100999:users ${cfg.configDir}/database"
];
# Compose file good
composeText = ''
services:
passbolt-server:
image: docker.io/passbolt/passbolt:${passboltVersion}
container_name: passbolt-server
hostname: passbolt-server
user: '33:33'
networks:
passbolt:
ports:
- "${cfg.port}:4433/tcp"
volumes:
- ${cfg.configDir}/gpg:/etc/passbolt/gpg
- ${cfg.configDir}/jwt:/etc/passbolt/jwt
environment:
APP_DEFAULT_TIMEZONE: ${time.timeZone}
APP_FULL_BASE_URL: https://${cfg.subdomain}.${config.numbus.services.domain}
DATASOURCES_DEFAULT_HOST: "passbolt-database"
DATASOURCES_DEFAULT_USERNAME: $DB_USERNAME
DATASOURCES_DEFAULT_PASSWORD: $DB_PASSWORD
DATASOURCES_DEFAULT_DATABASE: $DB_NAME
EMAIL_DEFAULT_FROM_NAME: "Passbolt"
EMAIL_TRANSPORT_DEFAULT_HOST: ${config.numbus.mail.smtpServer}
EMAIL_TRANSPORT_DEFAULT_PORT: ${config.numbus.mail.smtpPort}
EMAIL_TRANSPORT_DEFAULT_USERNAME: ${config.numbus.mail.smtpUsername}
EMAIL_TRANSPORT_DEFAULT_PASSWORD: $EMAIL_TRANSPORT_DEFAULT_PASSWORD
EMAIL_TRANSPORT_DEFAULT_TLS: true
EMAIL_DEFAULT_FROM: passbolt-noreply@${config.numbus.services.domain}
PASSBOLT_SSL_FORCE: true
command:
[
"/usr/bin/wait-for.sh",
"-t",
"0",
"passbolt-database:3306",
"--",
"/docker-entrypoint.sh",
]
depends_on:
- passbolt-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
passbolt-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: passbolt-database
hostname: passbolt-database
user: '1000:1000'
networks:
passbolt:
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_DATABASE: $DB_NAME
MYSQL_USER: $DB_USERNAME
MYSQL_PASSWORD: $DB_PASSWORD
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
passbolt:
name: passbolt
driver: bridge
'';
}
+67
View File
@@ -0,0 +1,67 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
piholeVersion = "2026.02.0";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.pi-hole;
in
helper.mkPodmanService {
description = "Pi-Hole, the ads black hole";
name = "pi-hole";
defaultPort = "4443";
scheme = "https";
dependencies = [ "network.target" "multi-user.target" ];
dataDir = false;
delaySec = 10;
generatedSecrets = {
PIHOLE_PASSWORD = "xkcdpass -n 10 -d -";
};
dirPermissions = [
"numbus-admin:users ${cfg.configDir}"
];
# Compose file good
composeText = ''
services:
pi-hole:
image: docker.io/pihole/pihole:${piholeVersion}
container_name: pi-hole
hostname: pi-hole
network_mode: pasta
ports:
- "${cfg.port}:443/tcp"
- "53:53/tcp"
- "53:53/udp"
volumes:
- ${cfg.configDir}:/etc/pihole
environment:
PIHOLE_UID: '1000'
PIHOLE_GID: '1000'
TZ: ${time.timeZone}
FTLCONF_webserver_api_password: $PIHOLE_PASSWORD
FTLCONF_webserver_domain: ${cfg.subdomain}.${config.numbus.services.domain}
FTLCONF_dns_upstreams: 9.9.9.9;149.112.112.112
FTLCONF_dns_hosts: |
${lib.concatStringsSep "" (lib.mapAttrsToList (name: service:
if builtins.isAttrs service && service ? enable && service.enable && service ? subdomain then
" ${config.numbus.networking.ipAddress} ${service.subdomain}.${config.numbus.services.domain}\n"
else
""
) config.numbus.services)}
FTLCONF_dns_listeningMode: "BIND"
FTLCONF_dns_domain_name: "${config.numbus.services.domain}"
FTLCONF_dns_domain_local: "true"
FTLCONF_dhcp_active: "false"
FTLCONF_ntp_ipv4_active: "false"
FTLCONF_ntp_ipv6_active: "false"
FTLCONF_ntp_sync_active: "false"
cap_add:
- SYS_NICE
restart: unless-stopped
'';
}
+153
View File
@@ -0,0 +1,153 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
traefikVersion = "v3.6.8";
# Helper
helper = import ./lib.nix { inherit config pkgs lib; };
cfg = config.numbus.services.traefik;
in
helper.mkPodmanService {
description = "Traefik reverse proxy, one to rule them all";
name = "traefik";
reverseProxied = false;
dependencies = [ "network.target" "multi-user.target" ];
dataDir = false;
delaySec = 10;
generatedSecrets = {
CLOUDFLARE_DNS_API_TOKEN = "cat ${config.numbus.mail.smtpPasswordPath}";
};
dirPermissions = [
"100999:users ${cfg.configDir}"
"100999:users /etc/${cfg.staticConfigFile}"
"100999:users ${config.numbus.traefikDynamicConfigDir}"
];
# Compose file good
composeText = ''
services:
traefik:
image: docker.io/library/traefik:${traefikVersion}
container_name: traefik
hostname: traefik
user: '1000:1000'
network_mode: pasta
ports:
- "80:80/tcp"
- "443:443/tcp"
volumes:
- /etc/${cfg.staticConfigFile}:/etc/traefik/traefik.yaml:ro
- ${config.numbus.traefikDynamicConfigDir}:/etc/traefik/conf:ro
- ${cfg.configDir}:/var/traefik/certs:rw
environment:
- CF_DNS_API_TOKEN=$CLOUDFLARE_DNS_API_TOKEN
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
restart: unless-stopped
'';
extraConfig = {
environment.etc."${cfg.staticConfigFile}".text = ''
global:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: ${cfg.logLevel}
accesslog: {}
api:
dashboard: false
insecure: false
entryPoints:
web:
address: :80
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: :443
forwardedHeaders:
trustedIPs:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "192.168.0.0/16"
- "172.16.0.0/12"
certificatesResolvers:
cloudflare:
acme:
email: ${config.numbus.email.administratorEmail}
storage: /var/traefik/certs/cloudflare-acme.json
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "9.9.9.9:53"
serversTransport:
insecureSkipVerify: true
providers:
file:
directory: "/etc/traefik/conf/"
watch: true
'';
environment.etc."${config.numbus.traefikDynamicConfigDir}/secureHeaders.yaml".text = ''
http:
middlewares:
secureHeaders:
headers:
FrameDeny: true
AccessControlAllowMethods: 'GET,OPTIONS,PUT'
AccessControlAllowOriginList:
- origin-list-or-null
AccessControlMaxAge: 100
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSIncludeSubdomains: true
STSPreload: true
ContentSecurityPolicy: default-src 'self' 'unsafe-inline'
CustomFrameOptionsValue: SAMEORIGIN
ReferrerPolicy: same-origin
PermissionsPolicy: vibrate 'self'
STSSeconds: 315360000
'';
environment.etc."${config.numbus.traefikDynamicConfigDir}/secureTLS.yaml".text = ''
tls:
options:
secureTLS:
minVersion: VersionTLS12
sniStrict: true
curvePreferences:
- CurveP521
- CurveP384
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
'';
};
extraOptions = {
enable.default = true;
staticConfigFile = mkOption {
type = types.str;
default = "traefik/config.yaml";
description = "The path for Traefik's static configuration file, relative to /etc/";
};
logLevel = mkOption {
type = types.enum [ "TRACE" "DEBUG" "INFO" "WARN" "ERROR" "FATAL" ];
default = "ERROR";
description = "The level of detail Traefik should print in the logs.";
};
# traefikDynamicConfigDir defined at global.nix
};
}