How to Self-Host FreshRSS with Docker
FreshRSS is an AGPL-3.0-licensed web feed reader that keeps subscriptions, article state, and feed fetching on a server you operate. This guide deploys the official freshrss/freshrss:1.29.1 image, the current release when this article was checked on July 16, 2026, on a fresh Debian or Ubuntu VPS, with SQLite for the database and Caddy for HTTPS at rss.example.com.
The container’s Apache listener is available only at 127.0.0.1:8080. Caddy is the public entry point, while the image’s built-in cron runs feed refreshes twice an hour. The verification path adds a real Atom feed, runs the updater, marks an article read, restarts the container, and checks that both the subscription and read state survived.
FreshRSS is one option in our broader guide to self-hosted apps for a VPS. It makes a good first service; just plan on maintaining TLS, updates, and backups after the novelty wears off.
Prerequisites
You need:
- a Debian or Ubuntu VPS with root or sudo access;
- an A record for
rss.example.compointing to the VPS, plus an AAAA record only if IPv6 is configured and filtered correctly; - TCP 80 and 443 open for Caddy and certificate issuance;
- a modern browser and an off-server backup destination.
The SQLite path in this tutorial is simple and complete for a single VPS. FreshRSS also supports PostgreSQL and MySQL/MariaDB, but either choice adds a separate database service, credentials, upgrades, and backup scope. No public database port is needed.
Secure the host’s administrative login before deploying the reader. Our SSH hardening guide explains how to add and test keys before disabling passwords.
Install Docker Engine and the Compose plugin with Docker’s convenience script (read it first if piping a script into root bothers you, or use Docker’s apt repository instead):
curl -fsSL https://get.docker.com -o get-docker.sh
sudo bash get-docker.sh
sudo docker compose version
Install Caddy and UFW from the Debian or Ubuntu repository. Caddy also publishes official package instructions for distributions that need its upstream repository.
sudo apt update
sudo apt install -y caddy ufw
This firewall example assumes administrative SSH uses TCP 22:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Replace port 22 if you moved SSH. Apply the same public inbound rules to the provider’s cloud firewall or security group. Port 8080 must not be public.
Create the Compose deployment
Store the deployment under /opt/freshrss. Bind mounts make the data and extensions easy to inspect, archive, and restore without relying on Docker’s volume-name conventions.
sudo install -d -m 750 /opt/freshrss
sudo install -d -m 750 /opt/freshrss/data
sudo install -d -m 750 /opt/freshrss/extensions
cd /opt/freshrss
Create /opt/freshrss/compose.yml. It follows FreshRSS’s official Docker examples; the release pin, loopback binding, and staggered cron schedule are our choices.
sudo tee compose.yml > /dev/null <<'YAML'
services:
freshrss:
image: freshrss/freshrss:1.29.1
container_name: freshrss
restart: unless-stopped
environment:
TZ: "UTC"
CRON_MIN: "13,43"
volumes:
- ./data:/var/www/FreshRSS/data
- ./extensions:/var/www/FreshRSS/extensions
ports:
- "127.0.0.1:8080:80"
logging:
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "cli/health.php"]
timeout: 10s
start_period: 60s
interval: 75s
retries: 3
YAML
FreshRSS listens on port 80 inside this image. Docker maps it to loopback port 8080, where only host processes such as Caddy can connect. /var/www/FreshRSS/data contains configuration, user data, and the SQLite databases. The extensions mount preserves any third-party extensions installed later.
TZ=UTC makes log and cron times unambiguous. CRON_MIN=13,43 asks the image’s scheduled job to run at 13 and 43 minutes past each UTC hour. The stagger avoids the common top-of-hour spike. FreshRSS’s feed-update documentation notes that scheduling much more often than every 20 minutes brings little benefit because individual feeds are not refreshed more frequently than that.
Pinning 1.29.1 keeps the deployment reproducible. It also requires you to read the FreshRSS release notes and deliberately change the tag when you are ready to update. Avoid the rolling edge tag for a routine production install.
Start the service:
cd /opt/freshrss
sudo docker compose up -d --wait --wait-timeout 120
sudo docker compose logs --tail=50 freshrss
Confirm that Apache is not exposed on every interface:
sudo ss -lntp | grep ':8080'
curl -fsSI http://127.0.0.1:8080/
The listener should show 127.0.0.1:8080, not 0.0.0.0:8080.
Complete the web installer through an SSH tunnel
Do not add the public Caddy route yet. The uninitialized web installer creates the first user, so exposing it before setup gives an Internet visitor a chance to claim the instance. Complete it through a private SSH tunnel instead.
From your workstation, open a tunnel and leave it running:
ssh -N -L 8080:127.0.0.1:8080 YOUR_USER@SERVER_IP
Browse to http://127.0.0.1:8080. The official Docker guide recommends the web installation path used here.
- Choose the interface language and continue after the prerequisite check passes.
- Select SQLite as the database.
- Set the application or base URL to
https://rss.example.com. - Create the initial user with a strong, unique password.
- Select form authentication and finish the installation.
- Log in through the tunnel.
Never select No authentication for a reader reachable from the Internet. FreshRSS’s access-control documentation calls that mode dangerous on a public server.
The Compose file contains no administrator password because the browser setup writes it directly into the persistent data directory. The official image also supports unattended environment variables, but those variables apply only during first initialization and make secret handling easier to get wrong.
Before public exposure, confirm the initial user exists:
cd /opt/freshrss
sudo docker compose exec --user www-data freshrss cli/list-users.php
Sign out through the tunneled browser, reload the root page, and visit http://127.0.0.1:8080/i/. The root must show the login flow, and /i/ must not offer a fresh installation wizard. Stop and inspect the persistent data directory if the installer is still available. Close the tunnel only after those checks pass.
Configure Caddy and TLS
Now expose the initialized login page through Caddy. Use an imported site snippet so this tutorial does not overwrite other hosts already served by Caddy:
sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/freshrss.caddy > /dev/null <<'CADDY'
rss.example.com {
reverse_proxy 127.0.0.1:8080
}
CADDY
if ! sudo grep -Eq \
'^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
/etc/caddy/Caddyfile; then
printf '\nimport /etc/caddy/sites-enabled/*\n' | \
sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -fsSI https://rss.example.com/
Caddy obtains and renews a public certificate when DNS points to this VPS and TCP 80/443 can reach it. If the certificate does not appear, check the DNS records, UFW, the provider firewall, and sudo journalctl -u caddy -n 100 --no-pager.
Do not broaden FreshRSS’s TRUSTED_PROXY setting just to make the page load. It affects which forwarded client addresses and authentication headers FreshRSS trusts. This host-proxy design works without adding a broad Docker-network range. If a later authentication integration requires the setting, trust only the actual proxy path.
Add a feed and prove state persists
Log in at https://rss.example.com. Choose Add a feed and enter the public FreshRSS releases feed:
https://github.com/FreshRSS/FreshRSS/releases.atom
Trigger an immediate server-side refresh rather than waiting for minute 13 or 43:
cd /opt/freshrss
sudo docker compose exec --user www-data freshrss \
php /var/www/FreshRSS/app/actualize_script.php
sudo docker compose logs --since=5m freshrss
Reload the browser and confirm release entries appear. Open one item, mark it read, and optionally mark it as a favorite. Then restart the container:
sudo docker compose restart freshrss
sudo docker compose exec --user www-data freshrss cli/health.php
Log in again. The subscription, its articles, and the read or favorite state should all have survived, which exercises the browser workflow, updater, SQLite data, and bind mount together. After minute 13 or 43 passes, check sudo docker compose logs --since=45m freshrss to confirm cron ran.
If you use a mobile reader, enable API access in the user settings and set a separate API password. Test login and article-state sync from that client. Do not assume the normal web password is the API credential.
Use OPML for migration, not recovery
FreshRSS can import and export subscriptions as OPML from Subscription management > Import/export. Export an OPML file after initial setup and whenever the subscription list changes substantially. It is useful for moving feeds to another reader or rebuilding the list by hand.
OPML is not a full backup. It does not preserve the whole FreshRSS configuration, read and favorite state, user credentials, extensions, or every feed-specific setting. Keep it beside, not instead of, the application backup.
Back up data, SQLite, and extensions
FreshRSS’s backup guide provides a portable per-user SQLite export:
cd /opt/freshrss
sudo docker compose exec --user www-data freshrss cli/db-backup.php
The command writes the export under persistent user data, so it still needs to be copied off the server. For full state, archive both bind mounts with the container stopped:
sudo install -d -m 700 /var/backups/freshrss
cd /opt/freshrss
sudo docker compose stop freshrss
sudo tar -C /opt/freshrss -czf \
"/var/backups/freshrss/freshrss-$(date +%F-%H%M%S).tar.gz" \
data extensions
sudo docker compose up -d --wait --wait-timeout 120
Encrypt the archive and copy it to storage outside the VPS. If you adopt PostgreSQL or MySQL/MariaDB, include a native pg_dump or mysqldump from the same recovery point.
Test the archive on an isolated VPS with the same image tag. Transfer one archive, set BACKUP to its real path, and keep the empty directories as rollback points:
export BACKUP=/path/to/freshrss-YYYY-MM-DD-HHMMSS.tar.gz
cd /opt/freshrss
sudo docker compose stop freshrss
sudo mv data data.empty
sudo mv extensions extensions.empty
sudo tar -C /opt/freshrss -xzf "$BACKUP"
sudo docker compose up -d --wait --wait-timeout 120
sudo docker compose exec --user www-data freshrss cli/health.php
Log in, refresh the test feed, and confirm the subscription and read state. Test each installed extension too.
For a portable database export, the official restore command is:
sudo docker compose exec --user www-data freshrss \
cli/db-restore.php --delete-backup --force-overwrite
Use it only against the intended test or recovery instance after reading the prompts and backup documentation.
Every Riven Cloud plan includes daily backups, and they make a good safety net, but they do not replace an application-consistent backup and a rehearsed restore.
Upgrade the container
Read the release notes, export the user database, and take the stopped-container archive. Change only the exact image tag in compose.yml, then run:
cd /opt/freshrss
sudo docker compose pull
sudo docker compose up -d --wait --wait-timeout 120
sudo docker compose exec --user www-data freshrss cli/health.php
Log in, run /var/www/FreshRSS/app/actualize_script.php, and confirm feed and read-state behavior. Docker deployments should replace the image rather than use the web updater. Treat a PostgreSQL major-version change as a separate database migration.
Choosing a Riven Cloud VPS for FreshRSS
For a private reader on SQLite, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable fit. Feed count, refresh frequency, full-text retrieval, and retention decide how far it stretches; if you add PostgreSQL, RSS-Bridge, heavy image proxying, or browser-based scraping, our Ultra plan (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) is the sensible step up. Every plan is an unmanaged KVM VPS with full root access, a 1 Gbps port, and daily backups, so you run the stack yourself and keep full control.
FreshRSS talks both to its readers and to feed publishers, so location affects two paths. Choose Tokyo or Singapore based on where you read from, and check that your important feeds respond well from that data-center IP (more RAM will not fix a publisher that blocks data-center traffic). If you read from mainland China, a quick comparison of the Tokyo Looking Glass and Singapore Looking Glass from your own access network will show which location feels faster. Once you have a rough idea of the workload, the details are on the Riven Cloud VPS plans page.
Security limits
FreshRSS fetches user-supplied feed URLs from the server, which creates server-side request forgery risk on shared or multi-user instances. As of July 16, 2026, release 1.29.1 does not restrict fetching internal hosts, so a feed URL can reach loopback or LAN services the container can route to. Rely on network-level controls instead: the compose and network layout in this guide, the firewall, and keeping cloud metadata endpoints and sensitive internal services out of the container’s reach. The next release after 1.29.1 adds default internal-host blocking with an INTERNAL_HOST_ALLOWLIST allowlist (FreshRSS PR #8400), so revisit this section after upgrading; at that point, allow only the exact host, port, or network range a private RSS-Bridge or RSSHub needs, and never set the allowlist to *.
Install extensions only from sources you trust because they execute server-side code. Protect private feed URLs, feed credentials, browser sessions, and backups. Keep Apache’s port on loopback and apply host and container updates promptly.
When not to self-host
A hosted reader is the better choice if nobody will own TLS, feed scheduling, updates, monitoring, and restore tests. A public reader for untrusted users also brings SSRF, privacy, abuse, and transfer concerns that a personal instance largely avoids, and a managed service is the safer fit when private feed credentials need formal controls or reader availability needs a commercial SLA.
Share