Use case · Feed reader

Miniflux self hosted on a dedicated server: Compose, PostgreSQL, nginx

Self-hosting Miniflux means two containers: the official miniflux/miniflux image, which is one Go binary, and PostgreSQL, the only database it supports. This build follows the Docker Compose example in the Miniflux docs, changed by us where the page says so: a pinned image tag, the web port bound to 127.0.0.1, secrets in mode-600 files instead of the YAML, a restart policy, and the first admin account checked over an SSH tunnel before nginx publishes anything. Backups are a nightly pg_dump plus the config directory.

See the machines

What Miniflux needs from the machine

Very little. The project README says Miniflux needs a couple of MB of RAM and negligible CPU even with several hundred feeds; that is the application alone, and PostgreSQL needs its own share on top. The FAQ adds that Miniflux keeps nothing on the local file system, favicons included: everything lives in the database, which decides both disk use and the backup plan.

ComponentRequirement or defaultSource
DatabasePostgreSQL 11 or later, the only one supportedRequirements page
Image tagslatest, a pinned x.y.z, or nightly; the docs recommend pinningDocker page
Architecturesamd64, arm64, arm/v7, arm/v6Docker page
Listen address127.0.0.1:8080 for the binary; the image sets 0.0.0.0:8080Configuration page, Dockerfile
Feed pollingEvery 60 minutes, 100 feeds a batch, 16 workersConfiguration page
CleanupRead entries marked removed after 60 days, unread after 180Configuration page

The image is published to Docker Hub, the GitHub Container Registry and Quay.io. Version 2.3.4 was the newest release on GitHub when this page was written; check the releases page and pin whatever is current. Browsers must support ECMAScript 11, and Internet Explorer and the Kindle browser are not supported.

Choosing the line

One feed reader for a person or a small team is a single service, the job the dedicated server range gives to Moonshot cartridges: one small server per slot, and still the whole machine: root, an unmetered port, billed by the month. A rack server only makes sense when Miniflux shares the box with other services, for example a read-it-later app such as wallabag that Miniflux can save articles to.

  • Remote console: every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC). Among cartridges only the m510 and m710x have one; the rest have power control only. If you want a console to recover from a bad firewall rule, pick one of those two or a rack server.
  • Architecture: the image covers amd64 and arm64, but the X-Gene ARM cartridge is out. It boots only Ubuntu 16.04 and 18.04, too old for the Docker Engine 28 this build checks for.
  • Delivery: about 30 minutes for a racked machine, counted from payment approval; built-to-order lines take 4 to 24 hours.
  • Operating system: Ubuntu 24.04, installed from the control panel. Add Docker Engine (version 28 or later) and the Compose plugin from Docker's apt repository.

Firewall, secrets and the Compose file

Run this as root (sudo -i). It switches ufw's IPv6 rules on before the first allow, admits SSH, enables the firewall, then continues only if Docker Engine is 28 or newer and /opt/miniflux/.env does not exist yet, so a second paste cannot replace the database password under a live volume.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
v=$(docker version --format '{{.Server.Version}}')
if [ "${v%%.*}" -ge 28 ] 2>/dev/null && [ ! -e /opt/miniflux/.env ]; then
install -d -m 700 /opt/miniflux && cd /opt/miniflux &&
install -m 600 /dev/null .env && install -m 600 /dev/null admin.env &&
db=$(openssl rand -hex 24) && ad=$(openssl rand -hex 12) &&
[ -n "$db" ] && [ -n "$ad" ] && echo "DB_PASSWORD=$db" > .env &&
printf 'CREATE_ADMIN=1\nADMIN_USERNAME=admin\nADMIN_PASSWORD=%s\n' "$ad" > admin.env &&
touch site.env && cat > compose.yaml <<'EOF' &&
services:
  miniflux:
    image: miniflux/miniflux:2.3.4
    restart: unless-stopped
    ports: ["127.0.0.1:8080:8080"]
    depends_on: {db: {condition: service_healthy}}
    env_file: [admin.env, site.env]
    environment:
      - DATABASE_URL=postgres://miniflux:${DB_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
  db:
    image: postgres:18
    restart: unless-stopped
    environment: [POSTGRES_USER=miniflux, "POSTGRES_PASSWORD=${DB_PASSWORD}", POSTGRES_DB=miniflux]
    volumes: ["miniflux-db:/var/lib/postgresql"]
    healthcheck: {test: ["CMD", "pg_isready", "-U", "miniflux"], interval: 10s, start_period: 30s}
volumes:
  miniflux-db:
EOF
docker compose -f /opt/miniflux/compose.yaml up -d
else echo "STOP: needs Docker Engine 28+ and no existing /opt/miniflux/.env"; fi

Both secret files are created empty at mode 600 before anything is written to them. The database password is hex so it needs no URL encoding inside DATABASE_URL, which the database page warns about. echo and printf are shell builtins, so neither password shows in ps. Compose reads DB_PASSWORD from .env in the project directory and substitutes it; the values still end up in the container environment, visible to anyone who can run docker inspect, which is one reason the admin variables are removed after the first start.

