Use case · Feed reader

FreshRSS self hosted on a dedicated server, from the official docs

Self-hosting FreshRSS takes one PHP container from the official freshrss/freshrss image, a database, a reverse proxy for HTTPS, and a feed-refresh schedule. This page builds it with Docker Compose and PostgreSQL on a whole machine: the web port bound to 127.0.0.1, the install wizard finished through an SSH tunnel before anything is public, CRON_MIN refreshing feeds twice an hour, the Google Reader API switched on for phone apps, and a nightly archive of the data volume.

See the machines

What FreshRSS needs from the machine

Very little. The project describes its requirements as really low: a web server, PHP and a database. The official Docker image bundles Apache and PHP, so the host needs only Docker Engine and Compose v2. Images are published for linux/amd64, linux/arm64 and linux/arm/v7, on Debian by default with -alpine variants.

DatabaseStatus in the docsHow it is backed up
SQLiteBuilt in; the Docker README calls it the easiest, with good performanceInside the data volume
PostgreSQL 10+The recommended database on the prerequisites pagecli/db-backup.php or pg_dump
MariaDB 10.6+ / MySQL 8.0+Also supportedcli/db-backup.php or mysqldump

Image tags follow the release channels: latest is the newest versioned release, edge is the rolling branch, and x.y.z tags pin one release. The docs warn that fixes are not backported to older versions, so a pinned tag is something you move forward on purpose, not a place to stay. This build pins 1.30.4, the release current when it was written.

Choosing the line

