Can I self-host Inoreader?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Miniflux. It takes one prompt, a 1024 MB VPS, and about 70 minutes. That is $9.99 a month you stop paying Inoreader — $119.88 a year on the Pro plan.

Why people pay for Inoreader

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Inoreader is the reader for people whose feeds are a workload rather than a habit. The free tier caps how much you can follow and the paid one lifts it, but the real sell is everything bolted around the reading: rules that file and tag on arrival, monitoring feeds that watch sites and searches that publish no RSS at all, newsletters routed into the same inbox, and an AI layer that reads the pile down to a short list. You are paying for somebody else to poll a few thousand sources on time and to keep the mobile apps in sync while you are not looking.

Inoreader plans and list prices
PlanList priceWhat it buys
FreefreeFree forever, with a capped number of feeds. The vendor presents it as the beginner tier and does not mention advertising on the pricing page; secondary reporting says it is ad-supported.
Prothe plan this page prices against$9.99/moMonthly-billed figure, marked most popular. The same page advertises $7.50/month billed annually, about $90 a year.
Team$44.99/moThe smallest Team size, billed monthly, from inoreader.com/pricing/enterprise. The ladder runs 3, 5, 10, 20 and 50 members at $44.99, $74.99, $119.99, $224.99 and $374.99 a month, each with a dearer Intelligence variant.
Customquote onlyQuote only. The pricing page sells add-ons and API access this way, and the enterprise page asks anyone outside the fixed Team sizes to get in touch.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by Miniflux

One project, named before the prompt, so you know what you are about to install.

A feed reader with nothing in it but feeds: one Go binary, one database, and a Google Reader API the phone apps already speak.

Matches the thing Inoreader is actually used for every day: open it, read what arrived, close it. Miniflux is a single Go binary with a keyboard-driven interface and no JavaScript framework in front of it, so it opens on a phone browser as fast as it does on a desk, and its Google Reader and Fever APIs mean the reader app you already like keeps working with your server behind it. What it does not attempt is the rest of the Inoreader pitch: no rules engine, no monitoring feeds for sites without RSS, no newsletter inbox, no AI filtering. If those are why you pay, this is not the swap.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Minifluxour pickONE EVENING— setup effort 2 of 4

    A feed reader with nothing in it but feeds: one Go binary, one database, and a Google Reader API the phone apps already speak.

    Matches the thing Inoreader is actually used for every day: open it, read what arrived, close it. Miniflux is a single Go binary with a keyboard-driven interface and no JavaScript framework in front of it, so it opens on a phone browser as fast as it does on a desk, and its Google Reader and Fever APIs mean the reader app you already like keeps working with your server behind it. What it does not attempt is the rest of the Inoreader pitch: no rules engine, no monitoring feeds for sites without RSS, no newsletter inbox, no AI filtering. If those are why you pay, this is not the swap.

  2. FreshRSSONE COMMAND— setup effort 1 of 4

    A feed reader in one container, with your subscriptions, your read history and your refresh schedule on your own disk.

    Second here and first elsewhere, which is an honest split rather than a hedge. FreshRSS is the featureful choice: extensions, themes, real multi-user support for a household or a small team, WebSub for feeds that push, and a community that has been maintaining all of it for a decade. It already anchors our Feedly page for exactly those reasons. Miniflux ranks above it on this page only because Inoreader's core sell is a fast reader you open every day, and minimalism is the whole of Miniflux's design where it is one option among many in FreshRSS. Both are excellent, and the deciding question is whether you want a reader you configure or a reader you do not.

The swap

You're paying

Inoreader

$9.99/mo · $119.88/yr

is replaced by

You'd run

Miniflux

ONE EVENING · ~70 min to running · 1024 MB RAM

Inoreader Pro · vendor list price · checked 2026-08-07 · source · confidence: medium

Before you start

RAM floor
1024 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~70 min1–3 hours, through the first backup

The prompt

Two paths to the same Miniflux: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

324 lines · 14,909 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Miniflux 2.3.3 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say one thing to them when you ask: that
hostname becomes `BASE_URL`, and Miniflux builds its session cookie and every link it renders
from `BASE_URL`, so a hostname that does not match the address in the browser produces a login
page that will not log anyone in.

Miniflux needs 1024 MB of RAM available and 5 GB free on /srv. The reader is a single Go binary
and costs almost nothing; PostgreSQL is the heavier half of that floor, and the entry history
is what grows the disk. Both images publish amd64 and arm64. Measure all four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/miniflux /srv/miniflux/backups
sudo install -d -m 700 /srv/miniflux/postgres
ls -la /srv/miniflux
```

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root. The PostgreSQL image chowns its own data directory on first start, so leave that one
alone. There is no data directory for Miniflux itself, and that is not an omission: feeds,
entries, read state and the account are all rows in PostgreSQL.

## 3. Secrets

Two secrets: the PostgreSQL password and the password for the user's own Miniflux account.
Generate both on the server. Do not print either, do not repeat them in your summary, and do
not put them in any log line.

```bash
umask 077
cat > /srv/miniflux/.env <<EOF
BASE_URL=https://<DOMAIN>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$(openssl rand -base64 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/miniflux/.env
umask 022
ls -l /srv/miniflux/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before running the block. The database password is hex because upstream warns
that special characters can be rejected inside a URL-style connection string unless they are
URL encoded, and hex has nothing to encode. Tell the user their username is `admin`, that they
read the password once with `sudo grep ADMIN_PASSWORD /srv/miniflux/.env`, and that they should
put it in their password manager now.

