RSS Aggregator
    PostgreSQL

    Deploy FreshRSS on a VPS

    Self-host FreshRSS 1.30.0 on a RamNode VPS with PostgreSQL 17, Docker Compose, automated install, built-in cron refreshes, and Nginx with Let's Encrypt.

    FreshRSS is a free, self-hosted RSS and Atom aggregator written in PHP. It handles thousands of feeds per user, supports multiple users, WebSub push updates, web scraping for partial-content feeds, saved searches and filters, labels, themes, and a large extension ecosystem. Google Reader and Fever compatible APIs let you use it with mobile and desktop clients such as Reeder, NetNewsWire, Read You, and FeedMe.

    This guide deploys FreshRSS 1.30.0 on Ubuntu 24.04 LTS using the official Docker image with a PostgreSQL 17 database in Docker Compose, a fully automated first-run install, built-in cron feed refreshes, and Nginx on the host as the TLS-terminating reverse proxy.

    Why 1.30.0 matters: 1.30.0 is a security-focused release that patches several SSRF, CSRF, and access-control issues. If you run an older FreshRSS anywhere, upgrade it. The project now also recommends its rolling edge channel for faster security patches; this guide pins stable releases for predictability and covers the tradeoff under Updating.

    What You Will Build

    • Docker Engine and the Compose plugin from Docker's official apt repository
    • A Compose stack in /opt/freshrss with freshrss/freshrss:1.30.0 and postgres:17-alpine
    • Unattended install and admin user creation via environment variables
    • Feed refresh twice an hour through the image's built-in cron
    • FreshRSS published only on 127.0.0.1:8081, fronted by Nginx with Let's Encrypt
    • Nightly database dumps plus OPML exports

    Server Sizing

    Use casevCPURAMDiskNotes
    Single user, a few hundred feeds11 GB15 GBFine with 1 GB swap
    Household or small team, up to ~2,000 feeds22 GB25 GBRecommended starting point
    Many users or heavy web scraping2 to 44 GB50 GB+Scraping and full-text retention grow the database

    Prerequisites

    • A RamNode VPS running Ubuntu 24.04 LTS
    • Root or sudo access over SSH
    • A DNS A record (and AAAA for IPv6) for a hostname such as reader.example.com

    Replace reader.example.com throughout with your hostname.

    Step 1: Prepare the System

    shell
    sudo apt update && sudo apt -y full-upgrade
    sudo apt -y install ca-certificates curl gnupg nginx certbot python3-certbot-nginx ufw unattended-upgrades
    sudo dpkg-reconfigure -plow unattended-upgrades

    Firewall:

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw enable

    Docker and UFW: Docker writes its own iptables rules, and ports published as 8081:80 bypass UFW entirely. This guide publishes FreshRSS on 127.0.0.1:8081 only and never publishes PostgreSQL, so nothing in the stack is exposed except through Nginx.

    Step 2: Install Docker Engine

    Add Docker's signing key and repository:

    shell
    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 -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
    sudo systemctl enable --now docker
    docker compose version

    Cap container log growth so a chatty container cannot fill the disk:

    shell
    sudo pico /etc/docker/daemon.json
    shell
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    shell
    sudo systemctl restart docker

    Step 3: Create the Project Directory and Secrets

    shell
    sudo mkdir -p /opt/freshrss/{data,extensions,db}
    cd /opt/freshrss

    Generate credentials:

    shell
    echo "DB_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=')"
    echo "ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')"
    echo "ADMIN_API_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')"

    Create the environment file with those values:

    shell
    sudo pico /opt/freshrss/.env
    shell
    # Public URL (no trailing slash)
    BASE_URL=https://reader.example.com
    
    # Timezone for the container and cron
    TZ=America/New_York
    
    # Database
    DB_HOST=freshrss-db
    DB_BASE=freshrss
    DB_USER=freshrss
    DB_PASSWORD=PASTE_DB_PASSWORD
    
    # Initial admin user
    ADMIN_USER=admin
    ADMIN_EMAIL=you@example.com
    ADMIN_PASSWORD=PASTE_ADMIN_PASSWORD
    ADMIN_API_PASSWORD=PASTE_ADMIN_API_PASSWORD
    shell
    sudo chmod 600 /opt/freshrss/.env

    The admin password is for the web UI. The API password is a separate credential that mobile apps use through the Google Reader and Fever APIs, so a leaked app config never exposes your web login.

    Step 4: Write the Compose File

    shell
    sudo pico /opt/freshrss/compose.yaml
    shell
    services:
      freshrss-db:
        image: postgres:17-alpine
        container_name: freshrss-db
        restart: unless-stopped
        environment:
          POSTGRES_DB: ${DB_BASE}
          POSTGRES_USER: ${DB_USER}
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes:
          - ./db:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_BASE}"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      freshrss:
        image: freshrss/freshrss:1.30.0
        container_name: freshrss
        restart: unless-stopped
        depends_on:
          freshrss-db:
            condition: service_healthy
        ports:
          - "127.0.0.1:8081:80"
        volumes:
          - ./data:/var/www/FreshRSS/data
          - ./extensions:/var/www/FreshRSS/extensions
        environment:
          TZ: ${TZ}
          # Refresh feeds at minute 3 and 33 of every hour
          CRON_MIN: "3,33"
          # Trust X-Forwarded-* from the Docker bridge (host Nginx arrives from here)
          TRUSTED_PROXY: 172.16.0.0/12
          FRESHRSS_INSTALL: |-
            --api-enabled
            --base-url ${BASE_URL}
            --db-type pgsql
            --db-host ${DB_HOST}
            --db-base ${DB_BASE}
            --db-user ${DB_USER}
            --db-password ${DB_PASSWORD}
            --default-user ${ADMIN_USER}
            --language en
          FRESHRSS_USER: |-
            --user ${ADMIN_USER}
            --password ${ADMIN_PASSWORD}
            --api-password ${ADMIN_API_PASSWORD}
            --email ${ADMIN_EMAIL}
            --language en

    How the pieces fit:

    SettingPurpose
    127.0.0.1:8081:80Container's Apache reachable only from the host, never directly from the internet
    CRON_MINEnables the image's internal cron to run the feed actualizer at those minutes
    TRUSTED_PROXYLets FreshRSS read the real client IP and HTTPS scheme from Nginx's forwarded headers
    FRESHRSS_INSTALLRuns the CLI installer on first boot when no config exists; skipped afterward
    FRESHRSS_USERCreates the admin user on first boot

    Step 5: Start the Stack

    shell
    cd /opt/freshrss
    sudo docker compose pull
    sudo docker compose up -d
    sudo docker compose ps
    sudo docker compose logs -f freshrss

    Wait for the logs to show the install and user creation completing, then press Ctrl+C. Confirm the app responds:

    shell
    curl -sI http://127.0.0.1:8081/i/ | head -n1

    Run the built-in health check:

    shell
    sudo docker exec --user www-data freshrss php cli/health.php

    Step 6: Configure Nginx and TLS

    shell
    sudo pico /etc/nginx/sites-available/freshrss
    shell
    server {
        listen 80;
        listen [::]:80;
        server_name reader.example.com;
    
        client_max_body_size 20M;  # OPML imports
    
        location / {
            proxy_pass http://127.0.0.1:8081;
            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-Host $host;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_redirect off;
    
            # Long-running manual refreshes and API sync
            proxy_read_timeout 300s;
            proxy_buffering off;
        }
    }

    Enable it and get a certificate:

    shell
    sudo ln -s /etc/nginx/sites-available/freshrss /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t && sudo systemctl reload nginx
    sudo certbot --nginx -d reader.example.com --redirect -m you@example.com --agree-tos --no-eff-email
    sudo certbot renew --dry-run

    Browse to https://reader.example.com and sign in with the admin username and password from .env.

    Step 7: Post-Install Configuration

    Lock down registration and authentication

    Under Administration > System configuration:

    • Registration: leave closed unless you are running a multi-user instance on purpose. You can set a custom closed-registration message.
    • Authentication method: keep Web form (the default). For SSO, FreshRSS supports OpenID Connect and HTTP header auth from a trusted proxy.
    • Log level: 1.30.0 adds a configurable log_level here. Leave it at the default unless troubleshooting.

    Feeds on local or private networks

    1.30.0 is a breaking change here: FreshRSS now refuses to fetch from local networks such as 127.0.0.1 by default to block SSRF. If you subscribe to feeds hosted on the same VPS (an RSS-Bridge or RSSHub container, for example), allow only those hosts:

    • Administration > System configuration has an internal host allowlist, or
    • set INTERNAL_HOST_ALLOWLIST in the environment: block of the freshrss service, then sudo docker compose up -d.

    Avoid *, which restores the old allow-everything behavior and reopens the SSRF exposure.

    Import feeds

    Subscription management > Import / export accepts OPML, and since 1.29.1 also plain .txt files with one feed URL per line. OPML export and import now carry each feed's refresh interval and preserve category order.

    Mobile and desktop clients

    The API is already enabled by the installer. Point clients at:

    APIEndpoint
    Google Readerhttps://reader.example.com/api/greader.php
    Feverhttps://reader.example.com/api/fever.php

    Authenticate with your username and the API password, not the web password. Each user sets their API password under Settings > Profile. Clients should use POST; FreshRSS now logs a warning when a client authenticates via GET.

    Extensions

    Drop extension folders into /opt/freshrss/extensions, then enable them under Settings > Extensions. Popular picks include YouTube channel embeds, reading-time estimates, and image proxies. Only install extensions from sources you trust; they run as PHP inside the app.

    Step 8: Backups

    Capture the database, the data directory (config, user settings, caches), and an OPML export per user.

    shell
    sudo pico /usr/local/bin/freshrss-backup
    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    cd /opt/freshrss
    set -a; . ./.env; set +a
    
    DEST=/var/backups/freshrss
    STAMP=$(date +%F)
    KEEP_DAYS=14
    mkdir -p "$DEST/$STAMP"
    chmod 700 "$DEST"
    
    docker compose exec -T freshrss-db pg_dump -U "$DB_USER" -Fc "$DB_BASE" > "$DEST/$STAMP/freshrss.dump"
    
    tar --exclude='data/cache' -czf "$DEST/$STAMP/freshrss-data.tar.gz" data extensions .env compose.yaml
    
    docker exec --user www-data freshrss php cli/export-opml-for-user.php --user "$ADMIN_USER" \
      > "$DEST/$STAMP/${ADMIN_USER}.opml"
    
    find "$DEST" -mindepth 1 -maxdepth 1 -type d -mtime +$KEEP_DAYS -exec rm -rf {} +
    shell
    sudo chmod 750 /usr/local/bin/freshrss-backup
    echo '45 3 * * * root /usr/local/bin/freshrss-backup' | sudo tee /etc/cron.d/freshrss-backup
    sudo /usr/local/bin/freshrss-backup
    ls -lh /var/backups/freshrss/$(date +%F)

    The archive includes .env, so treat backups as secrets and encrypt them when you ship them off-server (restic does this by default).

    Restore the database into a fresh stack:

    shell
    cd /opt/freshrss
    sudo docker compose stop freshrss
    sudo docker compose exec -T freshrss-db sh -c 'dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
    sudo docker compose exec -T freshrss-db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
      < /var/backups/freshrss/YYYY-MM-DD/freshrss.dump
    sudo docker compose start freshrss

    Updating FreshRSS

    Stable releases (this guide's default)

    shell
    sudo /usr/local/bin/freshrss-backup
    cd /opt/freshrss
    sudo sed -i 's|freshrss/freshrss:1.30.0|freshrss/freshrss:NEW_VERSION|' compose.yaml
    sudo docker compose pull freshrss
    sudo docker compose up -d
    sudo docker compose logs --tail=50 freshrss

    Database schema updates apply automatically on startup.

    Edge (rolling) channel

    The FreshRSS team now recommends the edge tag for faster security fixes. It tracks the development branch, so you trade some stability for patch speed. If you choose it, set image: freshrss/freshrss:edge, keep the nightly backups, and pull weekly.

    PostgreSQL major upgrades

    Do not change postgres:17-alpine to a new major version in place; the data directory format is incompatible. Dump with the backup script, start a new empty database container on the new version, and restore.

    Troubleshooting

    SymptomCause and fix
    Feeds never refreshConfirm CRON_MIN is set; check sudo docker compose logs freshrss for actualize output; run a manual refresh with sudo docker exec --user www-data freshrss php app/actualize_script.php
    Local feed fails after upgrading to 1.30.0New local-network block. Add the host to the internal allowlist or INTERNAL_HOST_ALLOWLIST
    Login loops or mixed-content warningsBASE_URL must use https://; confirm TRUSTED_PROXY covers the Docker bridge and Nginx sends X-Forwarded-Proto
    Install warning that document root does not point to ./p/Only applies to bare-metal installs; the Docker image is already configured correctly
    Mobile app authentication failsUse the API password, verify the endpoint path, and check that the API is enabled under Authentication
    Regex searches throw SQL errorsFixed in 1.30.0 for MySQL/MariaDB; PostgreSQL deployments were not affected
    502 Bad GatewayContainer down or unhealthy: sudo docker compose ps and inspect logs
    Disk filling upCheck docker system df; prune old images with sudo docker image prune -a; tighten archiving under Settings > Archiving

    Security Checklist

    • FreshRSS on 1.30.0 or newer (security release)
    • App published on loopback only; PostgreSQL never published
    • .env and backups readable by root only
    • Registration closed unless intentionally multi-user
    • Separate API passwords for mobile clients
    • Local-network fetching limited to an explicit allowlist, never *
    • Container logs capped; images pruned periodically
    • Off-server encrypted backups with a tested restore

    FreshRSS or Miniflux?

    FreshRSS is the better fit when you want extensions, themes, WebSub push, web scraping of partial feeds, rich saved queries, and multi-user administration. Miniflux is the better fit for a minimal, near-zero-maintenance single binary. The companion Miniflux guide covers that deployment.