first commit

This commit is contained in:
2026-05-05 08:30:51 +02:00
commit c0e781bf00
107 changed files with 7024 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
{config, ...}: let
serviceName = "baserow";
servicePort = config.m3ta.ports.get serviceName;
in {
virtualisation.oci-containers.containers.${serviceName} = {
image = "docker.io/baserow/baserow:2.1.6";
environment = {
BASEROW_AMOUNT_OF_GUNICORN_WORKERS = "4";
BASEROW_AMOUNT_OF_WORKERS = "2";
DATABASE_CONN_MAX_AGE = "60";
# Proxy: tell Django the connection is HTTPS so cookies get Secure flag
BASEROW_ENABLE_SECURE_PROXY_SSL_HEADER = "yes";
# Published apps run on different origins — allow cross-origin cookie delivery
BASEROW_FRONTEND_SAME_SITE_COOKIE = "none";
# Valid base domain for published app subdomains
BASEROW_BUILDER_DOMAINS = "az-gruppe.com";
# Disable Caddy's on_demand TLS — Traefik handles TLS termination
BASEROW_CADDY_GLOBAL_CONF = "auto_https off";
};
environmentFiles = [config.age.secrets.baserow-env.path];
ports = ["127.0.0.1:${toString servicePort}:80"];
volumes = ["baserow_data:/baserow/data"];
extraOptions = ["--add-host=postgres:10.89.0.1" "--ip=10.89.0.10" "--network=web"];
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
middlewares."${serviceName}-headers".headers = {
customRequestHeaders = {
X-Forwarded-Proto = "https";
X-Forwarded-Port = "443";
};
};
routers.${serviceName} = {
rule = "Host(`br.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
middlewares = ["${serviceName}-headers"];
};
routers.azubi = {
rule = "Host(`azubi.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
middlewares = ["${serviceName}-headers"];
};
routers.ausbilder = {
rule = "Host(`ausbilder.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
middlewares = ["${serviceName}-headers"];
};
};
}

View File

@@ -0,0 +1,20 @@
{lib, ...}: {
imports = [
./baserow.nix
./it-tools.nix
./librechat.nix
./litellm.nix
./librechat-dev.nix
./netbird.nix
./portainer.nix
./zammad-hr.nix
];
system.activationScripts.createPodmanNetworkWeb = lib.mkAfter ''
if ! /run/current-system/sw/bin/podman network exists web; then
/run/current-system/sw/bin/podman network create web --subnet=10.89.0.0/24 --internal
fi
if ! /run/current-system/sw/bin/podman network exists web-dev; then
/run/current-system/sw/bin/podman network create web-dev --subnet=10.89.1.0/24 --internal
fi
'';
}

View File

@@ -0,0 +1,27 @@
{config, ...}: let
serviceName = "it-tools";
servicePort = config.m3ta.ports.get serviceName;
in {
virtualisation.oci-containers.containers.${serviceName} = {
image = "docker.io/sharevb/it-tools:latest";
ports = ["127.0.0.1:${toString servicePort}:8080"];
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
routers.${serviceName} = {
rule = "Host(`tools.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
};
};
}

View File

