{ config, lib, pkgs, wafrnSrc ? null, ... }: let inherit (lib) concatStringsSep mapAttrsToList mkEnableOption mkIf mkOption optionalAttrs optionalString types; cfg = config.services.wafrn; defaultWafrnPackage = if wafrnSrc != null then pkgs.runCommand "wafrn-source-unstable" { } '' mkdir -p "$out" cp -a ${wafrnSrc}/. "$out/" chmod -R u+w "$out" '' else null; toEnvString = value: if builtins.isBool value then (if value then "true" else "false") else if builtins.isInt value || builtins.isFloat value then toString value else value; quoteEnv = value: let s = toEnvString value; escaped = lib.replaceStrings [ "\\" "\"" "\n" "\r" ] [ "\\\\" "\\\"" "\\n" "" ] s; in "\"${escaped}\""; baseEnv = { ADMIN_USER = "admin"; ADMIN_EMAIL = "admin@example.com"; ADMIN_PASSWORD = "change-me"; JWT_SECRET = "change-me"; DOMAIN_NAME = "wafrn.example.com"; CACHE_DOMAIN = "cache.wafrn.example.com"; MEDIA_DOMAIN = "media.wafrn.example.com"; FRONTEND_MEDIA_URL = "https://media.wafrn.example.com"; FRONTEND_CACHE_URL = "https://cache.wafrn.example.com/api/cache?media="; FRONTEND_FQDN_URL = "https://wafrn.example.com"; FRONTEND_SHORT_TITLE = "Wafrn"; FRONTEND_LONG_TITLE = "Wafrn, the social media that respects you"; FRONTEND_DESCRIPTION = "Wafrn is a federated social media inspired by tumblr that connects with the fediverse and bluesky"; DONATION_URL = "https://wafrn.example.com/about"; REGISTRATION_LEVEL = "PUBLIC"; REGISTRATIONS_DISABLED_TEXT = "This instance is a private instance, and does not allow registrations"; REVIEW_REGISTRATIONS = true; REGISTRATION_MINIMUM_AGE = 18; BUBBLE_SERVERS_SHOW_TYPE = "PUBLIC"; BLOCKED_SERVERS_SHOW_TYPE = "LOGGEDIN"; AUTOFOLLOW_MAIN_ADMIN = true; DISABLE_REQUIRE_SEND_EMAIL = false; SMTP_HOST = "smtp.example.com"; SMTP_USER = "wafrn"; SMTP_PORT = 587; SMTP_PASSWORD = "change-me"; SMTP_FROM = "wafrn@example.com"; POSTGRES_USER = "root"; POSTGRES_PASSWORD = "root"; POSTGRES_DBNAME = "wafrn"; POSTGRES_DB = "wafrn"; ACME_EMAIL = "admin@example.com"; WEBPUSH_EMAIL = "mailto:info@wafrn.net"; WEBPUSH_PRIVATE = ""; WEBPUSH_PUBLIC = ""; ENABLE_BSKY = cfg.bluesky.enable; PDS_DOMAIN_NAME = cfg.bluesky.pdsDomain; PDS_JWT_SECRET = "change-me"; PDS_ADMIN_PASSWORD = "change-me"; PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = ""; ENABLE_RAW_OUTPUT = false; LOG_SQL_QUERIES = ""; UPLOAD_LIMIT = ""; POSTS_PER_PAGE = ""; LOG_LEVEL = ""; BLOCKLIST_URI = ""; FRONTEND_PATH = ""; BLOCKED_IPS = ""; IGNORE_BLOCK_HOSTS = ""; FRONTEND_LOGO = ""; FRONTEND_API_URL = ""; FRONTEND_CACHE_BACKUP_URLS = ""; FRONTEND_SHORTEN_POSTS = "3"; FRONTEND_DISABLE_PWA = false; FRONTEND_MAINTENANCE = false; }; effectiveEnv = baseEnv // cfg.environment; envFileContents = concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${quoteEnv value}") effectiveEnv) + "\n"; envTemplate = pkgs.writeText "wafrn.env.base" envFileContents; serviceEnvFile = "${cfg.stateDir}/.env"; composeFile = "${cfg.stateDir}/docker-compose.yml"; packageSourcePath = toString cfg.package; sourcePath = if cfg.source != null then cfg.source else ""; preparedSourcePath = "${cfg.stateDir}/source"; usingPrebuiltImages = cfg.images.backend != null && cfg.images.frontend != null; effectiveCaddyConfigDir = if cfg.caddyConfigDir != null then cfg.caddyConfigDir else if usingPrebuiltImages then null else "${preparedSourcePath}/packages/caddy"; publishedPorts = lib.optionals (cfg.httpPort != null) [ "${toString cfg.httpPort}:80" ] ++ lib.optionals (cfg.httpsPort != null) [ "${toString cfg.httpsPort}:443" ]; firewallPorts = lib.optionals (cfg.httpPort != null) [ cfg.httpPort ] ++ lib.optionals (cfg.httpsPort != null) [ cfg.httpsPort ]; serviceCommon = { restart = "unless-stopped"; env_file = [ serviceEnvFile ]; }; backendBuild = { context = preparedSourcePath; dockerfile = "packages/backend/Dockerfile"; }; backendContainerSpec = if usingPrebuiltImages then { image = cfg.images.backend; } else { build = backendBuild; }; frontendContainerSpec = if usingPrebuiltImages then { image = cfg.images.frontend; } else { build = { context = preparedSourcePath; dockerfile = "packages/frontend/Dockerfile"; }; }; backendService = { depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; frontend = { condition = "service_started"; }; migration = { condition = "service_completed_successfully"; }; }; environment = { NODE_ENV = "production"; USE_WORKERS = "true"; }; volumes = [ "${cfg.stateDir}/uploads:/app/packages/backend/uploads" "${cfg.stateDir}/cache:/app/packages/backend/cache" "${cfg.stateDir}/frontend:/app/packages/frontend:ro" ]; }; composeConfig = { services = { backend = serviceCommon // backendService // backendContainerSpec; migration = serviceCommon // backendService // backendContainerSpec // { restart = "no"; depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; frontend = { condition = "service_started"; }; }; command = [ "npm" "exec" "tsx" "migrate.ts" "init-container" ]; }; frontend = serviceCommon // frontendContainerSpec // { restart = "unless-stopped"; ports = publishedPorts; extra_hosts = [ "host.docker.internal:host-gateway" ]; volumes = [ "${cfg.stateDir}/caddy:/data" "${cfg.stateDir}/frontend:/var/www/html/frontend" "${cfg.stateDir}/uploads:/var/www/html/uploads" ] ++ lib.optionals (effectiveCaddyConfigDir != null) [ "${effectiveCaddyConfigDir}:/etc/caddy/config" ]; }; db = { image = "postgres:17"; restart = "unless-stopped"; shm_size = "2gb"; env_file = [ serviceEnvFile ]; environment = { POSTGRES_DB = "${effectiveEnv.POSTGRES_DBNAME}"; }; volumes = [ "${cfg.stateDir}/postgres:/var/lib/postgresql/data" ]; }; redis = { image = "redis:7.2.4"; restart = "unless-stopped"; volumes = [ "${cfg.stateDir}/redis:/data" ]; }; } // optionalAttrs cfg.adminer.enable { adminer = { image = "adminer"; restart = "unless-stopped"; }; } // optionalAttrs cfg.bluesky.enable { pds_worker = serviceCommon // backendContainerSpec // { command = [ "npm" "exec" "tsx" "atproto.ts" ]; depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; migration = { condition = "service_completed_successfully"; }; }; environment = { NODE_ENV = "production"; USE_WORKERS = "true"; }; volumes = [ "${cfg.stateDir}/uploads:/app/packages/backend/uploads" "${cfg.stateDir}/cache:/app/packages/backend/cache" "${cfg.stateDir}/frontend:/app/packages/frontend:ro" ]; }; } // optionalAttrs (cfg.bluesky.enable && cfg.bluesky.useBundledPds) { pds = { image = "ghcr.io/bluesky-social/pds:0.4"; restart = "unless-stopped"; env_file = [ serviceEnvFile ]; environment = { PDS_DATA_DIRECTORY = "/pds"; PDS_BLOBSTORE_DISK_LOCATION = "/pds/blocks"; PDS_BLOB_UPLOAD_LIMIT = "157286400"; PDS_DID_PLC_URL = "https://plc.directory"; PDS_BSKY_APP_VIEW_URL = "https://api.bsky.app"; PDS_BSKY_APP_VIEW_DID = "did:web:api.bsky.app"; PDS_REPORT_SERVICE_URL = "https://mod.bsky.app"; PDS_REPORT_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; PDS_CRAWLERS = "https://bsky.network, https://atproto.africa"; PDS_EMAIL_SMTP_URL = "smtps://${effectiveEnv.SMTP_USER}:${effectiveEnv.SMTP_PASSWORD}@${toString effectiveEnv.SMTP_HOST}:${toString effectiveEnv.SMTP_PORT}"; PDS_EMAIL_FROM_ADDRESS = "${effectiveEnv.SMTP_FROM}"; LOG_ENABLED = "true"; }; volumes = [ "${cfg.stateDir}/pds:/pds" ]; }; }; networks = { default = { enable_ipv6 = true; }; }; }; composeTemplate = pkgs.writeText "wafrn-compose.yml" (lib.generators.toYAML { } composeConfig); composeCmd = "${pkgs.docker-compose}/bin/docker-compose"; in { options.services.wafrn = { enable = mkEnableOption "Wafrn social platform"; source = mkOption { type = types.nullOr types.str; default = null; example = "/srv/wafrn"; description = "Optional path to a Wafrn source checkout. If null, the service uses services.wafrn.package as the source."; }; package = mkOption { type = types.package; default = if defaultWafrnPackage != null then defaultWafrnPackage else throw "services.wafrn.package must be set when no flake-pinned wafrn source is available"; defaultText = lib.literalExpression ""; description = "Package containing Wafrn source tree used to build backend/frontend Docker images."; }; caddyConfigDir = mkOption { type = types.nullOr types.str; default = null; example = "/srv/wafrn/packages/caddy"; description = "Optional host path mounted into frontend container as /etc/caddy/config for Caddy hooks/overrides."; }; images = { backend = mkOption { type = types.nullOr types.str; default = null; example = "ghcr.io/your-org/wafrn-backend:2026.02.01"; description = "Prebuilt backend image. Set together with images.frontend to skip local source/Docker builds."; }; frontend = mkOption { type = types.nullOr types.str; default = null; example = "ghcr.io/your-org/wafrn-frontend:2026.02.01"; description = "Prebuilt frontend image. Set together with images.backend to skip local source/Docker builds."; }; }; stateDir = mkOption { type = types.path; default = "/var/lib/wafrn"; description = "Persistent state directory for databases, uploads, caddy data, and optional PDS data."; }; composeProjectName = mkOption { type = types.str; default = "wafrn"; description = "Docker Compose project name."; }; secretsFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/secrets/wafrn.env"; description = "Optional dotenv file with secret overrides appended at runtime."; }; environment = mkOption { type = types.attrsOf (types.oneOf [ types.str types.int types.float types.bool ]); default = { }; description = "Non-secret environment overrides written to the generated .env file."; }; httpPort = mkOption { type = types.nullOr types.port; default = 80; description = "Host port mapped to Wafrn HTTP (container port 80). Set to null to disable publishing HTTP."; }; httpsPort = mkOption { type = types.nullOr types.port; default = 443; description = "Host port mapped to Wafrn HTTPS (container port 443). Set to null to disable publishing HTTPS."; }; autoBuild = mkOption { type = types.bool; default = true; description = "Whether to pass --build when starting containers."; }; openFirewall = mkOption { type = types.bool; default = true; description = "Open configured HTTP/HTTPS ports in the NixOS firewall."; }; adminer.enable = mkOption { type = types.bool; default = false; description = "Enable Adminer container."; }; bluesky = { enable = mkOption { type = types.bool; default = false; description = "Enable Bluesky integration and ATProto worker."; }; useBundledPds = mkOption { type = types.bool; default = false; description = "Run Wafrn's bundled Bluesky PDS container. Set false to use an external PDS."; }; pdsDomain = mkOption { type = types.str; default = "bsky.example.com"; description = "PDS domain used by Wafrn (can point to an external PDS)."; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = config.virtualisation.docker.enable; message = "services.wafrn requires virtualisation.docker.enable = true;"; } { assertion = usingPrebuiltImages || cfg.source != null || cfg.package != null; message = "Provide services.wafrn.images.backend+frontend, set services.wafrn.source, or set services.wafrn.package."; } { assertion = (cfg.images.backend == null) == (cfg.images.frontend == null); message = "Set both services.wafrn.images.backend and services.wafrn.images.frontend, or neither."; } { assertion = cfg.httpPort != null || cfg.httpsPort != null; message = "services.wafrn requires at least one published port (httpPort or httpsPort)."; } ]; systemd.tmpfiles.rules = [ "d ${cfg.stateDir} 0750 root root -" "d ${cfg.stateDir}/postgres 0750 root root -" "d ${cfg.stateDir}/redis 0750 root root -" "d ${cfg.stateDir}/uploads 0750 root root -" "d ${cfg.stateDir}/cache 0750 root root -" "d ${cfg.stateDir}/caddy 0750 root root -" "d ${cfg.stateDir}/frontend 0750 root root -" ] ++ lib.optionals (!usingPrebuiltImages) [ "d ${cfg.stateDir}/source 0750 root root -" ] ++ lib.optionals (cfg.bluesky.enable && cfg.bluesky.useBundledPds) [ "d ${cfg.stateDir}/pds 0750 root root -" ]; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall firewallPorts; systemd.services.wafrn-prepare = { description = "Prepare Wafrn runtime files"; wantedBy = [ "multi-user.target" ]; before = [ "wafrn.service" ]; after = [ "docker.service" ]; path = [ pkgs.nodejs pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; TimeoutStartSec = "30min"; }; script = '' set -euo pipefail ${optionalString (!usingPrebuiltImages) '' selected_source="" if [ -n "${sourcePath}" ] && [ -d "${sourcePath}" ]; then selected_source="${sourcePath}" elif [ -n "${sourcePath}" ]; then echo "wafrn-nix: configured source does not exist (${sourcePath}), falling back to services.wafrn.package" >&2 fi if [ -z "$selected_source" ] && [ -d "${packageSourcePath}" ]; then selected_source="${packageSourcePath}" fi if [ -z "$selected_source" ]; then echo "wafrn-nix: no usable source directory found" >&2 exit 1 fi marker_file="${preparedSourcePath}/.wafrn-source-origin" need_refresh=1 if [ -f "$marker_file" ]; then if [ "$(cat "$marker_file")" = "$selected_source" ]; then need_refresh=0 fi fi if [ "$need_refresh" -eq 1 ]; then echo "wafrn-nix: preparing source tree from $selected_source" >&2 rm -rf "${preparedSourcePath}" mkdir -p "${preparedSourcePath}" cp -a "$selected_source/." "${preparedSourcePath}/" chmod -R u+w "${preparedSourcePath}" printf '%s' "$selected_source" > "$marker_file" else echo "wafrn-nix: reusing prepared source tree" >&2 chmod -R u+w "${preparedSourcePath}" fi if [ ! -e "${preparedSourcePath}/.git" ]; then mkdir -p "${preparedSourcePath}/.git" fi if [ ! -f "${preparedSourcePath}/package-lock.json" ]; then echo "wafrn-nix: package-lock.json missing, generating with npm" >&2 (cd "${preparedSourcePath}" && npm install --package-lock-only --ignore-scripts) fi ''} install -m 0600 ${envTemplate} ${serviceEnvFile} ${optionalString (cfg.secretsFile != null) '' if [ ! -f "${cfg.secretsFile}" ]; then echo "wafrn-nix: secretsFile does not exist: ${cfg.secretsFile}" >&2 exit 1 fi while IFS= read -r raw_line || [ -n "$raw_line" ]; do case "$raw_line" in ""|\#*) continue ;; esac key="''${raw_line%%=*}" value="''${raw_line#*=}" key="$(printf '%s' "$key" | tr -d '[:space:]')" if ! printf '%s' "$key" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$'; then echo "wafrn-nix: ignoring invalid env key from secretsFile: $key" >&2 continue fi first_char="''${value:0:1}" last_char="''${value: -1}" if [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; then value="''${value:1:''${#value}-2}" elif [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; then value="''${value:1:''${#value}-2}" fi value="''${value//\\/\\\\}" value="''${value//\"/\\\"}" printf '%s="%s"\n' "$key" "$value" >> ${serviceEnvFile} done < "${cfg.secretsFile}" ''} install -m 0644 ${composeTemplate} ${composeFile} ''; }; systemd.services.wafrn = { description = "Wafrn Docker Compose stack"; wantedBy = [ "multi-user.target" ]; requires = [ "docker.service" "wafrn-prepare.service" ]; after = [ "docker.service" "wafrn-prepare.service" ]; path = [ pkgs.docker-compose pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; WorkingDirectory = cfg.stateDir; }; script = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"} ''; preStop = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} down ''; reload = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"} ''; }; }; }