Now that my blog is live, it seems like a good time to talk about how I consume blogs/news from other sources.

I like to use RSS to get updates on content I really care about rather than letting algorithms decide what is best to show me.

FreshRSS

This is the feed aggregator of choice. There are local-only clients which I’ll talk about later, but it’s nice to have some state synced between devices. For this, I use a self-hosted option FreshRSS.

screenshot of fresh rss

In an attempt to run this on a budget, I’m running this on the cheapest Hetzner VPS available.

While this is configured with the below Terraform, Hetzner doesn’t provide an ArchLinux image, so we mount the ISO and manually do the install.

resource "hcloud_server" "polaris" {
  name        = "polaris"
  server_type = "cx22"
  location    = "nbg1"

  ssh_keys = []

  backups = true
  # image is a placeholder as we will manually install arch anyway
  image = "debian-12"
  iso = "archlinux-2025.02.01-x86_64.iso"

  # Define firewall rules through Hetzner Cloud Firewall
  firewall_ids = [hcloud_firewall.polaris.id]

  public_net {
    ipv4_enabled = true
    ipv6_enabled = true
  }
}

resource "hcloud_firewall" "polaris" {
  name = "firewall"

  # Allow SSH from anywhere
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = ["0.0.0.0/0", "::/0"]
  }

  # Allow HTTP from anywhere
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "80"
    source_ips = ["0.0.0.0/0", "::/0"]
  }

  # Allow HTTPS from anywhere
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "443"
    source_ips = ["0.0.0.0/0", "::/0"]
  }

  # Allow ICMP (ping) from anywhere
  rule {
    direction  = "in"
    protocol   = "icmp"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
}

We also do some DNS setup so I can access this with a nice hostname

resource "aws_route53_record" "fresh-rss" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "fresh-rss"
  type    = "CNAME"
  ttl     = 30
  records = ["polaris.${aws_route53_zone.primary.name}"]
}

resource "aws_route53_record" "polaris_AAAA" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "polaris"
  type    = "AAAA"
  ttl     = 30
  records = [hcloud_server.polaris.ipv6_address]
}

resource "aws_route53_record" "polaris_A" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "polaris"
  type    = "A"
  ttl     = 30
  records = [hcloud_server.polaris.ipv4_address]
}

Once that is all done, we actually need to install and set up FreshRSS. I manage this machine with Puppet. The setup is quite simple; we just use Podman to run the official FreshRSS docker image and use an nginx reverse proxy to do TLS termination.

class fresh_rss (
  String $base_url = 'https://fresh-rss.danwainwright.com',
) {
  require podman
  include nginx
  include nginx::ssl::certbot
  include rss_bridge # Will talk about this later

  file { '/opt/fresh-rss':
    ensure => directory,
  }
  -> file { '/opt/fresh-rss/data':
    ensure => directory,
    group  => 'http',
    mode   => '0775',
  }
  -> file { '/opt/fresh-rss/extensions':
    ensure => directory,
    group  => 'http',
  }

  file { '/opt/fresh-rss/data/config.php':
    ensure  => file,
    content => epp('fresh_rss/config.php.epp', { base_url => $base_url }),
    require => File['/opt/fresh-rss/data'],
    group   => 'http',
    mode    => '0664',
  }

  podman::image { 'fresh-rss':
    image => 'docker.io/freshrss/freshrss:latest',
  }
  -> podman::container { 'fresh-rss':
    image => 'docker.io/freshrss/freshrss:latest',
    flags => {
      mount   => [
        'type=bind,source=/opt/fresh-rss/data,destination=/var/www/FreshRSS/data',
        'type=bind,source=/opt/fresh-rss/extensions,destination=/var/www/FreshRSS/extensions',
      ],
      publish => [
        '4000:80',
      ],
      env     => [
        'TZ=Europe/Paris',
        'CRON_MIN=13,43',
      ],
    },
  }
}

The nginx config looks like

server {
    listen 80;
    server_name fresh-rss.danwainwright.com;

    return 301 https://$server_name$request_uri;
}


server {
    server_name fresh-rss.danwainwright.com;

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/fresh-rss.danwainwright.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/fresh-rss.danwainwright.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

    location / {
        proxy_pass http://localhost:4000;
    }
}

Some manual CertBot commands were needed to generate the initial Let’s Encrypt certificate, which I’ll skip over here.

RSS Bridge

So that’s the core RSS client done, which should be usable. Now on to a useful addition: RSS-Bridge. This is a tool that can scrape a standard website or API and generate an RSS feed from that.

For example, I use the Reddit bridge to get notifications for posts with enough upvotes on subreddits I care about. You can quite easily write new bridges, such as the BazarakiBridge I wrote to get notifications for motorbikes for sale matching my filter.

This is also managed by Puppet.

class rss_bridge {
  require podman

  file { '/opt/rss-bridge':
    ensure => directory,
  }
  -> file { '/opt/rss-bridge/config':
    ensure => directory,
  }

  # Mount in some custom bridges
  file { '/opt/rss-bridge/BazarakiBridge.php':
    ensure => file,
    source => 'puppet:///modules/rss_bridge/BazarakiBridge.php',
  }

  file { '/opt/rss-bridge/CyprusMailBridge.php':
    ensure => file,
    source => 'puppet:///modules/rss_bridge/CyprusMailBridge.php',
  }

  file { '/opt/rss-bridge/BuySellCyprusBridge.php':
    ensure => file,
    source => 'puppet:///modules/rss_bridge/BuySellCyprusBridge.php',
  }

  podman::image { 'rss-bridge':
    image => 'docker.io/rssbridge/rss-bridge:latest',
  }
  -> podman::container { 'rss-bridge':
    image         => 'docker.io/rssbridge/rss-bridge:latest',
    flags         => {
      mount       => [
        'type=bind,source=/opt/rss-bridge/config,destination=/config',
        'type=bind,source=/opt/rss-bridge/BazarakiBridge.php,destination=/app/bridges/BazarakiBridge.php',
        'type=bind,source=/opt/rss-bridge/CyprusMailBridge.php,destination=/app/bridges/CyprusMailBridge.php',
        'type=bind,source=/opt/rss-bridge/BuySellCyprusBridge.php,destination=/app/bridges/BuySellCyprusBridge.php',
      ],
      publish    => [
        '3000:80',
      ],
    },
  }
}

Issues

Bot blocking is becoming a more common thing around the internet, with many sites using CloudFlare services for this. That’s not something RSS-Bridge attempts to solve and is slowly breaking my custom bridges.

Local Clients

Now all of this runs a self-hosted webpage. I like to use NetNewsWire to connect to FreshRSS, providing a nice interface across iOS and macOS.

Conclusion

It’s really not that hard to set up an RSS client, and once you have it, it makes managing incoming content a lot easier.

If you’re producing content in any form, please consider adding RSS support for users like myself.

This blog has an RSS feed at https://www.danwainwright.com/index.xml