VPS Tutorials
Self-Host FreshRSS on a VPS (2026): Docker Compose, Caddy HTTPS, Auto-Refresh and Mobile Sync
This tutorial explains how to self-host FreshRSS on a VPS to build a lightweight RSS reader free of algorithmic interference. It covers Docker Compose deployment, the default SQLite setup, automatic HTTPS with Caddy, CRON_MIN auto-refresh, the mobile Google Reader API, pairing with RSSHub, backups, upgrades, security hardening and troubleshooting common issues.

Many people are returning to RSS, not out of nostalgia, but because the information feed has become exhausting. Social platforms stuff content in by algorithm, search results are increasingly full of ads and AI slop, and newsletters easily turn your inbox into a second junk pile.
The benefit of RSS is simple: you read what you subscribe to. No recommendation feed, no hostage-taking by trending lists, and no need to hand your reading habits over to a third-party platform.
FreshRSS is a self-hosted RSS reader that is very well suited to running on a VPS. It is lightweight, open source, supports multiple users, supports web and mobile sync, and can work with tools like RSSHub and RSS-Bridge to bring sites that don't offer RSS into your subscriptions.
This article covers deploying FreshRSS on a VPS with Docker Compose and using Caddy to obtain HTTPS automatically. We'll use the simplest and most stable default SQLite setup, which suits individuals and small teams; if you have a very large number of subscriptions, we'll also explain later when you should consider PostgreSQL.
It suits these scenarios:
- You want to build your own replacement for Google Reader / Inoreader / Feedly.
- You want your feeds, read states and starred articles to live on your own server.
- You want to sync reading progress across a desktop browser, phone app and tablet.
- You need to subscribe to blogs, tech sites, GitHub Releases, news feeds and podcast updates.
- You want to pair it with RSSHub to subscribe to some Chinese-language sites that don't originally have RSS.
It's less suited to these scenarios:
- You only read a few articles occasionally and browser bookmarks are enough.
- You don't want to maintain a VPS, backups and HTTPS at all.
- You need very complex team permissions, comment collaboration or knowledge-base features.
- You mainly want to archive web page snapshots; Linkwarden or Wallabag would be closer to your needs.
FreshRSS is positioned as a "reader", not a web archiving system. What it does best is fetch feeds, sync read states and keep article lists for the long term.
Third-party RSS services are convenient, but they have a few problems:
- Free tiers often limit the number of subscriptions, refresh frequency and search features.
- Your data and reading history live on someone else's platform.
- Some services don't support Chinese-language sites, mainland-China network access or RSSHub feeds well.
- When a service shuts down or raises prices, migrating is a pain.
Self-hosting FreshRSS has these benefits: your subscription OPML can be exported at any time, your data lives on your own VPS, you set the refresh frequency yourself, and mobile clients can sync via a standard API.
FreshRSS is very light. The official Docker image supports amd64, arm64 and arm/v7, and personal use has very low machine requirements.
| Scenario | Recommended configuration |
|---|---|
| Personal use, dozens to hundreds of feeds | 1 vCPU / 512MB-1GB RAM / 10GB SSD |
| Multiple users or paired with RSSHub | 1-2 vCPU / 1-2GB RAM / 20GB SSD |
| Many feeds, long-term full-text retention | 2 vCPU / 2GB RAM / 40GB+ SSD |
If your VPS already runs light services like Caddy, Uptime Kuma, Vaultwarden or Umami, FreshRSS can run alongside them. What actually takes up space is article cache, image cache, the SQLite database and the extensions directory, not the FreshRSS program itself.
FreshRSS supports SQLite, PostgreSQL and MySQL/MariaDB.
For a personal VPS tutorial I recommend SQLite by default, for simple reasons:
- One fewer database container.
- Backing up means backing up the
datadirectory. - Migration is simple.
- It's completely sufficient for 1-5 users.
When should you consider PostgreSQL?
- You have many users.
- You have a very large number of feeds.
- You already have a unified PostgreSQL service.
- You want more standardized database backup, monitoring and permission management.
This article uses the default SQLite setup. It isn't a "toy setup" — it's the most worry-free setup for a personal RSS reader.
This article assumes:
- Ubuntu 22.04 / 24.04 or Debian 12.
- Docker and Docker Compose V2 are installed.
- You have a domain, for example
rss.example.com. - The domain's A record points to the VPS public IP.
- Ports 80 and 443 can be opened for Caddy to obtain HTTPS automatically.
Create the directories:
sudo mkdir -p /opt/freshrss
sudo chown -R "$USER":"$USER" /opt/freshrss
cd /opt/freshrss
mkdir -p data extensions backups caddy-data caddy-config
Directory purposes:
| Directory | Purpose |
|---|---|
data | FreshRSS configuration, user data, SQLite database, cache |
extensions | Third-party extensions |
backups | Local backups |
caddy-data | Caddy certificates and state |
caddy-config | Caddy configuration data |
Because this article uses bind mounts to mount host directories into the container, you also need to handle write permissions. The web user inside the FreshRSS container is usually www-data, with numeric UID/GID commonly 33:33. On a Linux VPS, run:
sudo chown -R 33:33 data extensions
sudo chmod -R u+rwX,g+rwX data extensions
If the www-data UID is different in your environment, you can enter the container to confirm first, or switch to Docker named volumes. Named volumes are closer to the official Compose example and have fewer permission issues; the advantage of bind mounts is that paths are more intuitive when backing up and migrating.
The official documentation explicitly requires backing up data, and if you use third-party extensions, backing up extensions is also recommended.
Create .env:
nano .env
Write:
FRESHRSS_DOMAIN=rss.example.com
TZ=Asia/Shanghai
CRON_MIN=7,37
Explanation:
FRESHRSS_DOMAIN: the domain used by Caddy.TZ=Asia/Shanghai: use the China time zone to avoid article times and refresh times looking wrong.CRON_MIN=7,37: refresh feeds at the 7th and 37th minute of every hour, roughly once every 30 minutes.
FreshRSS's official documentation states that the update script won't refresh any single feed too frequently, so there's no need to set the scheduled task too densely. For personal use, once every 20-30 minutes is enough.
Create docker-compose.yml:
nano docker-compose.yml
Write:
services:
freshrss:
image: freshrss/freshrss:latest
container_name: freshrss
restart: unless-stopped
env_file:
- .env
environment:
TZ: ${TZ}
CRON_MIN: ${CRON_MIN}
FRESHRSS_ENV: production
volumes:
- ./data:/var/www/FreshRSS/data
- ./extensions:/var/www/FreshRSS/extensions
ports:
- "127.0.0.1:8080:80"
logging:
options:
max-size: 10m
healthcheck:
test: ["CMD", "cli/health.php"]
timeout: 10s
start_period: 60s
interval: 75s
retries: 3
caddy:
image: caddy:2-alpine
container_name: freshrss-caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
depends_on:
- freshrss
Here FreshRSS is deliberately bound to 127.0.0.1:8080, so port 8080 cannot be accessed directly from the internet. What's actually exposed externally is Caddy's 80/443.
If you want long-term reproducibility, in production you can pin freshrss/freshrss:latest to a specific version tag, for example freshrss/freshrss:1.29.1. The official documentation states that latest is the latest stable version and edge is the rolling branch; beginners are not advised to use edge directly in production.
Create Caddyfile:
nano Caddyfile
Write:
{$FRESHRSS_DOMAIN} {
encode zstd gzip
reverse_proxy freshrss:80
}
Open the firewall:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
If your cloud provider has security groups, allow 80 and 443 there too. When Caddy fails to obtain a certificate, check DNS, the system firewall and cloud security groups first.
FreshRSS's official Caddy documentation also supports subdirectory deployment, but I don't recommend beginners mount it under a path like /rss/. A subdomain like rss.example.com is simpler and avoids pitfalls with cookies, the API and mobile sync.
Run:
cd /opt/freshrss
docker compose up -d
Check status:
docker compose ps
Check logs:
docker compose logs -f --tail=100 freshrss
Open your browser and visit:
https://rss.example.com
The first visit shows the installation wizard. Recommended choices:
- Language: Simplified Chinese or English.
- Database: SQLite.
- Admin account: don't use a weak password.
- Site address: enter the real HTTPS domain.
If permission issues appear in the installation wizard, first check whether data and extensions have been granted to the www-data user inside the container. With the bind mount approach in this article, the most common fix is to re-run:
cd /opt/freshrss
sudo chown -R 33:33 data extensions
This Compose setup has already enabled cron inside the container via CRON_MIN=7,37.
You can watch refreshes in the logs:
docker compose logs --tail=200 freshrss
FreshRSS officially offers three refresh methods:
- Cron inside the container: simplest, used in this article.
- Host cron calling
actualize_script.php. - A separate cron-only container.
Don't enable all three at once. Duplicate refreshes won't make any single feed infinitely faster; they just add log noise and troubleshooting complexity.
FreshRSS supports manually adding RSS/Atom feeds and also supports OPML import.
If you're migrating from another RSS service, the usual process is:
- Export OPML from the old service.
- Import OPML in the FreshRSS admin panel.
- Wait for one round of auto-refresh.
- Check for failed feeds.
- Organize by category.
It's recommended to import a small batch for testing first, not thousands of feeds at once. Many old OPML files contain dead sites, and the first refresh may be very slow.
FreshRSS supports the Google Reader API and the Fever API. The official documentation also states that the Google Reader API is recommended for mobile, as it's more complete.
Admin panel steps:
- Go to settings.
- Enable API access in the authentication-related settings.
- Set the API password in your user profile.
- Open the API check page and confirm the configuration passes.
For mobile, you can choose clients that support the Google Reader API, such as Reeder, NetNewsWire, FeedMe, Read You and others. Keeping the API password separate from the login password is a good thing: mobile apps use the API password, and you shouldn't fill your main login password everywhere.
A common API address looks like:
https://rss.example.com/api/greader.php
Different clients fill it in slightly differently; some only need the root domain, others need the API path. If login fails, first open the API check page in the FreshRSS admin panel — it will tell you directly what's wrong with the server configuration.
Many sites on the Chinese-language internet have no RSS, or have very poor RSS. In that case FreshRSS can pair with RSSHub.
But there's one security point you must note: FreshRSS blocks access to some internal addresses by default to avoid SSRF-type risks. If you run RSSHub on the same Docker network, you need to set INTERNAL_HOST_ALLOWLIST per the official Docker documentation, allowing only explicit internal services, for example:
environment:
INTERNAL_HOST_ALLOWLIST: rsshub:1200
Don't write * or 0.0.0.0/0 just to save effort. That opens up access to all internal addresses and is very risky.
If you don't deploy RSSHub and instead subscribe directly to public RSS feeds, you don't need this configuration.
With the SQLite setup, the core backup is data and extensions. But SQLite databases aren't suitable for being packed online as the sole backup while the service is continuously writing. A more reliable approach is: temporarily stop the FreshRSS container before backing up, complete the file archive, then start it again.
Create a backup script:
nano backup-freshrss.sh
Write:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/freshrss"
BACKUP_DIR="$APP_DIR/backups"
DATE="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR/$DATE"
cd "$APP_DIR"
# 先尝试生成 FreshRSS 自带数据库快照,便于迁移和细粒度恢复。
# 如果这一步失败,脚本会停止,避免产生“看似成功但实际不完整”的备份。
docker exec --user www-data freshrss cli/db-backup.php
# 停止 FreshRSS,避免在线打包 SQLite 数据库时出现不一致。
docker compose stop freshrss
# 备份配置、SQLite 数据、扩展、Compose 和 Caddy 配置。
tar czf "$BACKUP_DIR/$DATE/freshrss-data.tar.gz" data extensions docker-compose.yml Caddyfile .env
# 重新启动服务。
docker compose start freshrss
# 保留最近 14 天备份。
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} \;
echo "Backup saved to $BACKUP_DIR/$DATE"
Add execute permission and test:
chmod +x backup-freshrss.sh
./backup-freshrss.sh
ls -lh backups
Add a scheduled task:
crontab -e
Back up at 3 AM every day:
0 3 * * * /opt/freshrss/backup-freshrss.sh >> /opt/freshrss/backups/backup.log 2>&1
This script briefly stops FreshRSS, which is usually acceptable for a personal reader. If you can't accept downtime, you should use FreshRSS's built-in db-backup.php export as the primary database backup path, back up configuration and extensions separately, and not treat an online tar of the running SQLite file as the sole recovery basis.
A local backup is only the first layer. A more reliable approach is to use rclone to sync to object storage, another VPS or a NAS.
Stop the service before restoring:
cd /opt/freshrss
docker compose down
Find the backup to restore, for example:
ls -lh /opt/freshrss/backups
Assume the backup directory is /opt/freshrss/backups/2026-07-09-0300/, extract it:
tar xzf /opt/freshrss/backups/2026-07-09-0300/freshrss-data.tar.gz -C /opt/freshrss
If you're restoring a database snapshot generated by cli/db-backup.php, you can also use cli/db-restore.php per FreshRSS's official restore process. Don't mix file-level restore and database-level restore carelessly; first confirm whether you're restoring the whole site directory or a database export.
Fix bind mount permissions:
cd /opt/freshrss
sudo chown -R 33:33 data extensions
Restart:
docker compose up -d
After restoring, check:
- Whether you can log in.
- Whether feeds are still there.
- Whether read states are correct.
- Whether the mobile API still connects.
- Whether auto-refresh runs.
Don't just back up without rehearsing. RSS data may not be core business, but losing years of subscriptions and starred articles is still painful.
Back up before upgrading:
cd /opt/freshrss
./backup-freshrss.sh
If using Docker Compose:
docker compose pull
docker compose up -d
After upgrading, check:
docker compose ps
docker compose logs --tail=100 freshrss
The official update documentation notes: for cross-version upgrades it's best to go version by version, not jump directly from a very old version to a very new one. For instances that haven't been maintained for a long time, check the Release Notes first, then decide the upgrade path.
At minimum, do these:
- Only open 80/443, don't open 8080.
- Use a strong admin password.
- Set the API password separately from the login password.
- Don't put
data,extensionsorbackupsin web-accessible directories. - Don't casually set
INTERNAL_HOST_ALLOWLIST=*. - If you don't need public registration, don't enable multi-user registration.
- Regularly update the image and VPS system patches.
- Sync backups off-site.
- If only you use it, you can add Cloudflare Access, Tailscale or WireGuard on top.
FreshRSS itself isn't heavy, but it fetches external URLs. Any service that can fetch external web pages must pay attention to SSRF and internal network access boundaries.
First check the container:
docker compose ps
Then check Caddy:
docker compose logs --tail=100 caddy
Focus on DNS, 80/443, the firewall and security groups. Under Cloudflare proxy mode, incorrect certificate or origin configuration can also cause access problems.
Check whether CRON_MIN exists in .env:
grep CRON_MIN .env
Then check the logs:
docker compose logs --tail=200 freshrss
If you choose host cron, don't set CRON_MIN again, to avoid two refresh mechanisms running at the same time.
Check in order:
- Whether API access is enabled in the admin panel.
- Whether the API password is set in the user profile.
- Whether the client selected the Google Reader API.
- Whether the API address is filled in correctly.
- Whether the reverse proxy forwards the path correctly.
The API check page in the FreshRSS admin panel is very useful; look at its PASS/FAIL first, and don't blindly change the client.
This is something FreshRSS can't solve. FreshRSS is a reader, not an all-purpose scraper.
Solutions:
- Look for a hidden RSS feed on the site.
- Use RSSHub.
- Use RSS-Bridge.
- Use FreshRSS's web scraping feature for simple pages.
But the more it resembles scraping, the more you need to pay attention to frequency, copyright and the target site's rules.
Common causes are cache, images, extensions or a large number of historical articles.
First check directory sizes:
du -h --max-depth=2 /opt/freshrss | sort -h
The official backup documentation mentions that cache can be skipped, because FreshRSS can rebuild the cache. You can also adjust the historical article retention policy to avoid unlimited growth.
These tools are often lumped together, but they have different purposes.
| Tool | Main purpose |
|---|---|
| FreshRSS | RSS reading, subscriptions, read-state sync |
| RSSHub | Convert sites without RSS into RSS feeds |
| Linkwarden | Bookmark management and web archiving |
| Wallabag | Read-later and article saving |
If your goal is to read feeds every day, choose FreshRSS. If your goal is to bookmark web pages and save snapshots, choose Linkwarden. If you want to subscribe to complex sources like Zhihu, Weibo or Bilibili, FreshRSS can pair with RSSHub.
Check before formal use:
- The domain resolves to the VPS.
- 80/443 are allowed in the firewall and security groups.
- FreshRSS is bound only to
127.0.0.1:8080. - Caddy has successfully obtained an HTTPS certificate.
-
TZ=Asia/Shanghaiis set. -
CRON_MINis set, and only one refresh method is enabled. -
dataandextensionsare granted to thewww-datauser inside the container, or named volumes are used instead. - The admin password is set to a strong password.
- The mobile API is enabled and tested successfully.
- The backup script has run successfully.
- At least one restore rehearsal has been done.
- If using RSSHub,
INTERNAL_HOST_ALLOWLISTonly allows explicit services.
FreshRSS's value isn't in showing off, but in turning the information feed back into something you control.
You can put tech blogs, open-source project Releases, news sites, podcast updates and forum posts all into FreshRSS. Open the reader every day and only read what you subscribed to, without being led around by a recommendation feed.
If you already have a VPS, FreshRSS is a lightweight application very much worth self-hosting. It doesn't eat resources, backups are simple, mobile sync is mature, and paired with RSSHub it's also well suited to information subscription scenarios on the Chinese-language internet.