Commit 9e27e910cdee

Vincent Demeester <vincent@sbr.pm>
2026-06-22 14:59:31
feat(aion): add wallabag + miniflux, remove linkwarden
Added self-hosted reading infrastructure on aion to replace Readwise Reader. Wallabag (read-it-later) on port 8084 with custom NixOS module, Miniflux (RSS) on port 8085 using nixpkgs module. Both use local PostgreSQL with peer auth. - Added wallabag NixOS module (PHP-FPM + Caddy + migrations) - Configured traefik routes on rhea for wallabag/miniflux - Replaced linkwarden with wallabag/miniflux in globals - Added agenix secrets for both services
1 parent 1fea1f6
modules/wallabag/default.nix
@@ -0,0 +1,379 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+with lib;
+
+let
+  cfg = config.services.wallabag;
+
+  defaultUser = "wallabag";
+  defaultGroup = "wallabag";
+
+  # Generate parameters.yml for Symfony
+  parametersYml = pkgs.writeText "wallabag-parameters.yml" ''
+    parameters:
+        database_driver: pdo_pgsql
+        database_host: ${cfg.database.host}
+        database_port: ${toString cfg.database.port}
+        database_name: ${cfg.database.name}
+        database_user: ${cfg.database.user}
+        database_password: ~
+        database_path: null
+        database_table_prefix: wallabag_
+        database_socket: null
+        database_charset: utf8
+
+        domain_name: '${cfg.domainName}'
+        server_name: '${cfg.serverName}'
+
+        mailer_dsn: '${cfg.mailerDsn}'
+        locale: ${cfg.locale}
+        secret: '%env(WALLABAG_SECRET)%'
+
+        twofactor_sender: ${cfg.twofactorSender}
+        fosuser_registration: ${boolToString cfg.registration}
+        fosuser_confirmation: ${boolToString cfg.registrationConfirmation}
+
+        fos_oauth_server_access_token_lifetime: 3600
+        fos_oauth_server_refresh_token_lifetime: 1209600
+
+        from_email: ${cfg.fromEmail}
+        rss_limit: 50
+
+        rabbitmq_host: localhost
+        rabbitmq_port: 5672
+        rabbitmq_user: guest
+        rabbitmq_password: guest
+        rabbitmq_prefetch_count: 10
+
+        redis_scheme: tcp
+        redis_host: localhost
+        redis_port: 6379
+        redis_path: null
+        redis_password: null
+
+        sentry_dsn: null
+  '';
+
+  # Setup script that initializes the data directory and runs migrations
+  wallabag-setup = pkgs.writeShellScript "wallabag-setup" ''
+    set -euo pipefail
+
+    DATA_DIR="${cfg.dataDir}"
+    PACKAGE="${cfg.package}"
+
+    # Ensure data directory structure
+    mkdir -p "$DATA_DIR/app/config"
+    mkdir -p "$DATA_DIR/var/cache/prod"
+    mkdir -p "$DATA_DIR/var/logs"
+    mkdir -p "$DATA_DIR/var/sessions"
+    mkdir -p "$DATA_DIR/web/uploads"
+    mkdir -p "$DATA_DIR/data/db"
+    mkdir -p "$DATA_DIR/data/assets"
+
+    # Copy parameters.yml
+    cp ${parametersYml} "$DATA_DIR/app/config/parameters.yml"
+
+    # Read database password from file if provided
+    DB_PASS=""
+    if [ -n "${toString cfg.database.passwordFile}" ] && [ -f "${toString cfg.database.passwordFile}" ]; then
+      DB_PASS=$(cat "${toString cfg.database.passwordFile}")
+    fi
+
+    # Read secret from file
+    WALLABAG_SECRET_VALUE=""
+    if [ -f "${toString cfg.secretKeyFile}" ]; then
+      WALLABAG_SECRET_VALUE=$(cat "${toString cfg.secretKeyFile}")
+    else
+      echo "ERROR: Secret key file not found: ${toString cfg.secretKeyFile}"
+      exit 1
+    fi
+
+    # Patch password into parameters.yml if using password auth
+    if [ -n "$DB_PASS" ]; then
+      ${pkgs.gnused}/bin/sed -i "s|database_password: ~|database_password: '$DB_PASS'|" \
+        "$DATA_DIR/app/config/parameters.yml"
+    fi
+
+    export WALLABAG_DATA="$DATA_DIR"
+    export WALLABAG_SECRET="$WALLABAG_SECRET_VALUE"
+
+    # Clear cache on every start (required after upgrades)
+    rm -rf "$DATA_DIR/var/cache/prod/"*
+
+    # Run database migrations
+    ${cfg.package}/bin/console doctrine:migrations:migrate --no-interaction --env=prod || true
+
+    # Install craue settings if first run
+    ${cfg.package}/bin/console wallabag:install --env=prod --no-interaction --reset=no 2>/dev/null || true
+  '';
+
+  phpPackage = pkgs.php83.withExtensions (
+    { enabled, all }:
+    enabled
+    ++ [
+      all.pdo_pgsql
+      all.pgsql
+      all.intl
+      all.gd
+      all.bcmath
+      all.tidy
+      all.imagick
+    ]
+  );
+
+in
+{
+  options.services.wallabag = {
+    enable = mkEnableOption "wallabag read-it-later service";
+
+    package = mkPackageOption pkgs "wallabag" { };
+
+    user = mkOption {
+      type = types.str;
+      default = defaultUser;
+      description = "User to run wallabag as";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = defaultGroup;
+      description = "Group to run wallabag as";
+    };
+
+    dataDir = mkOption {
+      type = types.path;
+      default = "/var/lib/wallabag";
+      description = "Data directory for wallabag";
+    };
+
+    domainName = mkOption {
+      type = types.str;
+      example = "https://wallabag.example.com";
+      description = "The full URL of your wallabag instance";
+    };
+
+    serverName = mkOption {
+      type = types.str;
+      default = "wallabag";
+      description = "Display name for the wallabag instance";
+    };
+
+    locale = mkOption {
+      type = types.str;
+      default = "en";
+      description = "Default locale";
+    };
+
+    mailerDsn = mkOption {
+      type = types.str;
+      default = "smtp://127.0.0.1";
+      description = "Mailer DSN for sending emails";
+    };
+
+    fromEmail = mkOption {
+      type = types.str;
+      default = "no-reply@wallabag.org";
+      description = "From email address";
+    };
+
+    twofactorSender = mkOption {
+      type = types.str;
+      default = "no-reply@wallabag.org";
+      description = "Two-factor authentication sender email";
+    };
+
+    registration = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Allow user registration";
+    };
+
+    registrationConfirmation = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Require email confirmation for registration";
+    };
+
+    secretKeyFile = mkOption {
+      type = types.path;
+      description = "Path to file containing the Symfony secret key";
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 8084;
+      description = "Port for the wallabag PHP-FPM FastCGI server (used with a reverse proxy)";
+    };
+
+    database = {
+      createLocally = mkOption {
+        type = types.bool;
+        default = true;
+        description = "Whether to create the PostgreSQL database locally";
+      };
+
+      host = mkOption {
+        type = types.str;
+        default = "/run/postgresql";
+        description = "Database host (use socket path for local peer auth)";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 5432;
+        description = "Database port";
+      };
+
+      name = mkOption {
+        type = types.str;
+        default = "wallabag";
+        description = "Database name";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "wallabag";
+        description = "Database user";
+      };
+
+      passwordFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = "Path to file containing the database password (null for peer auth)";
+      };
+    };
+
+    poolConfig = mkOption {
+      type = types.attrsOf (
+        types.oneOf [
+          types.str
+          types.int
+          types.bool
+        ]
+      );
+      default = { };
+      description = "Additional PHP-FPM pool configuration";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.postgresql = mkIf cfg.database.createLocally {
+      enable = true;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        {
+          name = cfg.database.user;
+          ensureDBOwnership = true;
+        }
+      ];
+    };
+
+    # PHP-FPM pool for wallabag
+    services.phpfpm.pools.wallabag = {
+      user = cfg.user;
+      group = cfg.group;
+      phpPackage = phpPackage;
+      phpOptions = ''
+        log_errors = on
+        post_max_size = 20M
+        upload_max_filesize = 20M
+        memory_limit = 256M
+      '';
+      phpEnv = {
+        WALLABAG_DATA = cfg.dataDir;
+      };
+      settings = {
+        "listen.mode" = mkDefault "0660";
+        "listen.owner" = mkDefault cfg.user;
+        "listen.group" = mkDefault cfg.group;
+        "pm" = mkDefault "dynamic";
+        "pm.max_children" = mkDefault 10;
+        "pm.start_servers" = mkDefault 2;
+        "pm.min_spare_servers" = mkDefault 1;
+        "pm.max_spare_servers" = mkDefault 4;
+        "pm.max_requests" = mkDefault 500;
+      }
+      // cfg.poolConfig;
+    };
+
+    # Caddy as lightweight reverse proxy (PHP-FPM → HTTP)
+    services.caddy = {
+      enable = true;
+      virtualHosts.":${toString cfg.port}" = {
+        extraConfig = ''
+          root * ${cfg.package}/web
+          php_fastcgi unix/${config.services.phpfpm.pools.wallabag.socket} {
+            env WALLABAG_DATA ${cfg.dataDir}
+          }
+          file_server
+        '';
+      };
+    };
+
+    # Setup service that runs before PHP-FPM
+    systemd.services.wallabag-setup = {
+      description = "Wallabag setup (migrations, cache clear)";
+      after = [ "postgresql.service" ];
+      requires = mkIf cfg.database.createLocally [ "postgresql.service" ];
+      requiredBy = [ "phpfpm-wallabag.service" ];
+      before = [ "phpfpm-wallabag.service" ];
+      serviceConfig = {
+        Type = "oneshot";
+        ExecStart = wallabag-setup;
+        User = cfg.user;
+        Group = cfg.group;
+        RemainAfterExit = true;
+        ReadWritePaths = [ cfg.dataDir ];
+        WorkingDirectory = cfg.package;
+      };
+      restartTriggers = [
+        cfg.package
+        parametersYml
+      ];
+    };
+
+    # Create data directories
+    systemd.tmpfiles.settings."10-wallabag" =
+      let
+        defaultConfig = {
+          user = cfg.user;
+          group = cfg.group;
+          mode = "0750";
+        };
+      in
+      {
+        "${cfg.dataDir}".d = defaultConfig;
+        "${cfg.dataDir}/app".d = defaultConfig;
+        "${cfg.dataDir}/app/config".d = defaultConfig;
+        "${cfg.dataDir}/var".d = defaultConfig;
+        "${cfg.dataDir}/var/cache".d = defaultConfig;
+        "${cfg.dataDir}/var/logs".d = defaultConfig;
+        "${cfg.dataDir}/var/sessions".d = defaultConfig;
+        "${cfg.dataDir}/web".d = defaultConfig;
+        "${cfg.dataDir}/web/uploads".d = defaultConfig;
+        "${cfg.dataDir}/data".d = defaultConfig;
+        "${cfg.dataDir}/data/db".d = defaultConfig;
+        "${cfg.dataDir}/data/assets".d = defaultConfig;
+      };
+
+    users.users = mkIf (cfg.user == defaultUser) {
+      wallabag = {
+        inherit (cfg) group;
+        isSystemUser = true;
+        home = cfg.dataDir;
+      };
+    };
+
+    users.groups = mkIf (cfg.group == defaultGroup) {
+      wallabag = { };
+    };
+
+    # Open firewall port
+    networking.firewall.allowedTCPPorts = [ cfg.port ];
+  };
+}
secrets/aion/miniflux-admin-credentials.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A4/TNf5rEQJAsAjNb9xPKLvIGJU4lXzUZitnqH/auQLJ
+LcWWyoBp3YRbMv7kzjLfNEejmiouB6T8gxuA6M+tk/g
+-> piv-p256 cUinNw A3zp4VpPRSn3YrhHyNPXZvuGMqXUGTUnRQNtZFAtyj/e
+T5ESiDZBuRhFPw8+CAuRmLZ0pR8JOUe8q9y4CxyVJL0
+-> ssh-ed25519 5bXRbA qgcjtKYFVkKwT/ms9hJ+xhNmk/m3v3p75X3a0+ZIf3s
+2p+/yBVu4Xv4palIGru6rtsiDwEfE0MxCA84k1gP5Qw
+--- sQolRs7IWIIJRW38pE3F7xWgqB/zamTujgqV8iQy50w
+�P���h�����$����	�����m����}��/��{Yi0��,�����Ʉ������P�GL>�XaN�ĉ�ӵ���e���a�&[(��^g�ʚ��2�g
\ No newline at end of file
secrets/aion/wallabag-secret-key.age
@@ -0,0 +1,10 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA A1R/xshyWy0eO0lTHAhHxF6z7QISDWzMuEMHbf8sPTka
+rcgmUKo/0bDrwHpdpkuwT8nIu9vMx3Vlodr95GLPQBY
+-> piv-p256 cUinNw A4Dtyl/i/kasJyY1nxC9B682Zl2qAPUTTGlT96HUNua+
+kWIyFGDw1lwdVg105SWtyiPHEWg0XANV76eizr+rG2o
+-> ssh-ed25519 5bXRbA FWCz4Z8KjCAQQrz+5T6eyUqsGPQlMcRxaltkqY6B93E
+m0wPKl89FVm0Xdkz42weLN8YOFwJk2pQ5N5cFcJy2d8
+--- Y3vWqSjooMNJS+3liZpIY1UVUJikMZGbKeoLG3MZEJk
+��Ҹe�;v�Bg�GC���
+ҙ�R�3Օ	T�]�/�W���!<4[?6�"����oHz<���mY���7{0OCz����*O�0�jE��Ի�ăi|9Y�
\ No newline at end of file
systems/aion/extra.nix
@@ -50,6 +50,7 @@ in
     ../../modules/music-playlist-dl
     ../../modules/harmonia
     ../../modules/xmpp-research-bot
+    ../../modules/wallabag
     ./xmpp.nix
   ];
 
@@ -93,6 +94,16 @@ in
       owner = "root";
       group = "root";
     };
+    "miniflux-admin-credentials" = {
+      file = ../../secrets/aion/miniflux-admin-credentials.age;
+      mode = "400";
+    };
+    "wallabag-secret-key" = {
+      file = ../../secrets/aion/wallabag-secret-key.age;
+      mode = "400";
+      owner = "wallabag";
+      group = "wallabag";
+    };
     # TODO: Uncomment after creating secrets with agenix
     # "xmpp-research-bot-password" = {
     #   file = ../../secrets/aion/xmpp-research-bot-password.age;
@@ -510,6 +521,27 @@ in
     "d /neo/paperless/trash 0755 vincent users -"
   ];
 