## 4. compose.yml

```bash
cat > /srv/miniflux/compose.yml <<'EOF'
# Miniflux · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://miniflux.app/docs/docker.html
#   configuration ...... https://miniflux.app/docs/configuration.html
#   database ........... https://miniflux.app/docs/database.html
#   requirements ....... https://miniflux.app/docs/requirements.html
#
# Two services: Miniflux and the PostgreSQL that holds every feed, every entry
# and the one account. Miniflux keeps nothing of its own on disk, so there is no
# application data volume in this file and the database is the whole backup
# surface. Upstream supports PostgreSQL 11 and above. The regular image is used
# rather than the -distroless one: both publish amd64 and arm64, but the regular
# image is built on Alpine and has a shell, so `docker compose exec` can look
# inside it when something is wrong. Tags and digests were read from the
# registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/miniflux/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  miniflux:
    image: miniflux/miniflux:2.3.3@sha256:49d7b60987616387c306a8023087b31f2c9b7b21288b523026cb04058e8b6dbb
    container_name: miniflux
    restart: unless-stopped
    env_file: /srv/miniflux/.env
    environment:
      # Hex rather than base64 in the connection string on purpose: upstream
      # warns that a password carrying special characters can be rejected in
      # this URL form unless it is URL encoded, and hex has none to encode.
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@postgres:5432/miniflux?sslmode=disable
      # Miniflux runs its own SQL migrations and exits at start-up when the
      # schema is behind the binary, so this stays set through every upgrade.
      RUN_MIGRATIONS: "1"
      # The single account is built from ADMIN_USERNAME and ADMIN_PASSWORD in
      # .env on the first start, and every start after that logs that it is
      # skipping because the user exists. Miniflux has no self-registration, so
      # this is the only door into the instance.
      CREATE_ADMIN: "1"
      # Caddy terminates TLS and speaks plain http to this container, so the
      # session cookie has to be told it travels over https or the browser
      # will not keep it.
      HTTPS: "1"
    healthcheck:
      # Upstream's own health check: the binary asks its own /healthcheck
      # route, which answers 200 only when the database answers as well.
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      retries: 12
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8180.
      - "127.0.0.1:8180:8080"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/miniflux && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. `docker compose` reads /srv/miniflux/.env for the
`${DB_PASSWORD}` substitutions, which is why the working directory matters on every command in
this prompt that touches compose.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-miniflux
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Miniflux · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://miniflux.app/docs/howto.html,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Miniflux builds its session cookie and every link
# it renders from BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Upstream asks a proxy for X-Forwarded-Proto and X-Forwarded-For, and
	# Caddy's reverse_proxy sends both by default, so there is nothing to add
	# here. 8180 is the loopback port compose publishes on this host. It is
	# not a container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8180
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-miniflux, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own. Nothing to schedule.

## 6. Firewall

Two ports open, both of them Caddy's. Idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8180 stays closed because it is bound to 127.0.0.1, and 5432 stays closed
because compose never publishes it. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8180 or 5432.

## 7. Start and verify

Miniflux runs its database migrations on the way up and creates the one account from the
environment during that same start-up.

```bash
cd /srv/miniflux
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthcheck; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/v1/me
curl -sS https://<DOMAIN>/ | grep -c 'Sign In - Miniflux'
docker compose logs miniflux | grep -c 'admin user'
```

Assert, all five, and print what you received for each. The loop ends printing `200`. The
health body is `OK`, which upstream returns only when the database answers as well. The
unauthenticated API call prints `401`, and that is the security assert here: the REST API is on
by default and refuses a call carrying no token. The page grep prints `1`, because the login
screen's title is `Sign In - Miniflux`. The log grep prints `1` or more, matching either
`Created new admin user` on a first start or `Skipping admin user creation because it already
exists` on a repeat. If any of the five misses, stop, run
`docker compose logs --tail 40 miniflux` and `docker compose logs --tail 20 postgres`, and name
the likely earlier step: a database that never reports healthy is step 2, a Miniflux container
that exits after a line about the password is step 3, and a `404` where a `401` was expected
means Caddy is not reaching the container. A running container is not success.

The first screen at https://<DOMAIN> is a sign-in form with `Username` and `Password` fields
and a `Login` button, and the browser tab reads `Sign In - Miniflux`. There is no sign-up link,
because Miniflux has none to show.

STOP: tell the user to open https://<DOMAIN>, sign in as `admin` with the password from step 3,
and wait. Do not continue until they confirm they are looking at the reader.

Then tell them the next step is theirs, concretely. A phone reader app talks to Miniflux
through the Google Reader or Fever API, and each is switched on under `Settings` then
`Integrations` by choosing a second username and password there. The server address the app
asks for is https://<DOMAIN>. Upstream names Capy Reader, NetNewsWire and Reeder Classic as
Google Reader clients, and Unread, FeedMe and NewsFlash as Fever ones. That credential is
theirs to pick and this install does not create it.

## 8. First backup and restore

Two artifacts. The database holds every feed, entry, read mark and the account. The config
archive holds the files that rebuild the service around it.

```bash
cd /srv/miniflux
docker compose exec -T postgres pg_dump -U miniflux -d miniflux | gzip > /srv/miniflux/backups/miniflux-db-$(date +%F).sql.gz
sudo tar -czf /srv/miniflux/backups/miniflux-config-$(date +%F).tar.gz -C /srv/miniflux compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/miniflux/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. A backup on the same disk is not a backup,
so run this from the user's machine:

```bash
mkdir -p ~/backups/miniflux
scp vps:/srv/miniflux/backups/* ~/backups/miniflux/
```

To restore: `docker compose down`, `sudo rm -rf /srv/miniflux/postgres`, recreate it as in step
2, untar the config archive back into /srv/miniflux so .env is in place before anything starts,
`docker compose up -d postgres`, wait for it to report healthy, then pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U miniflux -d miniflux`, then
`docker compose up -d`. Tell the user plainly what the stakes are: the subscriptions, the
folders and years of read history are all in that one dump, and the OPML export under
`Settings` is a list of feeds, not a copy of what they have read.

## 9. Updating later

New versions are listed at https://github.com/miniflux/v2/releases. Take both backup artifacts
first, then edit the image line in /srv/miniflux/compose.yml to the new tag and its digest:

```bash
cd /srv/miniflux
docker compose pull
docker compose up -d
docker compose logs --tail 30 miniflux
```

Miniflux migrates its own schema on the way up and exits at start-up if the schema is behind
the binary, which is why `RUN_MIGRATIONS` stays set rather than being a one-off. Watch that
log until it settles, then re-run step 7's health check before calling the update done.

## 10. What will probably go wrong

The login form will accept the password and hand back the login form again, with no error
message anywhere. I lost twenty minutes to that. Miniflux redirects a failed CSRF check back to
the login page silently, and the usual reason the check fails is that the browser never kept
the session cookie: `HTTPS` is set in compose.yml, so the cookie carries the secure flag, and
it is dropped if the page was reached over plain http or on any address other than the one in
`BASE_URL`. If a sign-in loops without complaining, run
`sudo grep BASE_URL /srv/miniflux/.env` and compare it character for character with what is in
the address bar before you read a single log line.

## 11. Out of scope

- Do not enable the Google Reader or Fever API from the command line or by editing the
  database. Each is switched on in the user's own settings by choosing a credential, and that
  choice is theirs.
- Do not configure OAUTH2 or OIDC. Registering an application with an outside identity provider
  is a second install, and `OAUTH2_USER_CREATION` opens a second way for accounts to appear.
- Do not set `METRICS_COLLECTOR`. It publishes a Prometheus endpoint, and nothing on this box
  is scraping one.
- Do not lower `POLLING_FREQUENCY` below its 60 minute default to make feeds arrive faster.
  That multiplies outbound requests to sites that did not ask for them.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Miniflux 2.3.3 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. `<DOMAIN>` becomes `BASE_URL`, and Miniflux builds its session cookie
and every link it renders from `BASE_URL`. A hostname that does not match what is in your
address bar produces a login page that takes your password and hands you the login page back,
with no error message. Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that
does not resolve, and failed attempts count against a rate limit you cannot see. On RAM, the
reader itself is one Go binary that costs almost nothing; PostgreSQL is the heavier half of
that 1024 MB floor, and the entry history is what grows the 5 GB.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/miniflux /srv/miniflux/backups
sudo install -d -m 700 /srv/miniflux/postgres
ls -la /srv/miniflux
```

You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no `data` directory here for Miniflux itself, and that is not a
missing step: feeds, entries, read state and your account are all rows in PostgreSQL.

## 3. Secrets

Two secrets: the PostgreSQL password and the password for your own Miniflux account. Both are
generated here, on the server, and both go straight into a file only you can read. Replace
`<DOMAIN>` on the first line with your real hostname before you paste.

