diff --git a/flake.lock b/flake.lock
index 4c2d362..7fabd1c 100644
--- a/flake.lock
+++ b/flake.lock
@@ -592,6 +592,26 @@
         "type": "github"
       }
     },
+    "rainbowrss": {
+      "inputs": {
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1736087671,
+        "narHash": "sha256-zWeiCs+8SAS1wN5M3w3vSNNpILoKXqX9aj/ZZcgfMms=",
+        "ref": "refs/heads/main",
+        "rev": "ceab6a148233ffb23de19411a3e5579e3394a35b",
+        "revCount": 9,
+        "type": "git",
+        "url": "https://git.clerie.de/clerie/rainbowrss.git"
+      },
+      "original": {
+        "type": "git",
+        "url": "https://git.clerie.de/clerie/rainbowrss.git"
+      }
+    },
     "root": {
       "inputs": {
         "berlinerbaeder-exporter": "berlinerbaeder-exporter",
@@ -607,6 +627,7 @@
         "nixpkgs": "nixpkgs_3",
         "nixpkgs-0dc1c7": "nixpkgs-0dc1c7",
         "nurausstieg": "nurausstieg",
+        "rainbowrss": "rainbowrss",
         "scan-to-gpg": "scan-to-gpg",
         "solid-xmpp-alarm": "solid-xmpp-alarm",
         "sops-nix": "sops-nix",
diff --git a/flake.nix b/flake.nix
index 0000bef..c9ac9fb 100644
--- a/flake.nix
+++ b/flake.nix
@@ -41,6 +41,10 @@
       url = "git+https://git.clerie.de/clerie/nurausstieg.git";
       inputs.nixpkgs.follows = "nixpkgs";
     };
+    rainbowrss = {
+      url = "git+https://git.clerie.de/clerie/rainbowrss.git";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
     scan-to-gpg = {
       url = "git+https://git.clerie.de/clerie/scan-to-gpg.git";
       inputs.nixpkgs.follows = "nixpkgs";
diff --git a/flake/overlay.nix b/flake/overlay.nix
index 3a91216..cb4a2cd 100644
--- a/flake/overlay.nix
+++ b/flake/overlay.nix
@@ -6,6 +6,7 @@
 , harmonia
 , hydra
 , nurausstieg
+, rainbowrss
 , scan-to-gpg
 , ssh-to-age
 , ...
@@ -26,6 +27,8 @@ final: prev: {
     hydra;
   inherit (nurausstieg.packages.${final.system})
     nurausstieg;
+  inherit (rainbowrss.packages.${final.system})
+    rainbowrss;
   inherit (scan-to-gpg.packages.${final.system})
     scan-to-gpg;
   inherit (ssh-to-age.packages.${final.system})
diff --git a/hosts/web-2/configuration.nix b/hosts/web-2/configuration.nix
index 68c4a78..0565c80 100644
--- a/hosts/web-2/configuration.nix
+++ b/hosts/web-2/configuration.nix
@@ -10,6 +10,7 @@
       ./clerie.nix
       ./drop.nix
       ./etebase.nix
+      ./feeds.nix
       ./fieldpoc.nix
       ./gitea.nix
       ./ip.nix
diff --git a/hosts/web-2/etebase.nix b/hosts/web-2/etebase.nix
index 626779d..3bfe9d3 100644
--- a/hosts/web-2/etebase.nix
+++ b/hosts/web-2/etebase.nix
@@ -11,6 +11,11 @@
     "etebase.clerie.de" = {
       enableACME = true;
       forceSSL = true;
+      locations = {
+        "= /" = {
+          return = ''302 "/admin/"'';
+        };
+      };
       locations = {
         "/" = {
           proxyPass = "http://127.0.0.1:8001";
diff --git a/hosts/web-2/feeds.nix b/hosts/web-2/feeds.nix
new file mode 100644
index 0000000..e684f8d
--- /dev/null
+++ b/hosts/web-2/feeds.nix
@@ -0,0 +1,48 @@
+{ pkgs, ... }:
+
+{
+  users.users."feeds" = {
+    isSystemUser = true;
+    group = "feeds";
+  };
+
+  users.groups."feeds" = {};
+
+  systemd.tmpfiles.rules = [
+      "d /var/lib/feeds - feeds feeds - -"
+  ];
+
+  services.nginx = {
+    virtualHosts."feeds.clerie.de" = {
+      enableACME = true;
+      forceSSL = true;
+      root = "/var/lib/feeds";
+    };
+  };
+
+  systemd.services."feeds" = {
+    wantedBy = [ "multi-user.target" ];
+    requires = [ "network.target" ];
+    after = [ "network.target" ];
+    serviceConfig = {
+      Type = "oneshot";
+      WorkingDirectory = "/var/lib/feeds";
+      RuntimeDirectory = "feeds";
+      User = "feeds";
+      Group = "feeds";
+      ExecStart = ''
+        ${pkgs.feeds-dir}/bin/feeds-dir
+      '';
+    };
+  };
+
+  systemd.timers."feeds" = {
+    wantedBy = [ "timers.target" ];
+    timerConfig = {
+      OnCalendar = "hourly";
+      RandomizedDelaySec = "1h";
+    };
+    requires = [ "network-online.target" ];
+    after = [ "network-online.target" ];
+  };
+}
diff --git a/pkgs/feeds-dir/default.nix b/pkgs/feeds-dir/default.nix
new file mode 100644
index 0000000..68a831e
--- /dev/null
+++ b/pkgs/feeds-dir/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+pkgs.writeShellApplication {
+  name = "feeds-dir";
+  text = builtins.readFile ./feeds-dir.sh;
+  runtimeInputs = with pkgs; [
+    rainbowrss
+  ];
+}
diff --git a/pkgs/feeds-dir/feeds-dir.sh b/pkgs/feeds-dir/feeds-dir.sh
new file mode 100755
index 0000000..876c7fb
--- /dev/null
+++ b/pkgs/feeds-dir/feeds-dir.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+for file in ./*.txt; do
+	rainbowrss --feeds "${file}" --out "$(basename "${file}" ".txt").html" || true
+done
diff --git a/pkgs/overlay.nix b/pkgs/overlay.nix
index 01098cd..d574b4e 100644
--- a/pkgs/overlay.nix
+++ b/pkgs/overlay.nix
@@ -8,6 +8,7 @@ final: prev: {
   clerie-update-nixfiles = final.callPackage ./clerie-update-nixfiles/clerie-update-nixfiles.nix {};
   chromium-incognito = final.callPackage ./chromium-incognito {};
   factorio-launcher = final.callPackage ./factorio-launcher {};
+  feeds-dir = final.callPackage ./feeds-dir {};
   git-checkout-github-pr = final.callPackage ./git-checkout-github-pr {};
   git-diff-word = final.callPackage ./git-diff-word {};
   git-pp = final.callPackage ./git-pp {};