The open web still publishes RSS. Almost every blog, news site, YouTube channel, GitHub release page, and podcast exposes a feed, and a good reader turns that firehose into a calm, chronological inbox you actually control. The catch is that hosted readers come and go - Google Reader is the famous casualty, but plenty of smaller services have quietly shut down and taken your subscriptions with them.
FreshRSS fixes that by being yours. It's a fast, open-source PHP aggregator that runs comfortably on a 512 MB VPS, stores everything in SQLite by default, and speaks the Google Reader and Fever sync APIs so the mobile app you already like keeps working. This guide takes you from a fresh server to a working public URL with HTTPS, an admin account, automatic feed refreshing, and mobile sync.
rss.example.com at the serverfreshrss/freshrss behind Caddy in one docker-compose.ymldata volume dailyTotal time: about 20 minutes.
80 and 443 open to the internet for Let's EncryptFreshRSS is light. With a few hundred feeds it sits around 80-120 MB of RAM, and SQLite keeps the storage footprint tiny. If you already run other Docker workloads, this slots in beside them on the same box.
Quick orientation, because people weigh this differently:
The killer feature is API compatibility. FreshRSS implements the Google Reader API, so the best native clients on every platform - Reeder, NetNewsWire, Readrops, FeedMe, Fluent Reader - sync against it as if it were a commercial service.
In your DNS provider, add an A record:
rss.example.com → YOUR_VPS_IPV4
Add an AAAA record if you use IPv6. Verify it resolves:
dig +short rss.example.com
DNS has to point at your VPS before Caddy can fetch a certificate.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm:
docker --version
docker compose version
If you use UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS.
sudo mkdir -p /opt/freshrss
cd /opt/freshrss
sudo mkdir -p freshrss-data freshrss-extensions caddy-data caddy-config
Everything persistent lives under /opt/freshrss. Back up this one directory and you can rebuild the stack on any host.
FreshRSS is a single container, and SQLite means no separate database service to babysit. Create /opt/freshrss/docker-compose.yml:
services:
freshrss:
image: freshrss/freshrss:1.24.3
container_name: freshrss
restart: unless-stopped
environment:
TZ: Europe/Berlin
CRON_MIN: "*/20"
TRUSTED_PROXY: "172.16.0.0/12"
volumes:
- ./freshrss-data:/var/www/FreshRSS/data
- ./freshrss-extensions:/var/www/FreshRSS/extensions
networks:
- freshrss-net
caddy:
image: caddy:2
container_name: freshrss-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- freshrss-net
networks:
freshrss-net:
A few notes:
80 internally. We don't publish it on the host because Caddy reaches it on the Docker network.CRON_MIN: "*/20" runs the built-in feed refresh every 20 minutes. The image ships its own cron, so you don't need a host crontab for updates.TRUSTED_PROXY tells FreshRSS to trust the X-Forwarded-* headers from Caddy. Without it, FreshRSS may build http:// links behind your https:// proxy and mangle logins. The 172.16.0.0/12 range covers the default Docker bridge networks.1.24.3 here) rather than latest so an unattended docker compose pull can't surprise you with a major upgrade.Create /opt/freshrss/Caddyfile:
rss.example.com {
encode zstd gzip
reverse_proxy freshrss:80
}
Caddy will request a Let's Encrypt certificate for rss.example.com automatically on first boot. No extra config needed.
cd /opt/freshrss
sudo docker compose up -d
sudo docker compose logs -f
Watch the logs until Caddy reports the certificate was issued. Then open https://rss.example.com in a browser. You should land on the FreshRSS install wizard.
The browser-based installer walks through five short screens:
data volume.SQLite is the right call for a single-user instance and easily handles thousands of articles. If you plan to host dozens of users, you can rerun this step against a PostgreSQL container instead, but most readers never need it.
Once you're in, there are three ways to subscribe:
https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID; for a subreddit, append .rss to its URL.Organize feeds into categories (folders) from day one - it's far easier than retagging hundreds later.
This is what makes a self-hosted reader actually pleasant to use. FreshRSS exposes the Google Reader API, and most native clients sync against it.
In the web UI:
Then in your reader app, choose the account type and fill in:
https://rss.example.com/api/greader.phpClients known to work over the Google Reader API:
If a client only offers "Fever API," FreshRSS supports that too at https://rss.example.com/api/fever.php - enable it on the same settings page.
Because you set CRON_MIN, the container refreshes feeds on its own. Verify it's running:
sudo docker compose logs freshrss | grep -i cron
You should see periodic refresh entries. To force a refresh immediately - useful right after a big OPML import:
sudo docker exec --user www-data freshrss \
php /var/www/FreshRSS/app/actuator.php --actionName refresh
You can also tune per-feed refresh intervals and article retention under each feed's settings, which keeps the database lean if you follow high-volume news sites.
Everything that matters - the SQLite database, your subscriptions, read state, and favorites - lives in /opt/freshrss/freshrss-data. Lose it and you've lost your reading setup.
Create /usr/local/bin/freshrss-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/freshrss"
DATE="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/freshrss-$DATE.tar.gz" -C /opt/freshrss freshrss-data
find "$BACKUP_DIR" -name "freshrss-*.tar.gz" -mtime +14 -delete
FreshRSS uses SQLite in WAL mode, so a tar of the data directory is safe to take while the container runs - the WAL and shared-memory files are captured alongside the main database. For a belt-and-braces snapshot, you can stop the container first, but for a personal reader the live tar is fine.
Make it executable and schedule a daily run:
sudo chmod +x /usr/local/bin/freshrss-backup.sh
echo "20 3 * * * root /usr/local/bin/freshrss-backup.sh" | \
sudo tee /etc/cron.d/freshrss-backup
For off-site safety, sync /var/backups/freshrss to S3, Backblaze B2, or another VPS with rclone on the same schedule. To restore: stop the stack, replace freshrss-data/ with the contents of a backup, start the stack again.
FreshRSS ships regular releases. The safe upgrade path:
cd /opt/freshrss
# bump the image tag in docker-compose.yml first, then:
sudo docker compose pull
sudo docker compose up -d
Always take a fresh backup first. The container applies any database migrations on startup, and rolling back is far easier with a known-good snapshot than with a half-migrated database.
A personal reader that only you use is a fine candidate for VPN-only access. Pair this with our Tailscale guide so rss.example.com only resolves inside your tailnet. The mobile clients sync fine over WireGuard or Tailscale, and you remove the public attack surface entirely. If you'd rather keep it public but locked down, our SSH hardening and fail2ban guide covers the server side.
Caddy returns 502 Bad Gateway. The FreshRSS container hasn't finished starting or crashed on boot. Check sudo docker compose logs freshrss. Most often it's a volume permission issue - run sudo chown -R 33:33 /opt/freshrss/freshrss-data /opt/freshrss/freshrss-extensions (UID 33 is www-data inside the image) and restart.
Install wizard shows red on the Checks page. The data directory isn't writable by the container. Same fix as above: sudo chown -R 33:33 /opt/freshrss/freshrss-data, then reload the page.
Logins redirect in a loop or links point to http://. FreshRSS isn't trusting the proxy headers. Confirm TRUSTED_PROXY is set in the compose file and matches the Docker network range, then recreate the container with sudo docker compose up -d.
Mobile app says "authentication failed." You're using your login password instead of the API password, or the API endpoint URL is wrong. Set an API password under Settings → Profile, and point the client at https://rss.example.com/api/greader.php exactly.
Feeds never update on their own. CRON_MIN was empty or malformed. Check sudo docker compose logs freshrss | grep -i cron, confirm the value is quoted like "*/20", and recreate the container.
freshrss-extensions volume and enable them in settings.Self-hosted FreshRSS is the kind of service you set up once and forget about. The feeds keep flowing, the apps keep syncing, and the next time a hosted reader shuts down you'll read the news about it in your own reader.
Need a small VPS to run a stack like this? Our Linux plans include fast NVMe storage, IPv6, and plenty of headroom for Docker workloads. See the options.