# Can I self-host Feedly?

**YES** — it's called FreshRSS. ONE COMMAND setup · ~10 minutes to running · 512 MB RAM minimum · $6.99/mo you stop paying ($83.88/yr on the Pro plan).

FreshRSS authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/feedly/

## Install prompt (Claude Code)

````text
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 FreshRSS 1.29.1 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. FreshRSS needs 512 MB of RAM
available and 5 GB free on /srv, and the 1.29.1 image is published for 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 512 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

The data directory is owned by uid 33, because that is `www-data` inside the image and
Apache is what writes the database.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/freshrss /srv/freshrss/backups
sudo install -d -m 750 -o 33 -g 33 /srv/freshrss/data
ls -la /srv/freshrss
```

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by `33`.
Everything lives under /srv/freshrss, on local disk, and nothing is written outside it.

## 3. Secrets

One secret: the password for the user's FreshRSS account, generated on the server. Do not
print it, do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/freshrss/.env <<EOF
TZ=UTC
CRON_MIN=13,43
TRUSTED_PROXY=172.16.0.0/12
ADMIN_USER=admin
ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/freshrss/.env
umask 022
ls -l /srv/freshrss/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user their username is `admin`,
that they can read the password with `sudo grep ADMIN_PASSWORD /srv/freshrss/.env`, and
that they should put it in their password manager now. `TRUSTED_PROXY` is the range Caddy
forwards from, so FreshRSS trusts the forwarded headers rather than logging the proxy as
the client.

## 4. compose.yml

```bash
cat > /srv/freshrss/compose.yml <<'EOF'
# FreshRSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and env vars . https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/README.md
#   built-in cron ...... https://freshrss.github.io/FreshRSS/en/admins/08_FeedUpdates.html
#   what to back up .... https://freshrss.github.io/FreshRSS/en/admins/05_Backup.html
#   reverse proxy ...... https://freshrss.github.io/FreshRSS/en/admins/Caddy.html
#
# One container. FreshRSS ships with SQLite, so the database, the user record and
# the favicons live under /var/www/FreshRSS/data, and that one directory is what a
# backup has to contain. CRON_MIN is the image's own cron daemon, and leaving it
# unset means nothing refreshes. Tag and digest are the 1.29.1 release read from
# Docker Hub on 2026-08-05, covering linux/amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  freshrss:
    image: freshrss/freshrss:1.29.1@sha256:ab6b363102ccdbc39f6a62db926f567c61a5289bf25ba460f1c34423d8cc1a4d
    container_name: freshrss
    restart: unless-stopped
    env_file: /srv/freshrss/.env
    volumes:
      # uid 33 (www-data) in the image writes here, hence the ownership in step 2.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/freshrss/data:/var/www/FreshRSS/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8084 never enters the firewall.
      - "127.0.0.1:8084:80"
EOF
cd /srv/freshrss && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on port 80 inside itself and 8084
is bound to 127.0.0.1 on the host, so the only route in is Caddy.

## 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-freshrss
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# FreshRSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://freshrss.github.io/FreshRSS/en/admins/Caddy.html 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. Upstream's own Caddy page
# uses a bare reverse_proxy for a subdomain; the headers are ours.

<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
	}

	# 8084 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:8084
}
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-freshrss, reload, and report what it objected to. Caddy
requests the certificate on the first request and renews it without a cron job. If a
FreshRSS link ever comes out as plain http, `base_url` in /srv/freshrss/data/config.php is
the documented override. Do not set it pre-emptively.

## 6. Firewall

Two ports open, both of them Caddy's, and 8084 is not one of them. These are 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, which Caddy offers by default. 8084 stays closed: bound to 127.0.0.1, a
rule for it would cover traffic that cannot arrive, and if 8084 shows up there a previous
run left it, which `sudo ufw delete allow 8084` fixes. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8084.

## 7. Start and verify

The web installer is skipped on purpose: until somebody completes it, it is open to
whoever reaches the hostname first. The password comes from the container's own
environment, so it never reaches shell history.