+  # Wallabag - read-it-later service
+  services.wallabag = {
+    enable = true;
+    port = 8084;
+    domainName = "https://wallabag.sbr.pm";
+    serverName = "wallabag";
+    secretKeyFile = config.age.secrets."wallabag-secret-key".path;
+    database.createLocally = true;
+  };
+
+  # Miniflux - RSS feed reader
+  services.miniflux = {
+    enable = true;
+    config = {
+      LISTEN_ADDR = "0.0.0.0:8085";
+      BASE_URL = "https://miniflux.sbr.pm";
+    };
+    createDatabaseLocally = true;
+    adminCredentialsFile = config.age.secrets."miniflux-admin-credentials".path;
+  };
+
   # Override prometheus-restic-exporter service to disable DynamicUser
   # This is needed so the service runs as vincent and can access SSH keys
   # DISABLED: Service is currently disabled due to excessive load
@@ -534,6 +566,8 @@ in
         8000 # Paperless
         8384 # Syncthing web UI
         13378 # Audiobookshelf
+        8084 # Wallabag (via Caddy)
+        8085 # Miniflux
         8686 # Lidarr
         9000 # Node exporter
         9709 # Lidarr exportarr (prometheus)
systems/common/services/linkwarden.nix
@@ -1,60 +0,0 @@
-{ pkgs, ... }:
-{
-  # Linkwarden - Self-hosted collaborative bookmark manager
-  # https://linkwarden.app/
-  #
-  # Replacement for Omnivore (which shut down in November 2024)
-  # Features: Full-page preservation, reader view, annotations, AI tagging
-
-  services.linkwarden = {
-    enable = true;
-
-    # Network configuration
-    host = "0.0.0.0";
-    port = 3002;
-
-    # Storage
-    storageLocation = "/var/lib/linkwarden";
-    cacheLocation = "/var/cache/linkwarden";
-
-    # Database (auto-configured PostgreSQL)
-    database = {
-      createLocally = true;
-      name = "linkwarden";
-      user = "linkwarden";
-    };
-
-    # Allow user registration
-    enableRegistration = true;
-
-    # Secret files
-    # TODO: Move to agenix for production
-    secretFiles.NEXTAUTH_SECRET = "${pkgs.writeText "nextauth-secret" ''
-      changeme-replace-with-agenix-secret-in-production
-    ''}";
-
-    # Environment variables
-    environment = {
-      PAGINATION_TAKE_COUNT = "24";
-      AUTOSCROLL_TIMEOUT = "30";
-      RE_ARCHIVE_LIMIT = "5";
-      # STORAGE_FOLDER is set automatically by the module
-      # Disable telemetry for privacy
-      NEXT_PUBLIC_DISABLE_REGISTRATION = "false";
-    };
-  };
-
-  # Ensure PostgreSQL is configured
-  services.postgresql = {
-    ensureDatabases = [ "linkwarden" ];
-    ensureUsers = [
-      {
-        name = "linkwarden";
-        ensureDBOwnership = true;
-      }
-    ];
-  };
-
-  # Open firewall for local access (Traefik will proxy)
-  networking.firewall.allowedTCPPorts = [ 3002 ];
-}
systems/rhea/extra.nix
@@ -348,6 +348,14 @@ in
                   "podcasts.sbr.pm"
                 ];
                 lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