@@ -0,0 +1,133 @@
{
config,
pkgs,
...
}: let
serviceName = "librechat-dev";
servicePort = config.m3ta.ports.get serviceName;
ragApiDevServiceName = "rag-api-dev";
ragApiDevPort = config.m3ta.ports.get ragApiDevServiceName;
envFileDev = config.age.secrets.librechat-env-dev.path;
envFileCommon = config.age.secrets.librechat.path;
in {
virtualisation.oci-containers = {
containers.meilisearch-dev = {
image = "getmeili/meilisearch:v1.12.3";
autoStart = false;
volumes = ["librechat_dev_meili:/meili_data"];
environment = {
MEILI_HTTP_ADDR = "0.0.0.0:7700";
MEILI_NO_ANALYTICS = "true";
};
environmentFiles = [envFileDev envFileCommon];
extraOptions = ["--ip=10.89.1.20" "--network=web-dev"];
};
containers.rag_api-dev = {
image = "ghcr.io/danny-avila/librechat-rag-api-dev-lite:latest";
autoStart = false;
environment = {
RAG_PORT = "8000";
DB_HOST = "10.89.1.1";
DB_PORT = "5432";
};
environmentFiles = [envFileDev envFileCommon];
dependsOn = ["meilisearch-dev"];
extraOptions = ["--add-host=postgres:10.89.1.1" "--ip=10.89.1.21" "--network=web-dev"];
ports = ["127.0.0.1:${toString ragApiDevPort}:8000"];
};
containers.mongodb-dev = {
image = "mongo:7";
autoStart = false;
volumes = [
"librechat_dev_mongo:/data/db"
"/var/backup/mongodb-dev:/data/backups"
];
extraOptions = ["--ip=10.89.1.22" "--network=web-dev"];
};
containers.${serviceName} = {
image = "ghcr.io/danny-avila/librechat-dev-api:latest";
autoStart = false;
ports = ["127.0.0.1:${toString servicePort}:3080"];
dependsOn = ["mongodb-dev" "rag_api-dev" "meilisearch-dev"];
environment = {
HOST = "0.0.0.0";
NODE_ENV = "development";
MONGO_URI = "mongodb://mongodb-dev:27017/LibreChatDev";
MEILI_HOST = "http://meilisearch-dev:7700";
RAG_PORT = "8000";
RAG_API_URL = "http://rag_api-dev:8000";
};
environmentFiles = [envFileDev envFileCommon];
volumes = [
"/var/lib/librechat-dev/librechat.yaml:/app/librechat.yaml:ro"
"librechat_dev_images:/app/client/public/images"
"librechat_dev_uploads:/app/uploads"
"librechat_dev_logs:/app/api/logs"
];
extraOptions = ["--ip=10.89.1.23" "--network=web-dev"];
};
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
routers.${serviceName} = {
rule = "Host(`chat-dev.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
};
};
environment.systemPackages = [
(pkgs.writeShellScriptBin "librechat-dev" ''
#!/usr/bin/env bash
set -e
SERVICES=(
podman-meilisearch-dev
podman-mongodb-dev
podman-rag_api-dev
podman-librechat-dev
)
case "$1" in
up)
echo "🚀 Starte LibreChat-Dev-Umgebung..."
for svc in "''${SERVICES[@]}"; do
sudo systemctl start "$svc"
done
;;
down)
echo "🛑 Stoppe LibreChat-Dev-Umgebung..."
for svc in "''${SERVICES[@]}"; do
sudo systemctl stop "$svc"
done
;;
restart)
echo "🔄 Neustart der LibreChat-Dev-Umgebung..."
for svc in "''${SERVICES[@]}"; do
sudo systemctl restart "$svc"
done
;;
status)
systemctl status "''${SERVICES[@]}"
;;
*)
echo "Usage: librechat-dev {up|down|restart|status}"
exit 1
;;
esac
'')
];
}

View File

