Skip to Content

Production Guide: Deploy FreshRSS with Docker Compose + Nginx + PostgreSQL on Ubuntu

A production-oriented FreshRSS deployment with PostgreSQL persistence, reverse proxy hardening, backups, and operational runbooks.

For teams that follow dozens or hundreds of engineering and security feeds, consumer RSS readers usually fail in production: weak backup strategy, no clean reverse-proxy path, and no operational runbook for upgrades. FreshRSS is lightweight, self-hosted, and efficient, but to run it reliably for a business you need more than a quick container start. In this guide, you will deploy FreshRSS on Ubuntu using Docker Compose with PostgreSQL for durable data, Nginx as the edge reverse proxy, and practical controls for secrets, backup, verification, and incident handling.

Architecture and flow overview

The deployment separates responsibilities into three layers: (1) application and database containers on an internal Docker network, (2) an Nginx reverse proxy handling public HTTP/S traffic and forwarding only required paths, and (3) host-level operational controls such as firewall policy, backup jobs, and health checks. FreshRSS remains isolated from direct internet exposure while PostgreSQL accepts connections only from the internal network. This architecture reduces attack surface and makes troubleshooting explicit because each layer has a clear role.

  • FreshRSS app container: serves the web app and scheduled feed refresh tasks.
  • PostgreSQL container: persistent state for users, subscriptions, tags, and read/unread metadata.
  • Nginx host service: TLS termination, request filtering, security headers, and logging.
  • Persistent volumes: separate app and database storage with predictable backup targets.
  • Operational scripts: repeatable verification, backup, restore drill, and upgrade checks.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 4 GB RAM, and 30+ GB disk.
  • DNS A record pointed to your server (for example rss.example.com).
  • Docker Engine + Docker Compose plugin installed.
  • Nginx installed on host, plus TLS certificates (Let's Encrypt recommended).
  • Outbound access for feed polling and package updates.
  • A secure location for secrets (password manager or vault).
sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg lsb-release nginx

# Docker Engine + compose plugin (official repo)
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo   "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] 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-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

If copy button does not work in your browser/editor, select the full block and copy manually.

Step-by-step deployment

Use a dedicated project directory so ownership, backup scope, and upgrade scripts remain clean. We will define a Docker Compose stack with explicit images, isolated network, health checks, and restart policy. Pin major versions to avoid unexpected breaking changes during unattended pulls.

sudo mkdir -p /opt/freshrss/{app,data,db,backups}
sudo chown -R $USER:$USER /opt/freshrss
cd /opt/freshrss

cat > .env <<'EOF'
TZ=UTC
APP_BASE_URL=https://rss.example.com
POSTGRES_DB=freshrss
POSTGRES_USER=freshrss
POSTGRES_PASSWORD=CHANGE_ME_LONG_RANDOM_DB_PASSWORD
FRESHRSS_ADMIN_USER=admin
FRESHRSS_ADMIN_PASSWORD=CHANGE_ME_LONG_RANDOM_ADMIN_PASSWORD
EOF

If copy button does not work in your browser/editor, select the full block and copy manually.

services:
  db:
    image: postgres:16-alpine
    container_name: freshrss-db
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [backend]

  freshrss:
    image: freshrss/freshrss:latest
    container_name: freshrss-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    env_file: .env
    environment:
      TZ: ${TZ}
      CRON_MIN: '*/15'
      BASE_URL: ${APP_BASE_URL}
      DB_BASE: pgsql
      DB_HOST: db
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: ${POSTGRES_DB}
      FRESHRSS_INSTALL: |-
        --api-enabled
        --default_user ${FRESHRSS_ADMIN_USER}
        --password ${FRESHRSS_ADMIN_PASSWORD}
    volumes:
      - ./data:/var/www/FreshRSS/data
      - ./app:/var/www/FreshRSS/extensions
    ports:
      - "127.0.0.1:8085:80"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/i/"]
      interval: 15s
      timeout: 5s
      retries: 10
    networks: [backend]

networks:
  backend:
    driver: bridge

If copy button does not work in your browser/editor, select the full block and copy manually.

docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 freshrss

If copy button does not work in your browser/editor, select the full block and copy manually.

Next, configure Nginx as the external entry point. Keep the app bound to localhost so only Nginx can reach it. This gives you one place for TLS policy, request limits, and consistent access logs.

server {
    listen 80;
    server_name rss.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name rss.example.com;

    # Replace with your certificate paths
    ssl_certificate     /etc/letsencrypt/live/rss.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rss.example.com/privkey.pem;

    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    client_max_body_size 16m;

    location / {
        proxy_pass http://127.0.0.1:8085;
        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 https;
        proxy_read_timeout 90;
    }
}

If copy button does not work in your browser/editor, select the full block and copy manually.

sudo tee /etc/nginx/sites-available/freshrss.conf > /dev/null <<'EOF'
# paste server blocks here
EOF
sudo ln -s /etc/nginx/sites-available/freshrss.conf /etc/nginx/sites-enabled/freshrss.conf
sudo nginx -t && sudo systemctl reload nginx

If copy button does not work in your browser/editor, select the full block and copy manually.

Configuration and secrets handling best practices

Treat this stack as production infrastructure, not a disposable utility. Rotate all bootstrap secrets after first login, keep only required ports open, and avoid storing plaintext credentials in shell history. Prefer a vault-backed workflow when possible. At minimum, lock file permissions and ensure regular secret rotation cadence with ownership tracking.

  • Set chmod 600 /opt/freshrss/.env and restrict directory ownership.
  • Store database and admin credentials in a secrets manager; pull into CI/CD runtime only when deploying.
  • Enable host firewall: allow 22, 80, 443 only.
  • Run restore drills monthly so backups are operationally proven.
  • Pin and review major image updates before production rollout.
chmod 600 /opt/freshrss/.env
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

# Backup DB and app data
date_tag=$(date +%F-%H%M)
mkdir -p /opt/freshrss/backups/$date_tag
docker exec freshrss-db pg_dump -U freshrss freshrss > /opt/freshrss/backups/$date_tag/db.sql
tar -czf /opt/freshrss/backups/$date_tag/app-data.tgz -C /opt/freshrss data app

If copy button does not work in your browser/editor, select the full block and copy manually.

Verification checklist

Validation should cover user experience, service health, and persistence guarantees. Do not stop after seeing a login page. Confirm that feed updates run, database writes persist after restart, and proxy headers preserve correct scheme/host behavior.

# HTTP and TLS
curl -I https://rss.example.com

# Container health
docker compose ps
docker inspect --format='{{json .State.Health}}' freshrss-db | jq
docker inspect --format='{{json .State.Health}}' freshrss-app | jq

# Functional checks
# 1) login as admin
# 2) add at least one feed
# 3) run refresh now
# 4) restart stack and ensure feed state persists

docker compose restart
sleep 10
docker compose ps

If copy button does not work in your browser/editor, select the full block and copy manually.

Expected outcomes: HTTPS 200/302, no restart loops, healthy containers, feeds updating without database errors, and unchanged subscription state after restart. Keep these checks as a reusable runbook and integrate them into release windows.

Common issues and fixes

Issue: 502 Bad Gateway from Nginx

Usually caused by app container not healthy or wrong upstream port. Verify 127.0.0.1:8085 binding and container health status. Confirm no local firewall rule blocks loopback forwarding policies.

Issue: FreshRSS cannot connect to PostgreSQL

Check credentials in .env, ensure DB healthcheck is passing, and confirm service name db in Compose matches the app DB host setting.

Issue: Feed updates stall or timeout

Validate outbound DNS/network, inspect blocked domains, and adjust refresh frequency. Large feed sets benefit from staggered updates and container resource limits.

Issue: Lost data after reboot

This is almost always a volume-path misconfiguration. Confirm host directories under /opt/freshrss/db and /opt/freshrss/data are mounted and writable.

Issue: SSL warnings persist

Re-check certificate path in Nginx and full chain validity. If using Let's Encrypt, ensure renewal hooks reload Nginx automatically after successful renewal.

FAQ

Can I run FreshRSS with SQLite instead of PostgreSQL?

Yes, but PostgreSQL is preferred for production because backup/restore workflows, concurrent access patterns, and long-term maintenance are generally more predictable.

How often should feeds refresh in production?

Start with every 15 minutes and tune by workload. High-frequency polling increases outbound traffic and can trigger source rate limits.

Should Nginx run in a container too?

It can, but host-level Nginx simplifies certificate lifecycle and allows independent troubleshooting when app containers are unhealthy.

How do I rotate admin and DB credentials safely?

Rotate during a maintenance window: update secrets in your store, change DB password in PostgreSQL, update .env, restart services, and validate login plus feed updates.

What backup retention policy is practical?

For small and medium deployments, daily backups with 14–30 day retention plus weekly offsite copies provide a strong baseline.

How do I test disaster recovery, not just backup creation?

Restore to a staging host monthly. Confirm users, subscriptions, and read-state are intact, then document total recovery time and blockers.

Can I add SSO to this deployment?

FreshRSS does not provide full enterprise IAM features out of the box. If SSO is mandatory, place authentication controls at the reverse proxy or identity layer.

Related internal guides

Talk to us

If you want this deployed with production hardening, monitoring, and backup automation tailored to your environment, our team can help.

Contact Us

OpenSearch on Kubernetes with Helm: Secure Cluster Setup, Snapshots, and Operations
A practical, production-first guide to deploying OpenSearch Dashboards using Kubernetes Helm, with secrets management, verification, and troubleshooting.