Against the official example, our changes are: the tag pinned to 2.3.4 instead of latest, the port mapped as 127.0.0.1:8080:8080 instead of 80:8080, CREATE_ADMIN, ADMIN_USERNAME and ADMIN_PASSWORD moved into admin.env, both passwords moved out of the YAML, an empty site.env for later, and restart: unless-stopped on both services. RUN_MIGRATIONS=1, the postgres:18 image, its volume path and the pg_isready health check are as published.

A port mapping with no address publishes on every host address, 0.0.0.0 and [::]. Published traffic skips ufw's rules on IPv4, and on IPv6 only when Docker's own IPv6 networking is enabled; otherwise docker-proxy carries the [::] listener and ufw rules apply to it. Binding to 127.0.0.1 keeps port 8080 local, and on Docker Engine 28 or later that binding is enforced as local-only, which is why the block checks the version first.

First sign-in over an SSH tunnel

Nothing is public yet, so the admin account is checked and its password replaced privately.

  1. Confirm the account exists: docker compose -f /opt/miniflux/compose.yaml logs miniflux | grep -i "admin user". create_admin.go logs "Created new admin user" on the first start and skips creation when the name already exists.
  2. 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.
  3. Log in as admin; the generated password is in /opt/miniflux/admin.env (cat it as root). Import your OPML file and look around.
  4. Replace the generated password with one only you know, using the documented command (it prompts, so it is the last thing you type): docker compose -f /opt/miniflux/compose.yaml exec miniflux /usr/bin/miniflux -reset-password.

The Docker page says the three admin variables can be removed after the first initialisation. The next block does that by emptying admin.env before it touches nginx.

Going public: nginx, BASE_URL and TLS

Point an A record (and AAAA, if the server has IPv6) at the machine first. The block empties admin.env, writes BASE_URL, which Miniflux uses to build links and set the cookie path, plus HTTPS=1, which forces the secure cookie flag and HSTS, recreates the container, and installs nginx with certbot.

cd /opt/miniflux && : > admin.env &&
echo 'BASE_URL=https://rss.example.com' > site.env && echo 'HTTPS=1' >> site.env &&
docker compose -f /opt/miniflux/compose.yaml up -d &&
apt install -y nginx certbot python3-certbot-nginx &&
cat > /etc/nginx/sites-available/miniflux <<'EOF' &&
server {
    server_name rss.example.com;
    listen 80;
    listen [::]:80;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_redirect 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;
    }
}
EOF
ln -sf /etc/nginx/sites-available/miniflux /etc/nginx/sites-enabled/miniflux &&
nginx -t && systemctl reload nginx && ufw allow 80,443/tcp &&
certbot --nginx -d rss.example.com

The server block is the reverse-proxy example from the How-To page; the only line we added is listen [::]:80. nginx runs on the host, so ufw filters it on IPv4 and IPv6 alike and the allow 80,443/tcp rule is required. Miniflux would honour X-Forwarded-Proto only from networks listed in TRUSTED_REVERSE_PROXY_NETWORKS; with HTTPS=1 set it does not need to. certbot asks for an email address and the terms, then adds HTTPS to that server block.

Mobile clients and integrations

Miniflux implements the Fever API and a subset of the Google Reader API. Each is switched on under Settings > Integrations with a username and password of its own, separate from your login. The docs list Reeder Classic, Unread, FeedMe, FocusReader, NewsFlash and Fluent Reader for Fever (JSON format only), and Capy Reader, NetNewsWire, Reeder Classic 5 or later and RSS Guard for Google Reader. Miniflux also has its own REST API with keys created under Settings > API Keys, and DISABLE_API=1 turns that API off.

  • Save targets: wallabag, Instapaper, Pinboard, Linkding, Linkwarden, Karakeep, Readeck, Shaarli and others in the integrations list.
  • Notifications: ntfy since Miniflux 2.2.0, switched on per feed; Telegram, Matrix, Discord, Slack, Pushover and Apprise are also listed.
  • Webhooks: new_entries and save_entry events, posted as JSON and signed with HMAC-SHA256 in the X-Miniflux-Signature header.

Integration and feed requests to private or loopback addresses are refused by default. If an integration lives on the same machine and you point Miniflux at 127.0.0.1, set INTEGRATION_ALLOW_PRIVATE_NETWORKS=1, or simply use that service's public name.

Backups and restores

The FAQ's backup answer is a pg_dump and a pointer to the PostgreSQL docs. Our job dumps in custom format, lists the dump to prove it is readable, archives /opt/miniflux (compose.yaml, the three env files and the script), and prunes copies older than 14 days only after today's dump exists. Run as root.