@@ -0,0 +1,169 @@
{
config,
pkgs,
...
}: let
serviceName = "librechat";
servicePort = config.m3ta.ports.get serviceName;
ragApiServiceName = "rag-api";
ragApiPort = config.m3ta.ports.get ragApiServiceName;
envFileProd = config.age.secrets.librechat-env-prod.path;
envFileCommon = config.age.secrets.librechat.path;
in {
virtualisation.oci-containers = {
containers.meilisearch = {
image = "getmeili/meilisearch:v1.35.1";
autoStart = true;
volumes = ["librechat_meili:/meili_data"];
environment = {
MEILI_HTTP_ADDR = "0.0.0.0:7700";
MEILI_NO_ANALYTICS = "true";
};
environmentFiles = [envFileCommon envFileProd];
extraOptions = ["--ip=10.89.0.20" "--network=web"];
};
containers.rag_api = {
image = "registry.librechat.ai/danny-avila/librechat-rag-api-dev-lite:latest";
autoStart = true;
environment = {
RAG_PORT = "8000";
DB_HOST = "10.89.0.1";
DB_PORT = "5432";
};
environmentFiles = [envFileCommon envFileProd];
dependsOn = ["meilisearch"];
extraOptions = ["--add-host=postgres:10.89.0.1" "--ip=10.89.0.21" "--network=web"];
ports = ["127.0.0.1:${toString ragApiPort}:8000"];
};
containers.mongodb = {
image = "mongo:8.0.17";
autoStart = true;
volumes = [
"librechat_mongo:/data/db"
"/var/backup/mongodb:/data/backups"
];
# Enable auth once users exist; see Mongo auth doc.
# command = [ "mongod", "--auth" ];
extraOptions = ["--ip=10.89.0.22" "--network=web"];
};
containers.${serviceName} = {
image = "registry.librechat.ai/danny-avila/librechat-dev:latest";
autoStart = true;
user = "1000:1000";
ports = ["127.0.0.1:${toString servicePort}:3080"];
dependsOn = ["mongodb" "rag_api" "meilisearch"];
environment = {
HOST = "0.0.0.0";
NODE_ENV = "production";
# Mongo URI (start without auth; switch to mongodb://user:pass@mongodb:27017/LibreChat after Step 4)
MONGO_URI = "mongodb://mongodb:27017/LibreChat";
MEILI_HOST = "http://meilisearch:7700";
RAG_PORT = "8000";
RAG_API_URL = "http://rag_api:8000";
};
environmentFiles = [envFileCommon envFileProd];
volumes = [
# Config file still needs to be a bind mount for host management
"/var/lib/librechat/librechat.yaml:/app/librechat.yaml:ro"
# Use named volumes for application data
"librechat_images:/app/client/public/images"
"librechat_uploads:/app/uploads"
"librechat_logs:/app/api/logs"
];
extraOptions = ["--ip=10.89.0.23" "--network=web" "--dns=8.8.8.8" "--dns=8.8.4.4"];
};
};
systemd.services."mongo-backup" = {
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
BACKUP_DIR="/var/backup/mongodb"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TEMP_BACKUP="mongodb_backup_$TIMESTAMP"
ARCHIVE_NAME="mongodb_backup_$TIMESTAMP.tar.gz"
# Ensure backup directory exists with proper permissions
mkdir -p "$BACKUP_DIR"
chown root:root "$BACKUP_DIR"
chmod 750 "$BACKUP_DIR"
echo "Starting MongoDB backup at $(date)"
# Create the backup dump in container
if ${pkgs.podman}/bin/podman exec mongodb mongodump --out "/data/backups/$TEMP_BACKUP"; then
echo "MongoDB dump completed successfully"
# Create compressed archive from the backup
cd "$BACKUP_DIR"
if [ -d "$TEMP_BACKUP" ]; then
echo "Creating compressed archive: $ARCHIVE_NAME"
${pkgs.gnutar}/bin/tar --use-compress-program=${pkgs.gzip}/bin/gzip -cf "$ARCHIVE_NAME" -C . "$TEMP_BACKUP"
# Remove the uncompressed backup directory
rm -rf "$TEMP_BACKUP"
# Verify archive was created
if [ -f "$ARCHIVE_NAME" ]; then
ARCHIVE_SIZE=$(${pkgs.coreutils}/bin/du -sh "$ARCHIVE_NAME" | cut -f1)
echo "Compressed backup created: $ARCHIVE_NAME (Size: $ARCHIVE_SIZE)"
# Keep only the 2 most recent backup archives
ls -1t mongodb_backup_*.tar.gz | tail -n +3 | xargs -r rm -f
echo "Old backup archives cleaned up, keeping 2 most recent"
# List current backups
echo "Current backups:"
ls -lah mongodb_backup_*.tar.gz 2>/dev/null || echo "No previous backups found"
else
echo "ERROR: Failed to create compressed archive" >&2
exit 1
fi
else
echo "ERROR: Backup directory not found at $BACKUP_DIR/$TEMP_BACKUP" >&2
exit 1
fi
else
echo "ERROR: MongoDB backup failed" >&2
exit 1
fi
echo "MongoDB backup completed successfully at $(date)"
'';
};
systemd.timers."mongo-backup" = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* 02:00:00";
RandomizedDelaySec = "30m";
Persistent = true;
};
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
routers.${serviceName} = {
rule = "Host(`chat.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
};
};
}

View File

@@ -0,0 +1,37 @@
{config, ...}: let
serviceName = "litellm";
servicePort = config.m3ta.ports.get serviceName;
in {
virtualisation.oci-containers.containers.${serviceName} = {
#image = "ghcr.io/berriai/litellm:v1.78.5-stable";
image = "docker.litellm.ai/berriai/litellm:v1.82.3-stable";
ports = ["127.0.0.1:${toString servicePort}:4000"];
environmentFiles = [config.age.secrets.litellm-env.path];
environment = {
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
STORE_MODEL_IN_DB = "True";
};
volumes = ["/var/lib/litellm/config.yaml:/app/config.yaml"];
extraOptions = ["--add-host=postgres:10.89.0.1" "--ip=10.89.0.30" "--network=web"];
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
routers.${serviceName} = {
rule = "Host(`llm.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
};
};
}