A feed reader for one person or a small team is a single service, which is the job the dedicated server range assigns to Moonshot cartridges: one small server per slot, one service per machine. A single-socket server is the class described for a web or application host with its database beside it, the shape of this build. Either way the machine is whole: every core, the disks and an unmetered port, with root and no other tenant.

  • Out-of-band console: every racked single-, dual- and quad-socket server has one (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges. The remaining cartridges offer power control alone; on those, a firewall mistake is fixed by reinstalling from the control panel.
  • IPv6 is not in the default build. It is added at checkout at no charge, as individual addresses. Ask for it if your phone apps will reach the reader over IPv6.
  • Leave out the X-Gene ARM cartridge here: it boots only Ubuntu 16.04 and 18.04. For arm64, the Ampere Altra line on the ARM dedicated servers page runs the official arm64 image.

Firewall, database secret and Compose file

Start from Ubuntu with Docker Engine 28 or later and Compose v2. ufw gets IPv6 switched on ahead of the first allow rule and is enabled with SSH as the only open port. The Compose file adapts the official docker-compose.yml and docker-compose-db.yml: the postgres:18 service, its volume at /var/lib/postgresql, and the TRUSTED_PROXY value are theirs; the loopback-only port and the env file are this build.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
mkdir -p /opt/freshrss && cd /opt/freshrss
umask 077
printf "POSTGRES_DB='freshrss'\nPOSTGRES_USER='freshrss'\nPOSTGRES_PASSWORD='%s'\n" "$(openssl rand -hex 24)" > db.env
cat > compose.yaml <<'EOF'
services:
  freshrss:
    image: freshrss/freshrss:1.30.4
    container_name: freshrss
    restart: unless-stopped
    logging:
      options:
        max-size: 10m
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - data:/var/www/FreshRSS/data
      - extensions:/var/www/FreshRSS/extensions
    environment:
      TZ: UTC
      CRON_MIN: '13,43'
      TRUSTED_PROXY: 172.16.0.1/12 192.168.0.1/16
    depends_on:
      - freshrss-db
  freshrss-db:
    image: postgres:18
    container_name: freshrss-db
    restart: unless-stopped
    env_file: db.env
    volumes:
      - db:/var/lib/postgresql
volumes:
  data:
  extensions:
  db:
EOF
docker compose up -d

umask 077 leaves db.env and compose.yaml at mode 600. printf is a shell builtin, so the generated password never appears in ps output or shell history. The values are single-quoted, which Compose strips when it reads the file. Keep the Postgres major version (postgres:18) fixed: the Docker README warns that a newer major version refuses to start on the old data.

A port mapping without an address publishes on every host address, IPv4 0.0.0.0 and IPv6 [::]. On IPv4, published traffic skips ufw. On IPv6 it skips ufw only when Docker manages IPv6 networking itself; otherwise docker-proxy holds the [::] socket and ufw rules apply to it. Binding to 127.0.0.1 avoids both, and since Docker Engine 28 a 127.0.0.1 binding is reachable from the host only (moby#45610).

The install wizard, over an SSH tunnel

Until the web installer is finished, whoever opens the site chooses the database and creates the admin account. So it is finished privately, before nginx exists. The order is ours; every setting comes from the docs.

  1. From your workstation, open a tunnel with your own login: ssh -L 8080:127.0.0.1:8080 you@your-server, then browse to http://localhost:8080.
  2. Pick the language and let the checks pass. For the database choose PostgreSQL, host freshrss-db (the container name, as the Docker README says), user and database freshrss, and the password from db.env (cat /opt/freshrss/db.env on the server).
  3. Create the default user with form login and a strong password. In FreshRSS this account is the admin; the FAQ notes there is only one admin at a time.
  4. Fix the base URL. The installer writes base_url from the address you used (see app/install.php), here http://localhost:8080. It is shown but not editable in the web interface, so the next block sets it with cli/reconfigure.php.
  5. Under Administration, System configuration, check that the maximum number of accounts is 1. That is the default in config.default.php, and the FAQ explains that 1 disables self-registration because the admin takes the only place. Admins can still add users by hand.

Public HTTPS with nginx

With the admin account in place, open 80 and 443 and put nginx in front. The server block follows the nginx domain-root example in the Docker README, including the two lines that forward the Authorization header the Google Reader API needs. nginx runs on the host as a normal process, so the ufw rule is what lets visitors reach it on IPv4 and IPv6.

cd /opt/freshrss
docker compose exec --user www-data freshrss cli/reconfigure.php --base-url https://rss.example.com
ufw allow 80,443/tcp
apt install -y nginx certbot python3-certbot-nginx
cat > /etc/nginx/sites-available/freshrss <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name rss.example.com;
    location / {
        proxy_pass http://127.0.0.1:8080/;
        proxy_redirect off;
        proxy_buffering off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_read_timeout 90;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
    }
}
EOF
ln -s /etc/nginx/sites-available/freshrss /etc/nginx/sites-enabled/freshrss
nginx -t && systemctl reload nginx
certbot --nginx -d rss.example.com

certbot fetches the certificate and adds the HTTPS server block for the name. The image ships Apache with mod_remoteip, and TRUSTED_PROXY sets which addresses it believes in X-Forwarded-For. The access-control docs warn that FreshRSS also trusts Remote-User and X-WebAuth-User headers from those addresses; in the source (app/Models/Auth.php) that applies only when the login type is HTTP authentication, so keep form login unless you deliberately set up proxy authentication. For other front ends see Nginx Proxy Manager on a dedicated server.

Feed refresh: CRON_MIN

Without CRON_MIN the container starts no cron daemon, so nothing refreshes feeds on a schedule. The variable takes a cron minute field; the docs recommend a refresh rate of about twice per hour, with 13,43 as their recommended value. The update script never refreshes one feed more often than once every 20 minutes, so, as the docs put it, triggering it much more often makes little sense.

  • Option 1, used here: CRON_MIN inside the FreshRSS container. Simple, but cron runs as an unmonitored second process.
  • Option 2: a host cron entry running docker exec --user www-data freshrss php ./app/actualize_script.php, with CRON_MIN left unset.
  • Option 3: a second container from the same image running cron -f, with the same volumes and network, which gets restarts and logging from Docker. Pick exactly one.

Phone and desktop clients

FreshRSS speaks two client APIs: the Google Reader API, which the README rates best, and the Fever API, which it lists as limited, less efficient and less safe. API access is off in config.default.php. Turn on "Allow API access" under Authentication, then each user sets a separate API password under Profile; the docs explain it may be used in less safe places than the main password and grants less.

Clients point at https://rss.example.com/api/greader.php or /api/fever.php with the username and the API password. The Profile page links to a server check: PASS means you are done, and if only the second check, without %2F support, passes, something in front of FreshRSS is rejecting escaped slashes; the way out the docs give is a client that does not escape them. The image itself sets Apache AllowEncodedSlashes On.

Backups and restores

The backup page names ./data/ as required (its cache/ folder can be skipped), ./extensions/ as recommended, and an external database as a separate job. cli/db-backup.php covers that job: it writes each user's database to data/users/*/backup.sqlite, a portable SQLite copy, which the script below then archives with the rest of the volume. printf writes the entry into /etc/cron.d, and its log folder is created up front so the first run has somewhere to write.

mkdir -m 700 -p /srv/freshrss-backup /var/log/freshrss-backup
cat > /opt/freshrss/backup.sh <<'EOF'
set -eu
umask 077
cd /opt/freshrss
docker compose exec -T --user www-data freshrss cli/db-backup.php --quiet
out=/srv/freshrss-backup/freshrss-$(date +%F).tgz
docker compose exec -T freshrss tar -czf - --exclude=data/cache -C /var/www/FreshRSS data extensions > "$out.part"
mv "$out.part" "$out"
find /srv/freshrss-backup -name 'freshrss-*.tgz' -mtime +14 -delete
EOF
printf '%s\n' 'SHELL=/bin/sh' \
  '30 3 * * * root umask 077; bash /opt/freshrss/backup.sh > /var/log/freshrss-backup/run-$(date +\%F).log 2>&1' \
  > /etc/cron.d/freshrss-backup

Fourteen days of retention is an example. Pull copies onto a separate host yourself, with rsync or scp: an archive on the same disks fails with them. To restore, from /opt/freshrss:

  1. Set A=/srv/freshrss-backup/freshrss-2026-10-23.tgz and run tar -tzf "$A" > /dev/null && echo archive OK. Continue only when archive OK appears; the steps below overwrite live data.
  2. docker compose stop freshrss, leaving the database running.
  3. docker compose run --rm --no-deps -T --entrypoint sh freshrss -c 'tar -xzf - -C /var/www/FreshRSS' < "$A" unpacks the data and extensions into the volumes.
  4. docker compose run --rm --no-deps freshrss cli/db-restore.php --delete-backup --force-overwrite clears each user's tables and reloads them from the restored backup.sqlite, the restore command the backup page gives.
  5. docker compose up -d, then sign in and check a few feeds.

Upgrades, licence and limits

Upgrading is a newer image on the same volumes. Run the backup, edit the tag in compose.yaml; docker compose pull downloads it, after which docker compose up -d swaps the running container for it. The updating page asks you to go version by version (1.26.0 to 1.27.1 before 1.28.1), checking FreshRSS runs after each step, so step the tag through intermediate releases rather than jumping. The optional healthcheck in the official Compose example calls cli/health.php.

FreshRSS is licensed GNU AGPL 3 (the repository lists AGPL-3.0). Our general reading, not legal advice: running it unmodified for yourself or your team owes nothing. Serve a modified copy to other people over a network and you owe those users an offer of the modified source; handing someone copies, such as a modified image, carries the usual GPL source obligations.

  • The reader fetches feeds from the server, so in a shared instance a user could point it at internal addresses. FreshRSS blocks loopback and private hosts by default; the allowlist (INTERNAL_HOST_ALLOWLIST) should stay narrow.
  • You run the stack: the OS, Docker, FreshRSS updates and restore tests stay with you.

Questions

Should a self-hosted FreshRSS use SQLite or PostgreSQL?

Both work. SQLite is built in and the Docker README calls it the easiest option with good performance; the prerequisites page lists PostgreSQL 10+ as the recommended database. For one reader SQLite keeps the stack to one container. For several users, or if you already run PostgreSQL, use it, and pin its major version.

Which port does the FreshRSS container listen on?

Apache inside the image listens on port 80. The LISTEN variable changes that address, which the docs mention for host networking. Publish it as 127.0.0.1:8080:80 and let a proxy on the host carry 80 and 443.

Can strangers sign up on my instance?

Not by default. max_registrations is 1 in config.default.php, and the admin account fills that place, so the registration form stays closed. Raising it, or setting 0 for no limit, opens self-registration from the login page.

Can FreshRSS run without Docker?

Yes. The prerequisites list Apache 2.4 (nginx and lighttpd also work), PHP 8.1+ and a database; you give the web server user write access to ./data/ and expose only the ./p/ folder. Feeds are then refreshed by cron or a systemd timer calling app/actualize_script.php.

Where do I change the address FreshRSS uses for links?

In base_url in data/config.php, or with cli/reconfigure.php --base-url. The installer sets it from the address you used, so it needs changing after an install through a tunnel or a move to a new domain.

Run your feed reader on your own hardware

Pick a line from the dedicated server range by what else will share the machine, with root, an unmetered port and monthly terms.

See the machines

Related on Server Room

Other use cases