+                wallabag = mkRouter "wallabag" [
+                  "wallabag.sbr.pm"
+                  "read.sbr.pm"
+                ];
+                miniflux = mkRouter "miniflux" [
+                  "miniflux.sbr.pm"
+                  "rss.sbr.pm"
+                ];
                 homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
                 # OpenCode web interface on okinawa (VPN-only)
                 opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
@@ -381,6 +389,8 @@ in
                 homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
                 audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
                 lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
+                wallabag = mkService "http://${builtins.head globals.machines.aion.net.ips}:8084";
+                miniflux = mkService "http://${builtins.head globals.machines.aion.net.ips}:8085";
                 opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
                 llm = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8090";
                 reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
globals.nix
@@ -612,10 +612,15 @@ _: {
       aliases = [ "s" ];
     };
     homepage.host = "rhea";
-    # Linkwarden bookmark manager (runs on sakhalin, proxied via rhea/Traefik)
-    linkwarden = {
+    # Wallabag read-it-later (runs on aion, proxied via rhea/Traefik)
+    wallabag = {
       host = "rhea";
-      aliases = [ "links" ];
+      aliases = [ "read" ];
+    };
+    # Miniflux RSS reader (runs on aion, proxied via rhea/Traefik)
+    miniflux = {
+      host = "rhea";
+      aliases = [ "rss" ];
     };
     # Traefik dashboard
     traefik.host = "rhea";
secrets.nix
@@ -151,6 +151,9 @@ in
   "secrets/sakhalin/homeassistant-prometheus-token.age".publicKeys = users ++ [ sakhalin ];
   "secrets/demeter/mosquitto-homeassistant-password.age".publicKeys = users ++ [ demeter ];
   "secrets/aion/restic-aix-password.age".publicKeys = users ++ [ aion ];
+  # Wallabag + Miniflux on aion
+  "secrets/aion/miniflux-admin-credentials.age".publicKeys = users ++ [ aion ];
+  "secrets/aion/wallabag-secret-key.age".publicKeys = users ++ [ aion ];
   # OpenCode web on okinawa
   "secrets/okinawa/opencode-password.age".publicKeys = users ++ [ okinawa ];
   "secrets/okinawa/groq-api-key.age".publicKeys = users ++ [ okinawa ];