```bash
cd /srv/freshrss
docker compose pull
docker compose up -d
sleep 15
docker compose exec -T --user www-data freshrss cli/do-install.php --default-user admin
docker compose exec -T --user www-data freshrss sh -c 'cli/create-user.php --user "$ADMIN_USER" --password "$ADMIN_PASSWORD"'
sudo ls -l /srv/freshrss/data/config.php
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'FreshRSS'
```

Assert, all three: `ls -l` prints a line for `data/config.php`, the first curl prints
`200`, and the second prints a number greater than `0`, because `FreshRSS` appears in the
served document. Print what you actually received for each. If any of the three misses,
stop, run `docker compose logs --tail 30 freshrss`, and say which earlier step is the
likely cause. If `do-install.php` reports the install already exists, that is not an error
on a second run: carry on and check `config.php`, which is the security assert: that file
is what makes the web installer stop answering.

A running container is not success; three asserts passing is success. The first screen at
https://<DOMAIN> is a login form asking for a username and a password, and the user signs
in as `admin` with the password from step 3.

## 8. First backup and restore

Take the backup now, before the user imports a feed. Stop first: a SQLite file copied
mid-write is not a backup.

```bash
cd /srv/freshrss
docker compose stop
sudo tar -C /srv/freshrss -czf /srv/freshrss/backups/freshrss-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/freshrss/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds,
and `data` plus `.env` is the whole install. A backup on the same disk as the data is not
a backup, so run this one from the user's machine, not the server:

```bash
mkdir -p ~/backups/freshrss
scp vps:/srv/freshrss/backups/*.tar.gz ~/backups/freshrss/
```

To restore: `docker compose down`, `sudo rm -rf /srv/freshrss/data`,
`sudo tar -C /srv/freshrss -xzf` the archive, then `docker compose up -d`. The reader is a
SQLite file under `data/users/admin/`. Tell the user those four commands are the whole
disaster plan.

## 9. Updating later

New versions are listed at https://github.com/FreshRSS/FreshRSS/releases. Take a backup
first, then edit the image line in /srv/freshrss/compose.yml to the new tag and its
digest. FreshRSS migrates its own schema on the first request after an upgrade, so load
the page once before calling the update done.

```bash
cd /srv/freshrss
docker compose pull
docker compose up -d
docker compose logs --tail 20 freshrss
```

## 10. What will probably go wrong

Nothing, for about half an hour, and it looks exactly like a broken install. I added a
dozen feeds, refreshed, and got an empty reader. Two things were true: the image's cron
only fires at 13 and 43 minutes past the hour, and FreshRSS refuses to refresh a feed more
often than every twenty minutes no matter who asks. If the user says no articles are
arriving, check the clock before any log, and tell them the refresh button in the
interface proves it works now.

## 11. Out of scope

- Do not switch the database to PostgreSQL or MySQL. SQLite is why this is one container
  with one directory to copy.
- Do not configure SMTP. A single-user install sends no mail, and address validation stays
  off.
- Do not install FreshRSS extensions. Only `data/` is mounted, so one installed now
  disappears at the next image bump.
- Do not enable the Google Reader or Fever API. Each is a credential per app, and that is
  the user's call.
````

## Chat fallback

````text
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 FreshRSS 1.29.1 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.

## 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 `512` MB available, at least `5` G free, `amd64` or `arm64`, and
your server's IP address on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it at your
DNS provider, wait a minute, and run `dig +short <DOMAIN>` again. Do not go on without it:
Caddy cannot get a certificate for a hostname that does not resolve, and failed attempts
count against a rate limit you cannot see.

## 2. Layout

The `data` directory is owned by uid 33 because that is `www-data` inside the image, and
Apache in the container is the process that writes the database.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/freshrss /srv/freshrss/backups
sudo install -d -m 750 -o 33 -g 33 /srv/freshrss/data
ls -la /srv/freshrss
```

You should see: `backups` owned by your own username, and `data` owned by `33`.

If you do not: `data` owned by you instead of `33` means the second command did not run,
and the container will fail to write its database with a permission error that mentions
nothing about ownership. Run the second line again on its own.

## 3. Secrets

One secret: the password for your own FreshRSS account. It is generated here, on the
server, and it goes straight into a file only you can read.

```bash
umask 077
cat > /srv/freshrss/.env <<EOF
TZ=UTC
CRON_MIN=13,43
TRUSTED_PROXY=172.16.0.0/12
ADMIN_USER=admin
ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/freshrss/.env
umask 022
ls -l /srv/freshrss/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Your FreshRSS
username will be `admin`. Read the password once with
`sudo grep ADMIN_PASSWORD /srv/freshrss/.env` and put it straight into 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 one at a time in different shells. Run
`chmod 600 /srv/freshrss/.env` and carry on.

Do not paste the contents of that file, the password, or any command output containing it
into this chat window. Nothing in the rest of this guide needs it, and once it is in a
transcript it is somebody else's copy.

## 4. compose.yml

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

```bash
cat > /srv/freshrss/compose.yml <<'EOF'
# FreshRSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and env vars . https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/README.md
#   built-in cron ...... https://freshrss.github.io/FreshRSS/en/admins/08_FeedUpdates.html
#   what to back up .... https://freshrss.github.io/FreshRSS/en/admins/05_Backup.html
#   reverse proxy ...... https://freshrss.github.io/FreshRSS/en/admins/Caddy.html
#
# One container. FreshRSS ships with SQLite, so the database, the user record and
# the favicons live under /var/www/FreshRSS/data, and that one directory is what a
# backup has to contain. CRON_MIN is the image's own cron daemon, and leaving it
# unset means nothing refreshes. Tag and digest are the 1.29.1 release read from
# Docker Hub on 2026-08-05, covering linux/amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  freshrss:
    image: freshrss/freshrss:1.29.1@sha256:ab6b363102ccdbc39f6a62db926f567c61a5289bf25ba460f1c34423d8cc1a4d
    container_name: freshrss
    restart: unless-stopped
    env_file: /srv/freshrss/.env
    volumes:
      # uid 33 (www-data) in the image writes here, hence the ownership in step 2.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/freshrss/data:/var/www/FreshRSS/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8084 never enters the firewall.
      - "127.0.0.1:8084:80"
EOF
cd /srv/freshrss && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/freshrss/.env not found` means step 3 did not write the
file, so go back. `services must be a mapping` means the indentation was lost between the
page and your terminal: run `rm /srv/freshrss/compose.yml` and paste the block again in
one go.

## 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-freshrss
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# FreshRSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://freshrss.github.io/FreshRSS/en/admins/Caddy.html 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. Upstream's own Caddy page
# uses a bare reverse_proxy for a subdomain; the headers are ours.

<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
	}

	# 8084 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:8084
}
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-freshrss /etc/caddy/Caddyfile`,
reload, and paste again, checking that the blank line from the second command really
landed. Caddy asks Let's Encrypt for the certificate on the first request to your hostname
and renews it on its own, so there is nothing to schedule.

Later on, if a link inside FreshRSS comes out as plain `http`, the documented fix is
`base_url` in /srv/freshrss/data/config.php. Do not set it now.

## 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 `8084`.

If you do not: a rule for `8084` from an earlier attempt should go, with
`sudo ufw delete allow 8084`. 8084 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would cover traffic that
cannot arrive. 80/tcp is there to redirect to HTTPS and answer the ACME challenge, 443/tcp
is the only way in, and 443/udp is HTTP/3.

## 7. Start and verify

You are skipping the web installer on purpose. Until somebody completes that wizard it is
open to whoever reaches the hostname first, and the command line installer closes the
window without a browser being involved. The password is read from the container's own
environment, so it never reaches your shell history.

```bash
cd /srv/freshrss
docker compose pull
docker compose up -d
sleep 15
docker compose exec -T --user www-data freshrss cli/do-install.php --default-user admin
docker compose exec -T --user www-data freshrss sh -c 'cli/create-user.php --user "$ADMIN_USER" --password "$ADMIN_PASSWORD"'
sudo ls -l /srv/freshrss/data/config.php
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'FreshRSS'
```

You should see: a line of installer output, a line of user output, then a listing for
`data/config.php`, then `200`, then a number greater than `0`.

If you do not: `No such file or directory` for `config.php` is the important failure,
because that file is what makes the web installer stop answering. Until it exists a
stranger who finds your hostname can finish the wizard and own the reader, so fix this
before anything else: run `docker compose logs --tail 30 freshrss` and look for a
permission error on `/var/www/FreshRSS/data`, which is step 2 done wrong. If the installer
says the install already exists, that is fine on a second run. If the first curl prints
`000` or `502`, the certificate is not there yet: run `sudo journalctl -u caddy -n 30`.

A container listed in `docker ps` is not proof of anything. The three checks above are.

Now open https://<DOMAIN> in a browser. The first screen is a login form asking for a
username and a password. Sign in as `admin` with the password from step 3.

## 8. First backup and restore

Do this before you import a single feed, so you find out now whether it works. The stop
matters: a SQLite file copied mid-write is not a backup.

```bash
cd /srv/freshrss
docker compose stop
sudo tar -C /srv/freshrss -czf /srv/freshrss/backups/freshrss-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/freshrss/backups/
```

You should see: one `.tar.gz` file, a few hundred kilobytes on a fresh install. The site
is down for a few seconds while this runs, which is the price of a backup that is actually
consistent.

If you do not: `tar: data: Cannot open` means the `cd` did not happen. A size of `45`
bytes means tar wrote an empty archive because the paths were wrong, so check
`sudo ls /srv/freshrss/data` before you trust it.

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

```bash
mkdir -p ~/backups/freshrss
scp vps:/srv/freshrss/backups/*.tar.gz ~/backups/freshrss/
```

You should see: one file copied, and the same file listed by `ls -lh ~/backups/freshrss/`.

If you do not: `Permission denied (publickey)` means you ran it on the server by mistake.
The `vps:` prefix only means something on your own machine.

Now prove the restore, because a backup you have never restored is a guess:

```bash
cd /srv/freshrss
docker compose down
sudo rm -rf /srv/freshrss/data
sudo tar -C /srv/freshrss -xzf /srv/freshrss/backups/freshrss-$(date +%F).tar.gz
docker compose up -d
```

You should see: `Created` and `Started`, then a working login page at https://<DOMAIN>
with your `admin` account still there.

If you do not: a login page that has turned back into the installation wizard means the
archive did not contain `data/config.php`. Stop and go back to the tar step. Those four
commands are the whole disaster plan, and you have now run them once.

## 9. Updating later

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

```bash
cd /srv/freshrss
docker compose pull
docker compose up -d
docker compose logs --tail 20 freshrss
```

You should see: `Recreated`, then Apache startup lines and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. FreshRSS
migrates its own schema on the first request after an upgrade, so load the page once and
read the log before you call the update done.

## 10. What will probably go wrong

Nothing, for about half an hour, and it looks exactly like a broken install. I added a
dozen feeds, refreshed, and got an empty reader. Two things were true: the image's cron
only fires at 13 and 43 minutes past the hour, and FreshRSS refuses to refresh a feed more
often than every twenty minutes no matter who asks. If no articles are arriving, check the
clock before any log, and use the refresh button in the interface to prove it works now.

## 11. Out of scope

- Do not switch the database to PostgreSQL or MySQL. SQLite is why this is one container
  with one directory to copy.
- Do not configure SMTP. A single-user install sends no mail, and address validation stays
  off.
- Do not install FreshRSS extensions. Only `data/` is mounted, so one installed now
  disappears at the next image bump.
- Do not enable the Google Reader or Fever API. Each is a credential per app, and that is
  your call to make later.
````

## docker-compose.yml

```yaml
# FreshRSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and env vars . https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/README.md
#   built-in cron ...... https://freshrss.github.io/FreshRSS/en/admins/08_FeedUpdates.html
#   what to back up .... https://freshrss.github.io/FreshRSS/en/admins/05_Backup.html
#   reverse proxy ...... https://freshrss.github.io/FreshRSS/en/admins/Caddy.html
#
# One container. FreshRSS ships with SQLite, so the database, the user record and
# the favicons live under /var/www/FreshRSS/data, and that one directory is what a
# backup has to contain. CRON_MIN is the image's own cron daemon, and leaving it
# unset means nothing refreshes. Tag and digest are the 1.29.1 release read from
# Docker Hub on 2026-08-05, covering linux/amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  freshrss:
    image: freshrss/freshrss:1.29.1@sha256:ab6b363102ccdbc39f6a62db926f567c61a5289bf25ba460f1c34423d8cc1a4d
    container_name: freshrss
    restart: unless-stopped
    env_file: /srv/freshrss/.env
    volumes:
      # uid 33 (www-data) in the image writes here, hence the ownership in step 2.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/freshrss/data:/var/www/FreshRSS/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8084 never enters the firewall.
      - "127.0.0.1:8084:80"
```

## Caddyfile

```text
# FreshRSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://freshrss.github.io/FreshRSS/en/admins/Caddy.html 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. Upstream's own Caddy page
# uses a bare reverse_proxy for a subdomain; the headers are ours.

<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
	}

	# 8084 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:8084
}
```

## install.sh

```bash
#!/usr/bin/env bash
# FreshRSS · 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://github.com/FreshRSS/FreshRSS/blob/edge/Docker/README.md
#   https://freshrss.github.io/FreshRSS/en/admins/08_FeedUpdates.html
#   https://freshrss.github.io/FreshRSS/en/admins/05_Backup.html
#   https://freshrss.github.io/FreshRSS/en/admins/Caddy.html
#
# One secret is generated here, on this machine: the password for your own
# FreshRSS account. It is written to /srv/freshrss/.env with mode 600 and it is
# never printed to the terminal.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/freshrss}"
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 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 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 ----------------------------------------------------
#
# data/ is owned by uid 33 because that is www-data inside the image, and Apache
# in the container is the process that writes the SQLite database.

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

# --- 3. Generate the one secret, on the server -------------------------------
#
# This password has never existed anywhere else: not in the prompt, not in a chat
# window, not in this repository. Read it later with
#   sudo grep ADMIN_PASSWORD /srv/freshrss/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		TZ=UTC
		CRON_MIN=13,43
		TRUSTED_PROXY=172.16.0.0/12
		ADMIN_USER=${ADMIN_USER}
		ADMIN_PASSWORD=$(openssl rand -base64 24)
	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-freshrss"
	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 8084 is not 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; 8084 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it, then install it non-interactively --------------------------
#
# The web installer is skipped on purpose. It is open to whoever reaches the
# hostname first until somebody completes it, and cli/do-install.php closes that
# window without a browser being involved. The password is read from the
# container's own environment, so it never appears on a command line.

docker compose pull
docker compose up -d
sleep 15

docker compose exec -T --user www-data freshrss cli/do-install.php --default-user "$ADMIN_USER" \
	|| echo "==> do-install reported nothing to do; data/config.php already exists"
docker compose exec -T --user www-data freshrss \
	sh -c 'cli/create-user.php --user "$ADMIN_USER" --password "$ADMIN_PASSWORD"' \
	|| echo "==> create-user reported nothing to do; the account already exists"

sudo test -f "$APP_DIR/data/config.php" || die "data/config.php was not written, so the web installer is still open"

# --- 7. Prove it works before claiming it does -------------------------------

echo "==> waiting for https://${DOMAIN_HOST}/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: docker compose logs freshrss"

curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'FreshRSS' \
	|| die "the page answered 200 but does not mention FreshRSS. Check: docker compose logs freshrss"

# --- 8. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup.

docker compose stop
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/freshrss-$(date +%Y%m%d-%H%M%S).tar.gz" data .env
docker compose start
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	FreshRSS is running 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.
	  2. Add a feed, then wait. Feeds refresh at 13 and 43 minutes past the
	     hour, and FreshRSS will not refresh any feed more often than every
	     twenty minutes. An empty reader for half an hour is normal.
	  3. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/feedly/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