View File

@@ -0,0 +1,243 @@
{
config,
pkgs,
...
}: let
serviceName = "netbird";
servicePort = config.m3ta.ports.get serviceName;
domain = "v.az-gruppe.com";
proxyDomain = "p.az-gruppe.com";
ipBase = "10.89.0";
ipOffset = 50;
# Derived IPs
gatewayIp = "${ipBase}.1";
dashboardIp = "${ipBase}.${toString ipOffset}";
serverIp = "${ipBase}.${toString (ipOffset + 1)}";
proxyIp = "${ipBase}.${toString (ipOffset + 2)}";
# Database configuration
dbName = "netbird";
dbUser = "netbird";
dbHost = gatewayIp;
# NetBird config as Nix attribute set
netbirdConfig = {
server = {
listenAddress = ":80";
exposedAddress = "https://${domain}:443";
stunPorts = [3478];
metricsPort = 9090;
healthcheckAddress = ":9000";
logLevel = "info";
logFile = "console";
dataDir = "/var/lib/netbird";
auth = {
issuer = "https://${domain}/oauth2";
localAuthDisabled = true;
signKeyRefreshEnabled = true;
dashboardRedirectURIs = [
"https://${domain}/nb-auth"
"https://${domain}/nb-silent-auth"
];
cliRedirectURIs = ["http://localhost:53000/"];
};
reverseProxy = {
trustedHTTPProxies = ["${gatewayIp}/32"];
};
# Proxy Feature
proxy = {
enabled = true;
domain = proxyDomain;
};
store = {
engine = "postgres";
postgres = {
host = dbHost;
port = 5432;
database = dbName;
username = dbUser;
};
};
};
};
# Generate YAML config
yamlFormat = pkgs.formats.yaml {};
configYamlBase = yamlFormat.generate "netbird-config-base.yaml" netbirdConfig;
# Script to inject secrets at runtime
configGenScript = pkgs.writeShellScript "netbird-gen-config" ''
set -euo pipefail
AUTH_SECRET=$(cat "$1")
DB_PASSWORD=$(cat "$2")
ENCRYPTION_KEY=$(cat "$3")
${pkgs.yq-go}/bin/yq eval "
.server.authSecret = \"$AUTH_SECRET\" |
.server.store.encryptionKey = \"$ENCRYPTION_KEY\" |
.server.store.postgres.password = \"$DB_PASSWORD\"
" ${configYamlBase}
'';
in {
age.secrets."${serviceName}-auth-secret".file = ../../../../secrets/${serviceName}-auth-secret.age;
age.secrets."${serviceName}-db-password".file = ../../../../secrets/${serviceName}-db-password.age;
age.secrets."${serviceName}-encryption-key".file = ../../../../secrets/${serviceName}-encryption-key.age;
age.secrets."${serviceName}-dashboard-env".file = ../../../../secrets/${serviceName}-dashboard-env.age;
age.secrets."${serviceName}-server-env".file = ../../../../secrets/${serviceName}-server-env.age;
age.secrets."${serviceName}-proxy-env".file = ../../../../secrets/${serviceName}-proxy-env.age;
# Systemd oneshot service to generate config with secrets
systemd.services."${serviceName}-config" = {
description = "Generate NetBird config with secrets";
wantedBy = ["multi-user.target"];
before = ["podman-${serviceName}-server.service"];
requiredBy = ["podman-${serviceName}-server.service"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "netbird-write-config" ''
mkdir -p /var/lib/${serviceName}
${configGenScript} \
${config.age.secrets."${serviceName}-auth-secret".path} \
${config.age.secrets."${serviceName}-db-password".path} \
${config.age.secrets."${serviceName}-encryption-key".path} \
> /var/lib/${serviceName}/config.yaml
chmod 600 /var/lib/${serviceName}/config.yaml
'';
};
};
virtualisation.oci-containers.containers = {
"${serviceName}-dashboard" = {
image = "netbirdio/dashboard:latest";
autoStart = true;
ports = ["127.0.0.1:${toString servicePort}:80"];
environmentFiles = [config.age.secrets."${serviceName}-dashboard-env".path];
extraOptions = [
"--ip=${dashboardIp}"
"--network=web"
];
};
"${serviceName}-server" = {
image = "netbirdio/netbird-server:latest";
autoStart = true;
ports = ["3478:3478/udp"];
environmentFiles = [config.age.secrets."${serviceName}-server-env".path];
volumes = [
"${serviceName}_data:/var/lib/netbird"
"/var/lib/${serviceName}/config.yaml:/etc/netbird/config.yaml:ro"
];
cmd = ["--config" "/etc/netbird/config.yaml"];
extraOptions = [
"--ip=${serverIp}"
"--network=web"
];
};
"${serviceName}-proxy" = {
image = "netbirdio/reverse-proxy:latest";
autoStart = true;
ports = ["51820:51820/udp"];
volumes = [
"${serviceName}_proxy_certs:/certs"
];
environmentFiles = [config.age.secrets."${serviceName}-proxy-env".path];
cmd = [
"--domain=${proxyDomain}"
"--mgmt=https://${domain}:443"
"--addr=:8443"
"--cert-dir=/certs"
"--acme-certs"
"--trusted-proxies=${gatewayIp}/32"
];
dependsOn = ["${serviceName}-server"];
extraOptions = [
"--ip=${proxyIp}"
"--network=web"
];
};
};
services.traefik.dynamicConfigOptions = {
# HTTP services and routers
http = {
services = {
"${serviceName}-dashboard".loadBalancer.servers = [
{url = "http://localhost:${toString servicePort}/";}
];
"${serviceName}-server".loadBalancer.servers = [
{url = "http://${serverIp}:80/";}
];
"${serviceName}-server-h2c".loadBalancer.servers = [
{url = "h2c://${serverIp}:80";}
];
};
routers = {
# gRPC (Signal + Management)
"${serviceName}-grpc" = {
rule = "Host(`${domain}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))";
entrypoints = "websecure";
tls.certResolver = "ionos";
service = "${serviceName}-server-h2c";
priority = 100;
};
# Backend (relay, WebSocket, API, OAuth2)
"${serviceName}-backend" = {
rule = "Host(`${domain}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))";
entrypoints = "websecure";
tls.certResolver = "ionos";
service = "${serviceName}-server";
priority = 100;
};
# Dashboard (catch-all, lowest priority)
"${serviceName}-dashboard" = {
rule = "Host(`${domain}`)";
entrypoints = "websecure";
tls.certResolver = "ionos";
service = "${serviceName}-dashboard";
priority = 1;
};
};
};
# TCP for proxy TLS passthrough
tcp = {
services."${serviceName}-proxy-tls".loadBalancer.servers = [
{address = "${proxyIp}:8443";}
];
routers."${serviceName}-proxy-passthrough" = {
entryPoints = ["websecure"];
rule = "HostSNI(`*`)";
service = "${serviceName}-proxy-tls";
priority = 1;
tls.passthrough = true;
};
};
# ServersTransport for proxy protocol v2 (optional)
serversTransports."pp-v2" = {
proxyProtocol.version = 2;
};
};
networking.firewall.allowedUDPPorts = [
3478 # STUN
51820 # WireGuard for proxy
];
}