install -d -m 700 /srv/miniflux-backup /var/log/miniflux-backup
cat > /opt/miniflux/backup.sh <<'EOF'
set -euo pipefail
umask 077
C="docker compose -f /opt/miniflux/compose.yaml"
d=/srv/miniflux-backup; t=$(date +%F)
$C exec -T db pg_dump -U miniflux -Fc miniflux > "$d/miniflux-$t.dump"
$C exec -T db pg_restore -l < "$d/miniflux-$t.dump" > /dev/null
tar -czf "$d/miniflux-config-$t.tgz" -C /opt/miniflux .
tar -tzf "$d/miniflux-config-$t.tgz" > /dev/null
find "$d" -name "miniflux-$t.dump" -mtime -1 | grep -q . &&
find "$d" -name 'miniflux-*' -mtime +14 -delete
EOF
printf '%s\n' '30 3 * * * root bash /opt/miniflux/backup.sh > /var/log/miniflux-backup/last.log 2>&1' > /etc/cron.d/miniflux-backup
bash /opt/miniflux/backup.sh && ls -l /srv/miniflux-backup

Fourteen days is an example. Copy the directory off the machine yourself from another host with plain rsync -a or scp, never rsync --delete, and prune there with its own job. The config archive holds the database password, so keep it as private as the dump. To restore, open a root shell (sudo -i) and run every step below in it: steps 1 and 3 set B and P, which the later steps read.

  1. Set B=/srv/miniflux-backup/miniflux-2026-10-23.dump (your date) and check it: docker compose -f /opt/miniflux/compose.yaml exec -T db pg_restore -l < "$B" > /dev/null && echo dump OK. Stop unless you see dump OK.
  2. Stop the app: docker compose -f /opt/miniflux/compose.yaml stop miniflux.
  3. Save the current database under its own name and prove it reads back: `P=/srv/miniflux-backup/before-restore.dump; [ ! -e "$P" ] && docker compose -f /opt/miniflux/compose.yaml exec -T db pg_dump -Fc -U miniflux miniflux > "$P" && docker compose -f /opt/miniflux/compose.yaml exec -T db pg_restore -l < "$P" > /dev/null && echo current OK`. Stop unless you see current OK.
  4. Restore: docker compose -f /opt/miniflux/compose.yaml exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists < "$B", then docker compose -f /opt/miniflux/compose.yaml start miniflux and sign in to check.
  5. Rollback: stop the app again (step 2), then repeat step 4 with "$P" in place of "$B". Delete before-restore.dump only when you are satisfied; while it exists, step 3 refuses to run, so move it aside before any later restore.

On a new machine, unpack the config archive into /opt/miniflux before the database container ever starts, because the postgres image creates its user from POSTGRES_PASSWORD only on the first start of an empty volume. Then, as root: docker compose -f /opt/miniflux/compose.yaml up -d db, set B to the copied dump as in step 1, load it with the step 4 pg_restore command, and finish with docker compose -f /opt/miniflux/compose.yaml up -d.

Upgrades, licence and limits

The upgrade page says to read the release notes for breaking changes, back up the database, verify the backup, then run the new version with migrations. Here, as root: bash /opt/miniflux/backup.sh, change the tag in compose.yaml, then docker compose -f /opt/miniflux/compose.yaml pull to fetch the image; once that completes, recreate the stack with docker compose -f /opt/miniflux/compose.yaml up -d. RUN_MIGRATIONS=1 stays set, so the schema is migrated on start. Our advice, not the Miniflux docs: leave postgres:18 alone, because a new PostgreSQL major version is a dump and restore into a fresh volume, not a tag edit.

Miniflux is released under the Apache License 2.0 (the LICENSE file and the image labels say Apache-2.0). Our general reading, not legal advice: it is a permissive licence, so running it for yourself or your company carries no duty to publish anything. If you pass copies on, retain the copyright notices and the licence text, and state which files you modified; there is no obligation to supply source code.

  • Miniflux is deliberately minimal: the FAQ rules out a plugin system and external theme files. If you want extensions, FreshRSS is the other route.
  • PostgreSQL only. There is no SQLite option for a smaller footprint.
  • Full-text search, favicons and every article live in the database, so watch its volume as feeds pile up and adjust the retention settings if needed.

Questions

Can Miniflux run without PostgreSQL?

No. The requirements page lists PostgreSQL 11 or later as the only supported database. The official Compose example runs it as a second container with a named volume, which is what this page does.

Where does the first admin account come from?

From CREATE_ADMIN=1 with ADMIN_USERNAME and ADMIN_PASSWORD on the first start; the account is skipped if it already exists. The docs say the three variables can be removed after initialisation. Alternatively, miniflux -create-admin asks for the credentials at a terminal.

Which phone apps work with a self-hosted Miniflux?

Apps that speak the Fever API, such as Unread, FeedMe and FocusReader, or the Google Reader API, such as NetNewsWire and Capy Reader. Enable the matching API under Settings > Integrations and give the app those credentials, not your login.

Does the image run on ARM?

Yes: the Docker page lists amd64, arm64, arm/v7 and arm/v6, and pulling a tag fetches the right one. Our X-Gene cartridge is still unsuitable because its newest image is Ubuntu 18.04, too old for this Docker setup.

How do I move a self-hosted Miniflux to another server?

Run the backup job, copy the dump and the config archive across, unpack the archive into /opt/miniflux, start only the database container, restore the dump, then start the stack. Change DNS last.

Run your feed reader on a whole machine

Pick a cartridge or rack server from the dedicated range by what else will share it. Root, an unmetered port, billed monthly.

See the machines

Related on Server Room

Other use cases