User guide / Running a Fiction Reader server

Reference

Running a server

A server suits a household with several devices, or one always-on machine that keeps checking for new chapters while your laptop is shut. This page assumes you're comfortable with Docker and a terminal.

All guide pages

Why a server

With a server there's one archive, one set of read markers and one download per chapter, and every device sees the same thing.

The setup this is built around is a mini PC on a home network, running the app and PostgreSQL under Docker Compose. You read from a tablet, a desktop browser, and a phone over a VPN.

First run

cd deploy/docker
cp .env.example .env
openssl rand -base64 32          # paste into FR_ENCRYPTION_KEY
docker compose up -d
docker compose logs -f app       # watch it start

Then open http://<that-machine>:8080, create a profile, and go to Settings → Sources to turn sites on and add any accounts.

Back up FR_ENCRYPTION_KEY somewhere separate. It encrypts every saved site password and notification address. If you lose it, they have to be entered again.

The image runs everything in one process, which suits a single machine. There's no shell inside it, so you manage it through the app and through Compose.

Storage as directories

Named Docker volumes work with no preparation, but they live under /var/lib/docker/volumes, which is awkward to back up. To use ordinary directories, run this before the first start:

sudo ./setup-hostpaths.sh        # creates /srv/fiction-reader/{content,pgdata,backups}

Then set in .env:

FR_DATA_ROOT=/srv/fiction-reader
COMPOSE_FILE=docker-compose.yml:docker-compose.hostpaths.yml

The script creates the directories with the right owners. If Docker creates them itself they belong to root, and the containers can't write to them.

Surviving a power cut

sudo systemctl enable --now docker

Both services restart unless you stopped them yourself. PostgreSQL recovers after an unclean shutdown, and a download that was interrupted is picked up again.

Reaching it by name

Add a local DNS record that maps a name to the machine's address, for example in Pi-hole, then use http://fiction.home:8080. A DNS record only maps a name to an address, so you still need the port unless something is listening on port 80.

Check what's using port 80 before you put a reverse proxy on it. Pi-hole's own admin page listens there. ss -tlnp 'sport = :80' will tell you.

Letting the Android app in

The Android app loads its pages from https://localhost, and the server has to allow that origin:

FR_CORS_ORIGINS=https://localhost

This is the most common reason the Android app can't connect. Without the setting, the app can't read any of the server's replies. Set it first, then check the address and your VPN.

The setting is empty by default and can't be a wildcard. The server identifies readers by a profile name alone, so an open setting would let any web page you visit read and change your library.

FanFiction.net and the browser image

FanFiction.net only answers a real browser. The ordinary image doesn't contain one, so on a server every request to that site is refused, and the site's card in Settings says so.

To use FanFiction.net on a server, build and run the browser version of the image, deploy/docker/Dockerfile.browser. It's about 1 GB, because it includes Chromium. A few things to set:

  • Give the container more shared memory, with shm_size: 512m in Compose. Chromium crashes on large pages without it.
  • Set FR_BROWSER_PROFILE_DIR to a directory on the data volume, so the browser keeps its profile between restarts. A browser with no history gets challenged more often.

Stories from this site download slowly, one page at a time. The site also puts up an anti-bot check after a run of pages. When that happens the downloads wait and carry on later.

The same browser helps with SpaceBattles, which runs an anti-bot check from time to time. With the browser image, a page that meets the check is loaded in the browser and the rest are fetched normally. Without it, the request waits and is tried again later.

The desktop and Android apps don't need any of this. They use a browser they already have.

Configuration

Settings are environment variables starting with FR_. These are the ones worth knowing. The repository's documentation has the full list.

VariableDefaultMeaning
FR_DATABASE_URLrequiredPostgreSQL connection string.
FR_ENCRYPTION_KEYrequired32 random bytes, base64. Encrypts saved site logins and notification addresses.
FR_LISTEN_ADDR:8080The address to listen on.
FR_CONTENT_DIR/dataWhere chapter text, covers and images are stored.
FR_INSTANCE_TOKENemptyIf set, every request has to carry it. It's one shared secret for the whole server.
FR_CORS_ORIGINSemptyExtra allowed origins, separated by commas. The Android app needs https://localhost.
FR_DEFAULT_CHECK_INTERVAL_MINUTES360How often a subscribed story is checked for new chapters.
FR_DISABLED_SOURCESemptySites that start switched off on a new server. After that, Settings decides.
FR_LOCAL_LIBRARY_ENABLEDfalseTurns My Books on and keeps it on. If unset, the switch in Settings decides.
FR_LOCAL_LIBRARY_DIRemptySets the books folder and stops it being changed in Settings.
FR_TUTORIAL_ENABLEDtrueWhether the guided tour starts by itself for a new profile.
FR_LOG_LEVELinfodebug, info, warn or error.
FR_METRICS_ENABLEDfalseExposes Prometheus metrics at /metrics.

Per-site rate limits and retries aren't environment variables. You set them in Settings → Sources while the server is running.

My Books on a server

My Books is off by default. Turn it on in Settings → My Books, or set FR_LOCAL_LIBRARY_ENABLED=true.

To keep the books in a folder on the host that you can copy files into, uncomment FR_LOCAL_LIBRARY_DIR and the /books mount in the Compose file. Create the directory first, owned by uid 65532.

Backups

A backup has three parts. A database dump isn't enough by itself: restore only that and you get a library whose chapters won't open.

# 1. the database
docker compose exec -T postgres pg_dump -U fiction fiction \
  | gzip > fiction-$(date +%F).sql.gz

# 2. the content volume
docker run --rm --volumes-from "$(docker compose ps -q app)" alpine \
  tar czf - -C /data . > content-$(date +%F).tar.gz

# 3. FR_ENCRYPTION_KEY, kept somewhere safe and separate

deploy/docker/backup.sh does all three. Restore in the same order: database, then content, then start the app.

If the content backup is slightly older than the database, you only lose the chapters fetched in between, and the next check downloads them again. After a restore, Settings → Archive verification can check the content without changing it.

Upgrades

Pull or rebuild the image, then:

docker compose up -d

Database changes are applied automatically when the server starts. After an upgrade, reload any open browser tabs and restart desktop windows that point at the server, because an open window keeps running the old version.

TLS and reverse proxies

TLS on a home network is optional. It lets browsers install the app without a warning. Caddy is the least work:

fiction.home.arpa {
    tls internal
    reverse_proxy app:8080
}
Live progress needs an unbuffered proxy. Download and queue progress arrive over a streaming connection. Caddy handles that by default. With nginx, set proxy_buffering off; and proxy_read_timeout 3600s;, or the progress bars won't move.

Sizing

Measured with roughly 100 stories and 30,000 chapters:

ResourceIdleWhile downloading
App memoryabout 100 MBabout 280 MB
PostgreSQLabout 150 MBabout 250 MB
CPUnegligibleunder one core
Diskabout 30 KB per chapter, so around 1 GB per 30,000 chapters, plus images

A mini PC with 4 GB of memory is plenty. The browser image needs more, since it runs Chromium.

Security

A reader is identified by a profile name and nothing else. There are no passwords, which is fine on a private network and is the reason for the rule below.

Don't expose this service to the internet. Don't forward the port through your router. To reach it from outside your home, use a VPN.
  • FR_INSTANCE_TOKEN adds a shared secret to every request. It helps on a home network but isn't proper authentication.
  • Saved site logins are encrypted and are never returned by the server.
  • Downloaded pages are cleaned before they're stored, and addresses are checked before they're fetched.
  • Logs leave out credentials. Read a log before you paste it into a public issue all the same.