View File

@@ -0,0 +1,32 @@
{config, ...}: let
serviceName = "portainer";
servicePort = config.m3ta.ports.get serviceName;
in {
virtualisation.oci-containers.containers.${serviceName} = {
image = "docker.io/portainer/portainer-ce:latest";
ports = ["127.0.0.1:${toString servicePort}:9000"];
volumes = [
"/etc/localtime:/etc/localtime:ro"
"/run/podman/podman.sock:/var/run/docker.sock:ro"
"portainer_data:/data"
];
};
# Traefik configuration
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
routers.${serviceName} = {
rule = "Host(`pt.az-gruppe.com`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
};
};
}

View File

@@ -0,0 +1,296 @@
{
config,
pkgs,
...
}: let
instanceName = "hr";
serviceName = "zammad-${instanceName}";
servicePort = config.m3ta.ports.get serviceName;
elasticsearchServiceName = "${serviceName}-elasticsearch";
elasticsearchPort = config.m3ta.ports.get elasticsearchServiceName;
envFileProd = config.age.secrets."${serviceName}-env-prod".path;
envFileCommon = config.age.secrets."${serviceName}-env".path;
zammadVersion = "6.5.2-22";
zammadImage = "ghcr.io/zammad/zammad:${zammadVersion}";
ipBase = "10.89.0";
ipOffset = 40;
# Domain-Konfiguration
zammadDomain = "hr-ticket.az-gruppe.com";
sharedEnvironment = {
MEMCACHE_SERVERS = "zammad-memcached:11211";
POSTGRESQL_DB = "zammad_${instanceName}";
POSTGRESQL_HOST = "10.89.0.1";
POSTGRESQL_USER = "zammad_${instanceName}";
POSTGRESQL_PORT = "5432";
POSTGRESQL_OPTIONS = "?pool=50";
REDIS_URL = "redis://zammad-redis:6379";
TZ = "Europe/Berlin";
BACKUP_DIR = "/var/tmp/zammad";
BACKUP_TIME = "03:00";
HOLD_DAYS = "10";
ELASTICSEARCH_ENABLED = "true";
ELASTICSEARCH_HOST = "zammad-elasticsearch";
ELASTICSEARCH_PORT = "9200";
ELASTICSEARCH_NAMESPACE = "zammad_${instanceName}";
NGINX_PORT = "8080";
# CSRF & Reverse Proxy Settings
NGINX_SERVER_SCHEME = "https";
NGINX_SERVER_NAME = zammadDomain;
ZAMMAD_HTTP_TYPE = "https";
ZAMMAD_FQDN = zammadDomain;
RAILS_TRUSTED_PROXIES = "['127.0.0.1', '::1', '10.89.0.0/24']";
};
in {
virtualisation.oci-containers = {
containers."${serviceName}-elasticsearch" = {
image = "elasticsearch:8.19.6";
autoStart = true;
volumes = ["${serviceName}_elasticsearch:/usr/share/elasticsearch/data"];
environment = {
"discovery.type" = "single-node";
"xpack.security.enabled" = "false";
ES_JAVA_OPTS = "-Xms1g -Xmx1g";
};
extraOptions = [
"--ip=${ipBase}.${toString ipOffset}"
"--network=web"
"--network-alias=zammad-elasticsearch"
];
ports = ["127.0.0.1:${toString elasticsearchPort}:9200"];
};
containers."${serviceName}-memcached" = {
image = "memcached:1.6.39-alpine";
autoStart = true;
cmd = ["memcached" "-m" "256M"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 1)}"
"--network=web"
"--network-alias=zammad-memcached"
];
};
containers."${serviceName}-redis" = {
image = "redis:7.4.6-alpine";
autoStart = true;
volumes = ["${serviceName}_redis:/data"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 2)}"
"--network=web"
"--network-alias=zammad-redis"
];
};
containers."${serviceName}-railsserver" = {
image = zammadImage;
autoStart = true;
cmd = ["zammad-railsserver"];
environment = sharedEnvironment;
environmentFiles = [envFileCommon envFileProd];
volumes = ["${serviceName}_storage:/opt/zammad/storage"];
dependsOn = ["${serviceName}-memcached" "${serviceName}-redis" "${serviceName}-elasticsearch"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 4)}"
"--network=web"
"--add-host=postgres:10.89.0.1"
"--network-alias=zammad-railsserver"
];
};
containers."${serviceName}-scheduler" = {
image = zammadImage;
autoStart = true;
cmd = ["zammad-scheduler"];
environment = sharedEnvironment;
environmentFiles = [envFileCommon envFileProd];
volumes = ["${serviceName}_storage:/opt/zammad/storage"];
dependsOn = ["${serviceName}-memcached" "${serviceName}-redis"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 5)}"
"--network=web"
"--add-host=postgres:10.89.0.1"
];
};
containers."${serviceName}-websocket" = {
image = zammadImage;
autoStart = true;
cmd = ["zammad-websocket"];
environment = sharedEnvironment;
environmentFiles = [envFileCommon envFileProd];
volumes = ["${serviceName}_storage:/opt/zammad/storage"];
dependsOn = ["${serviceName}-memcached" "${serviceName}-redis"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 6)}"
"--network=web"
"--add-host=postgres:10.89.0.1"
"--network-alias=zammad-websocket"
];
};
containers."${serviceName}-nginx" = {
image = zammadImage;
autoStart = true;
cmd = ["zammad-nginx"];
environment = sharedEnvironment;
environmentFiles = [envFileCommon envFileProd];
volumes = ["${serviceName}_storage:/opt/zammad/storage"];
dependsOn = ["${serviceName}-railsserver"];
ports = ["127.0.0.1:${toString servicePort}:8080"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 7)}"
"--network=web"
"--add-host=postgres:10.89.0.1"
];
};
containers."${serviceName}-backup" = {
image = zammadImage;
autoStart = true;
cmd = ["zammad-backup"];
environment = sharedEnvironment;
environmentFiles = [envFileCommon envFileProd];
volumes = [
"${serviceName}_storage:/opt/zammad/storage:ro"
"/var/backup/${serviceName}:/var/tmp/zammad:rw"
];
dependsOn = ["${serviceName}-memcached" "${serviceName}-redis"];
extraOptions = [
"--ip=${ipBase}.${toString (ipOffset + 8)}"
"--network=web"
"--add-host=postgres:10.89.0.1"
"--user=0:0"
];
};
};
# Init als oneshot systemd-Service
systemd.services."${serviceName}-init" = {
description = "Zammad ${instanceName} Database Initialization";
after = [
"podman-${serviceName}-memcached.service"
"podman-${serviceName}-redis.service"
"podman-${serviceName}-elasticsearch.service"
];
requires = [
"podman-${serviceName}-memcached.service"
"podman-${serviceName}-redis.service"
];
wantedBy = [];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
echo "Starting Zammad ${instanceName} database initialization..."
${pkgs.podman}/bin/podman run --rm \
--name ${serviceName}-init-oneshot \
--network web \
--ip ${ipBase}.${toString (ipOffset + 3)} \
--add-host=postgres:10.89.0.1 \
--user 0:0 \
--env-file ${envFileCommon} \
--env-file ${envFileProd} \
--env MEMCACHE_SERVERS=zammad-memcached:11211 \
--env POSTGRESQL_DB=zammad_${instanceName} \
--env POSTGRESQL_HOST=10.89.0.1 \
--env POSTGRESQL_USER=zammad_${instanceName} \
--env POSTGRESQL_PORT=5432 \
--env POSTGRESQL_OPTIONS='?pool=50' \
--env REDIS_URL=redis://zammad-redis:6379 \
--env TZ=Europe/Berlin \
--env ELASTICSEARCH_ENABLED=true \
--env ELASTICSEARCH_HOST=zammad-elasticsearch \
--env ELASTICSEARCH_PORT=9200 \
--env ELASTICSEARCH_NAMESPACE=zammad_${instanceName} \
--env NGINX_SERVER_SCHEME=https \
--env NGINX_SERVER_NAME=${zammadDomain} \
--env ZAMMAD_HTTP_TYPE=https \
--env ZAMMAD_FQDN=${zammadDomain} \
-v ${serviceName}_storage:/opt/zammad/storage \
${zammadImage} \
zammad-init
echo "Zammad ${instanceName} initialization completed successfully"
'';
};
# Backup retention service
systemd.services."${serviceName}-backup-cleanup" = {
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
BACKUP_DIR="/var/backup/${serviceName}"
HOLD_DAYS=10
echo "Starting ${serviceName} backup cleanup at $(date)"
mkdir -p "$BACKUP_DIR"
chown root:root "$BACKUP_DIR"
chmod 750 "$BACKUP_DIR"
${pkgs.findutils}/bin/find "$BACKUP_DIR" -type f -name "*.gz" -mtime +$HOLD_DAYS -delete
echo "Current backups:"
ls -lah "$BACKUP_DIR" || echo "No backups found"
echo "${serviceName} backup cleanup completed at $(date)"
'';
};
systemd.timers."${serviceName}-backup-cleanup" = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* 04:00:00";
RandomizedDelaySec = "30m";
Persistent = true;
};
};
# Traefik configuration with proper headers
services.traefik.dynamicConfigOptions.http = {
services.${serviceName}.loadBalancer.servers = [
{
url = "http://localhost:${toString servicePort}/";
}
];
middlewares."${serviceName}-headers".headers = {
customRequestHeaders = {
X-Forwarded-Proto = "https";
X-Forwarded-Port = "443";
X-Forwarded-Host = zammadDomain;
X-Real-IP = "";
};
};
routers.${serviceName} = {
rule = "Host(`${zammadDomain}`)";
tls = {
certResolver = "ionos";
};
service = serviceName;
entrypoints = "websecure";
middlewares = ["${serviceName}-headers"];
};
};
}