```bash
umask 077
cat > /srv/miniflux/.env <<EOF
BASE_URL=https://<DOMAIN>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$(openssl rand -base64 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/miniflux/.env
umask 022
ls -l /srv/miniflux/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Your Miniflux
username is `admin`. Read the password once with
`sudo grep ADMIN_PASSWORD /srv/miniflux/.env` and put it in your password manager.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/miniflux/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both secrets, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `DB_PASSWORD` on an existing volume
produces an authentication failure in the Miniflux log rather than anything about passwords.
The database password is hex rather than base64 because upstream warns that special characters
can be rejected inside a URL-style connection string unless they are URL encoded.

Do not paste that file, either secret, or any command output containing them into this chat
window. The agent path never sees those values; this window will hand them to a third party
unless you keep them out of it.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/miniflux/compose.yml <<'EOF'
# Miniflux · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://miniflux.app/docs/docker.html
#   configuration ...... https://miniflux.app/docs/configuration.html
#   database ........... https://miniflux.app/docs/database.html
#   requirements ....... https://miniflux.app/docs/requirements.html
#
# Two services: Miniflux and the PostgreSQL that holds every feed, every entry
# and the one account. Miniflux keeps nothing of its own on disk, so there is no
# application data volume in this file and the database is the whole backup
# surface. Upstream supports PostgreSQL 11 and above. The regular image is used
# rather than the -distroless one: both publish amd64 and arm64, but the regular
# image is built on Alpine and has a shell, so `docker compose exec` can look
# inside it when something is wrong. Tags and digests were read from the
# registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/miniflux/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  miniflux:
    image: miniflux/miniflux:2.3.3@sha256:49d7b60987616387c306a8023087b31f2c9b7b21288b523026cb04058e8b6dbb
    container_name: miniflux
    restart: unless-stopped
    env_file: /srv/miniflux/.env
    environment:
      # Hex rather than base64 in the connection string on purpose: upstream
      # warns that a password carrying special characters can be rejected in
      # this URL form unless it is URL encoded, and hex has none to encode.
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@postgres:5432/miniflux?sslmode=disable
      # Miniflux runs its own SQL migrations and exits at start-up when the
      # schema is behind the binary, so this stays set through every upgrade.
      RUN_MIGRATIONS: "1"
      # The single account is built from ADMIN_USERNAME and ADMIN_PASSWORD in
      # .env on the first start, and every start after that logs that it is
      # skipping because the user exists. Miniflux has no self-registration, so
      # this is the only door into the instance.
      CREATE_ADMIN: "1"
      # Caddy terminates TLS and speaks plain http to this container, so the
      # session cookie has to be told it travels over https or the browser
      # will not keep it.
      HTTPS: "1"
    healthcheck:
      # Upstream's own health check: the binary asks its own /healthcheck
      # route, which answers 200 only when the database answers as well.
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      retries: 12
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8180.
      - "127.0.0.1:8180:8080"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/miniflux && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/miniflux/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your
terminal: run `rm /srv/miniflux/compose.yml` and paste again in one go. Note the `cd` on the
last line, and keep it on every later compose command: `docker compose` reads
/srv/miniflux/.env from the working directory to fill in `${DB_PASSWORD}`, and from anywhere
else that substitution comes out empty.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-miniflux
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Miniflux · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://miniflux.app/docs/howto.html,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Miniflux builds its session cookie and every link
# it renders from BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Upstream asks a proxy for X-Forwarded-Proto and X-Forwarded-For, and
	# Caddy's reverse_proxy sends both by default, so there is nothing to add
	# here. 8180 is the loopback port compose publishes on this host. It is
	# not a container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8180
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-miniflux /etc/caddy/Caddyfile`,
reload, and paste again. Check that the `<DOMAIN>` inside the block is your real hostname and
that it matches `BASE_URL` in .env character for character, because those two disagreeing is
the failure in step 10 and it does not announce itself.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8180` or `5432`.

If you do not: delete anything for `8180` or `5432` with `sudo ufw delete allow 8180`. 8180 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

Miniflux runs its database migrations on the way up and creates the one account from the
environment during that same start-up.

```bash
cd /srv/miniflux
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthcheck; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/v1/me
curl -sS https://<DOMAIN>/ | grep -c 'Sign In - Miniflux'
docker compose logs miniflux | grep -c 'admin user'
```

You should see, in order: the loop reaching `200`, then `OK`, then `401`, then `1`, then `1`
or more.

If you do not: the `401` is the one worth understanding. Miniflux's REST API is on by default
and answers 401 to a call carrying no token, so seeing it means the API is up and refusing
strangers. A `404` in its place means Caddy is not reaching the container: check
`docker compose ps`. If the loop never reaches `200`, run
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy
is step 2 done wrong, and `docker compose logs --tail 40 miniflux` second. The last count is
`1` on a first install, where the log line reads `Created new admin user`, and also `1` on a
second run, where it reads `Skipping admin user creation because it already exists`. A `0`
there means no account was made and nobody can log in.

Open https://<DOMAIN> in a browser. The first screen is a sign-in form with `Username` and
`Password` fields and a `Login` button, and the browser tab reads `Sign In - Miniflux`. Sign
in as `admin` with the password from step 3. There is no sign-up link on that page, because
Miniflux has none to show: accounts are made by you in the admin screen and nowhere else, and
that is why this install does not have a registration door to close afterwards.

The next step is yours, and it is the reason most people run this. A phone reader app talks to
Miniflux through the Google Reader or Fever API, and each is switched on under `Settings` then
`Integrations` by choosing a second username and password there. The server address the app
asks for is https://<DOMAIN>. Upstream names Capy Reader, NetNewsWire and Reeder Classic as
Google Reader clients, and Unread, FeedMe and NewsFlash as Fever ones.

## 8. First backup and restore

Two artifacts. The database holds every feed, entry, read mark and the account. The config
archive holds the files that rebuild the service around it.

```bash
cd /srv/miniflux
docker compose exec -T postgres pg_dump -U miniflux -d miniflux | gzip > /srv/miniflux/backups/miniflux-db-$(date +%F).sql.gz
sudo tar -czf /srv/miniflux/backups/miniflux-config-$(date +%F).tar.gz -C /srv/miniflux compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/miniflux/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/miniflux
scp vps:/srv/miniflux/backups/* ~/backups/miniflux/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/miniflux/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives. `No such file or directory` means the wildcard matched nothing, so check the listing on
the server again.

Now prove the restore, today, while the only thing at risk is an empty reader:

```bash
cd /srv/miniflux
docker compose down
sudo rm -rf /srv/miniflux/postgres
sudo install -d -m 700 /srv/miniflux/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/miniflux/backups/miniflux-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U miniflux -d miniflux
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/healthcheck; echo
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `OK` from the last command,
and you can still sign in as `admin` with the same password.

If you do not: `role "miniflux" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what is at stake
before you skip this: your subscriptions, your folders and years of read history are all in
that one dump, and the OPML export under `Settings` is a list of feeds, not a copy of what you
have read.

## 9. Updating later

New versions are listed at https://github.com/miniflux/v2/releases. Take both backup artifacts
first, then edit the `image:` line in /srv/miniflux/compose.yml to the new tag and its digest.

```bash
cd /srv/miniflux
docker compose pull
docker compose up -d
docker compose logs --tail 30 miniflux
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Miniflux
migrates its own schema on the way up and exits at start-up if the schema is behind the binary,
which is why `RUN_MIGRATIONS` stays set in the compose file rather than being a one-off. Re-run
the health check from step 7 before you call the update done.

## 10. What will probably go wrong

The login form will accept your password and hand you back the login form, with no error
message anywhere. I lost twenty minutes to that. Miniflux redirects a failed CSRF check back to
the login page silently, and the usual reason the check fails is that the browser never kept
the session cookie: `HTTPS` is set in compose.yml, so the cookie carries the secure flag, and
it is dropped if you reached the page over plain http or on any address other than the one in
`BASE_URL`. If a sign-in loops without complaining, run
`sudo grep BASE_URL /srv/miniflux/.env` and compare it character for character with what is in
your address bar before you read a single log line.

## 11. Out of scope

- Do not enable the Google Reader or Fever API from the command line or by editing the
  database. Each is switched on in your own settings by choosing a credential, and that choice
  is yours.
- Do not configure OAUTH2 or OIDC. Registering an application with an outside identity provider
  is a second install, and `OAUTH2_USER_CREATION` opens a second way for accounts to appear.
- Do not set `METRICS_COLLECTOR`. It publishes a Prometheus endpoint, and nothing on this box
  is scraping one.
- Do not lower `POLLING_FREQUENCY` below its 60 minute default to make feeds arrive faster.
  That multiplies outbound requests to sites that did not ask for them.

315 lines · 14,986 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Miniflux 2.3.3, with the PostgreSQL it stores every feed and entry in, under
~/selfhost/miniflux, answering at http://localhost:8180.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Miniflux polls feeds only while this computer is awake with Docker running, so a closed lid
collects nothing, and http://localhost:8180 means "this computer" wherever it is read, so no
phone reader app can reach this one.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Miniflux plus PostgreSQL needs 1024 MB of
RAM available and 5 GB free on the home disk, and both images publish amd64 and arm64. If
either floor is missed, print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/miniflux/backups
ls -la ~/selfhost/miniflux
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: feeds, entries,
read state and the account are rows in PostgreSQL, and step 5 keeps that database in a volume
Docker manages, so there is no ownership fix to run.

## 4. Secrets

Two secrets: the PostgreSQL password and the password for the user's own Miniflux account.
Generate both here, print neither, and keep both out of your summary and out of any log line.
Git Bash ships openssl, so these run the same on all three.

```bash
umask 077
cat > ~/selfhost/miniflux/.env <<EOF
BASE_URL=http://localhost:8180
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$(openssl rand -base64 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/miniflux/.env
umask 022
ls -l ~/selfhost/miniflux/.env
```

Assert: the file exists with mode `-rw-------`. The database password is hex because upstream
warns that special characters can be rejected inside a URL-style connection string unless they
are URL encoded. Tell the user their username is `admin`, that they read the password with
`grep ADMIN_PASSWORD ~/selfhost/miniflux/.env`, and that they put it in their password manager
now. On Windows those mode bits are advisory: the boundary is their own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/miniflux/compose.yml <<'EOF'
# Miniflux · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://miniflux.app/docs/docker.html
#   configuration ...... https://miniflux.app/docs/configuration.html
#   database ........... https://miniflux.app/docs/database.html
#
# Two services on the computer the reader is sitting at. Paths are relative to
# ~/selfhost/miniflux/, so one file works on macOS, Linux and Windows. Miniflux
# writes nothing to disk, so the database is the whole backup surface, and it is
# a named volume because PostgreSQL chowns that directory to its own uid, which
# a home-directory bind mount cannot allow on Windows. No TLS here, so the VPS
# file's HTTPS setting is absent. Digests read on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - miniflux-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  miniflux:
    image: miniflux/miniflux:2.3.3@sha256:49d7b60987616387c306a8023087b31f2c9b7b21288b523026cb04058e8b6dbb
    container_name: miniflux
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Hex, not base64: upstream warns special characters can be rejected in
      # this URL form unless URL encoded.
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@postgres:5432/miniflux?sslmode=disable
      # Miniflux exits when the schema is behind the binary, so this stays set.
      RUN_MIGRATIONS: "1"
      # The one account comes from ADMIN_USERNAME and ADMIN_PASSWORD in .env;
      # later starts log a skip. Miniflux has no self-registration.
      CREATE_ADMIN: "1"
    healthcheck:
      # Upstream's own: the binary asks its own /healthcheck route.
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      retries: 12
    ports:
      # Loopback only; the same host port the VPS compose file publishes.
      - "127.0.0.1:8180:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  miniflux-pgdata:
EOF
cd ~/selfhost/miniflux && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Compose reads ./.env from this folder for the
`${DB_PASSWORD}` substitution, which is why every later command starts by changing into it.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8180 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/miniflux/compose.yml
```

Assert: that prints `1`, the single published-port entry `- "127.0.0.1:8180:8080"`. PostgreSQL
declares no `ports:`, so 5432 cannot appear. A `0.0.0.0:8180` or a bare `8180:8080` there means
the file was edited: put the `127.0.0.1:` prefix back first.

## 7. Start and verify

Miniflux runs its migrations on the way up and creates the one account from the environment
during that start-up.

```bash
cd ~/selfhost/miniflux
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8180/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8180/healthcheck; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8180/v1/me
curl -sS http://localhost:8180/ | grep -c 'Sign In - Miniflux'
docker compose logs miniflux | grep -c 'admin user'
```

Assert all five, and print what you received for each: the loop ends on `200`; the health body
is `OK`, which upstream returns only when the database answers too; the unauthenticated API
call prints `401`, the security assert here: the REST API is on by default and refuses a call
with no token; the page grep prints `1`, the login screen's title; the log grep
prints `1` or more, matching `Created new admin user`, or `Skipping admin user creation` on a
repeat. If any misses, stop, run `docker compose logs --tail 40 miniflux` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy is step 4, where an empty `DB_PASSWORD` leaves PostgreSQL refusing to start. If
`port is already allocated` came back, find what holds 8180
(`lsof -nP -iTCP:8180 -sTCP:LISTEN`, or `netstat -ano | findstr :8180` on Windows) and stop
until the user frees it, because 8180 is inside `BASE_URL`. A running container is not success.

The first screen at http://localhost:8180 is a sign-in form with `Username` and `Password`
fields and a `Login` button, and the browser tab reads `Sign In - Miniflux`. There is no
sign-up link, because Miniflux has none to show.

STOP: tell the user to open http://localhost:8180, sign in as `admin` with the password from
step 4, and wait. Do not continue until they confirm they are looking at the reader. Then tell
them feeds poll once an hour by default, so a subscription added now can leave the list empty
for a while, and the refresh button proves it works today.

## 8. First backup and restore

Two artifacts: a database dump with every feed, entry and read mark, and a config archive with
the two files that rebuild the service around it.

```bash
cd ~/selfhost/miniflux
docker compose exec -T postgres pg_dump -U miniflux -d miniflux | gzip > ~/selfhost/miniflux/backups/miniflux-db-$(date +%F).sql.gz
tar -C ~/selfhost/miniflux -czf ~/selfhost/miniflux/backups/miniflux-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/miniflux/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together, so this is not a backup yet. Ask the user for a destination that leaves this
computer, a folder their sync service watches or a USB stick, and copy both there with `cp`.
In Git Bash a Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user
confirms both filenames are listed there. Otherwise say plainly there is no backup.

To restore, in this order. `cd ~/selfhost/miniflux`, untar the config archive there first, so
compose.yml and .env are back before any container starts: PostgreSQL takes `DB_PASSWORD` from
.env the moment it initialises an empty volume, and a missing .env means a blank password and a
database that will not start. Then `docker compose down -v`, the one place `-v` belongs because
it drops the old volume on purpose, `docker compose up -d postgres`, wait about 30 seconds for
healthy, then pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U miniflux -d miniflux`, then `docker compose up -d`.
Sign in and check the subscriptions are back. That is the disaster plan.

## 9. Updating later

New versions are listed at https://github.com/miniflux/v2/releases. Take both backups first,
then edit the image line in ~/selfhost/miniflux/compose.yml to the new tag and digest.

```bash
cd ~/selfhost/miniflux
docker compose pull
docker compose up -d
docker compose logs --tail 30 miniflux
```

Miniflux exits at start-up if the schema is behind the binary, which is why `RUN_MIGRATIONS`
stays set rather than being a one-off. Watch that log until it settles, then re-run step 7's
health check before calling the update done.

## 10. What will probably go wrong

I shut the laptop on a Friday and opened the reader on Monday expecting the weekend. Miniflux
does not catch up. Its scheduler runs only while the container runs, and on restart it fetches
what each feed holds at that moment, so anything that scrolled out of a busy feed over the
weekend is gone and nothing in the interface says it was there. Turn on Docker Desktop's
start-at-login setting, and tell the user plainly that a computer which sleeps is a reader with
holes in it.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8180 to 0.0.0.0 for a phone app, and do not point `BASE_URL` at this machine's
  wifi address. Both put the reader on every network the user joins.
- Do not enable the Google Reader or Fever API. Each hands out a second credential, and no
  device but this one can reach the endpoint it would authenticate against.
- Do not lower `POLLING_FREQUENCY` below its 60 minute default. That multiplies requests to
  sites that did not ask for them.
compose.local.ymlthe services, pinned · local layout60 lines

authored from upstream docs, never pasted · 2,545 bytes

# Miniflux · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://miniflux.app/docs/docker.html
#   configuration ...... https://miniflux.app/docs/configuration.html
#   database ........... https://miniflux.app/docs/database.html
#
# Two services on the computer the reader is sitting at. Paths are relative to
# ~/selfhost/miniflux/, so one file works on macOS, Linux and Windows. Miniflux
# writes nothing to disk, so the database is the whole backup surface, and it is
# a named volume because PostgreSQL chowns that directory to its own uid, which
# a home-directory bind mount cannot allow on Windows. No TLS here, so the VPS
# file's HTTPS setting is absent. Digests read on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - miniflux-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  miniflux:
    image: miniflux/miniflux:2.3.3@sha256:49d7b60987616387c306a8023087b31f2c9b7b21288b523026cb04058e8b6dbb
    container_name: miniflux
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Hex, not base64: upstream warns special characters can be rejected in
      # this URL form unless URL encoded.
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@postgres:5432/miniflux?sslmode=disable
      # Miniflux exits when the schema is behind the binary, so this stays set.
      RUN_MIGRATIONS: "1"
      # The one account comes from ADMIN_USERNAME and ADMIN_PASSWORD in .env;
      # later starts log a skip. Miniflux has no self-registration.
      CREATE_ADMIN: "1"
    healthcheck:
      # Upstream's own: the binary asks its own /healthcheck route.
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      retries: 12
    ports:
      # Loopback only; the same host port the VPS compose file publishes.
      - "127.0.0.1:8180:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  miniflux-pgdata:

agent-readable mirror: /self-host/inoreader.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned69 lines

authored from upstream docs, never pasted · 3,298 bytes

# Miniflux · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://miniflux.app/docs/docker.html
#   configuration ...... https://miniflux.app/docs/configuration.html
#   database ........... https://miniflux.app/docs/database.html
#   requirements ....... https://miniflux.app/docs/requirements.html
#
# Two services: Miniflux and the PostgreSQL that holds every feed, every entry
# and the one account. Miniflux keeps nothing of its own on disk, so there is no
# application data volume in this file and the database is the whole backup
# surface. Upstream supports PostgreSQL 11 and above. The regular image is used
# rather than the -distroless one: both publish amd64 and arm64, but the regular
# image is built on Alpine and has a shell, so `docker compose exec` can look
# inside it when something is wrong. Tags and digests were read from the
# registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/miniflux/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  miniflux:
    image: miniflux/miniflux:2.3.3@sha256:49d7b60987616387c306a8023087b31f2c9b7b21288b523026cb04058e8b6dbb
    container_name: miniflux
    restart: unless-stopped
    env_file: /srv/miniflux/.env
    environment:
      # Hex rather than base64 in the connection string on purpose: upstream
      # warns that a password carrying special characters can be rejected in
      # this URL form unless it is URL encoded, and hex has none to encode.
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@postgres:5432/miniflux?sslmode=disable
      # Miniflux runs its own SQL migrations and exits at start-up when the
      # schema is behind the binary, so this stays set through every upgrade.
      RUN_MIGRATIONS: "1"
      # The single account is built from ADMIN_USERNAME and ADMIN_PASSWORD in
      # .env on the first start, and every start after that logs that it is
      # skipping because the user exists. Miniflux has no self-registration, so
      # this is the only door into the instance.
      CREATE_ADMIN: "1"
      # Caddy terminates TLS and speaks plain http to this container, so the
      # session cookie has to be told it travels over https or the browser
      # will not keep it.
      HTTPS: "1"
    healthcheck:
      # Upstream's own health check: the binary asks its own /healthcheck
      # route, which answers 200 only when the database answers as well.
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      retries: 12
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8180.
      - "127.0.0.1:8180:8080"
    depends_on:
      postgres:
        condition: service_healthy
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,076 bytes

# Miniflux · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://miniflux.app/docs/howto.html,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Miniflux builds its session cookie and every link
# it renders from BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Upstream asks a proxy for X-Forwarded-Proto and X-Forwarded-For, and
	# Caddy's reverse_proxy sends both by default, so there is nothing to add
	# here. 8180 is the loopback port compose publishes on this host. It is
	# not a container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8180
}
install.shthe same install, no agent160 lines

authored from upstream docs, never pasted · 7,296 bytes

#!/usr/bin/env bash
# Miniflux · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=rss.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://miniflux.app/docs/docker.html
#   https://miniflux.app/docs/configuration.html
#   https://miniflux.app/docs/database.html
#   https://miniflux.app/docs/requirements.html
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the password for your own Miniflux account. Both go into /srv/miniflux/.env
# with mode 600 and neither is ever printed.
#
# DOMAIN_HOST is also BASE_URL. Miniflux builds its session cookie and every
# link it renders from BASE_URL, so a hostname that does not match the address
# in your browser produces a login page that will not log you in.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/miniflux}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_USER="${ADMIN_USER:-admin}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. rss.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Miniflux plus PostgreSQL wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# There is no data directory for Miniflux itself: feeds, entries, read state and
# the account are all rows in PostgreSQL, and the reader writes nothing else.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# The database password is hex because upstream warns that special characters
# can be rejected inside a URL-style connection string unless they are URL
# encoded, and hex has nothing to encode. Read them later with
#   sudo grep -E 'ADMIN_PASSWORD|DB_PASSWORD' /srv/miniflux/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BASE_URL=https://${DOMAIN_HOST}
		ADMIN_USERNAME=${ADMIN_USER}
		ADMIN_PASSWORD=$(openssl rand -base64 24)
		DB_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-miniflux"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8180 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8180 and 5432 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# Miniflux runs its own SQL migrations on the way up and creates the single
# account from ADMIN_USERNAME and ADMIN_PASSWORD during that same start-up.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/healthcheck"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthcheck" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/healthcheck answered ${code:-nothing}. Check: docker compose logs --tail 40 miniflux"

curl -sS "https://${DOMAIN_HOST}/healthcheck" | grep -q '^OK$' \
	|| die "/healthcheck answered 200 without the body OK. Check: docker compose logs --tail 40 miniflux"

# The REST API must refuse an unauthenticated call. Upstream answers 401 to a
# request carrying no X-Auth-Token and no basic credentials.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/v1/me" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

# The first screen is the sign-in form, and Miniflux offers no way to register.
curl -sS "https://${DOMAIN_HOST}/" | grep -q 'Sign In - Miniflux' \
	|| die "the root page does not carry the sign-in title. Check: docker compose logs --tail 40 miniflux"

docker compose logs miniflux | grep -q 'admin user' \
	|| die "the log shows neither an admin user created nor one skipped. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U miniflux -d miniflux | gzip > "$APP_DIR/backups/miniflux-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/miniflux-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/miniflux-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Miniflux is answering at https://${DOMAIN_HOST}/

	  1. Your username is ${ADMIN_USER}. Read the password once with
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     put it in your password manager, and do not paste it anywhere else.
	     It was not printed here.
	  2. There is no sign-up link, because Miniflux has none. New accounts are
	     made by you, in the admin screen, and nowhere else.
	  3. To read on a phone, open Settings then Integrations and choose a
	     username and password for the Google Reader or Fever API. The server
	     address the app asks for is https://${DOMAIN_HOST} . Upstream names
	     Capy Reader, NetNewsWire and Reeder Classic as Google Reader clients,
	     and Unread, FeedMe and NewsFlash as Fever ones.
	  4. Feeds poll once an hour by default, so a subscription added now can
	     leave the list empty for a while. The refresh button proves it works.
	  5. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Inoreader.

  • You own a PostgreSQL, and it is the only thing you own. Miniflux writes nothing to disk of its own, which keeps the install small and makes the backup singular: everything you have ever subscribed to and everything you have read is in one database that has to be dumped rather than copied.
  • The omissions are the product. No AI summarising, no monitoring feeds that turn a page without RSS into one, no newsletter inbox, no rules engine. Inoreader sells all four, and an upstream that keeps saying no to them is the reason this stays a single binary you never think about.
  • The phone is a second decision, and it is yours. There is no first-party app. You switch on the Google Reader or Fever API in your own settings, pick a second username and password there, and point a third-party client at your hostname. It works, and nobody does that step for you.
  • Polling is outbound and on your schedule, which is 60 minutes out of the box. A publisher that rate-limits your server's IP is now your problem, and a box that is down is a reader that quietly stops collecting rather than one that tells you.
  • Nobody can sign up, which is a security property on day one and a chore afterwards. Miniflux has no registration screen at all, so every extra account, for a partner or a housemate, is one you create by hand in the admin screen.

Where this came from

“Only PostgreSQL >= 11 is supported.”

  • CREATE_ADMIN builds the single account from ADMIN_USERNAME and ADMIN_PASSWORD during start-up, and the documented options list carries no setting for open registration or public sign-up. source
  • Upstream publishes the image on Docker Hub, GitHub Container Registry and Quay, and documents the container health check as the miniflux binary calling its own endpoint with -healthcheck auto. source
  • A database password carrying special characters can be rejected inside the URL form of DATABASE_URL unless it is URL encoded, which is why this install generates a hex one. source
  • The Google Reader API is switched on by choosing a username and password under Settings then Integrations, and upstream names Capy Reader, NetNewsWire, Reeder Classic and RSS Guard as clients. source
  • The published Alpine image exposes port 8080, sets LISTEN_ADDR to 0.0.0.0:8080 and runs as uid 65534, while the distroless variant is built from a base image that carries no shell. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Inoreader?

    Not Inoreader itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Miniflux. A feed reader with nothing in it but feeds: one Go binary, one database, and a Google Reader API the phone apps already speak. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 70 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Inoreader?

    Miniflux. A feed reader with nothing in it but feeds: one Go binary, one database, and a Google Reader API the phone apps already speak. Matches the thing Inoreader is actually used for every day: open it, read what arrived, close it. Miniflux is a single Go binary with a keyboard-driven interface and no JavaScript framework in front of it, so it opens on a phone browser as fast as it does on a desk, and its Google Reader and Fever APIs mean the reader app you already like keeps working with your server behind it. What it does not attempt is the rest of the Inoreader pitch: no rules engine, no monitoring feeds for sites without RSS, no newsletter inbox, no AI filtering. If those are why you pay, this is not the swap. Also evaluated and ranked below it: FreshRSS. The prompt on this page installs Miniflux only. Miniflux is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Inoreader?

    1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Miniflux itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Inoreader Pro, $9.99/mo — $119.88 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Miniflux install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Miniflux on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Miniflux on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Miniflux polls feeds only while this computer is awake, and http://localhost:8180 is reachable from nothing else, so the phone reader app that is the usual reason to run Miniflux cannot connect and the reading happens where the install is. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.