Why a server
One archive, one set of read markers, one download per chapter, and every device looking at the same thing. Nothing to reconcile, and no story fetched twice.
The reference deployment is a mini PC on a home network running the API, the worker and PostgreSQL under Docker Compose, 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 the migrations apply
Then open http://<that-machine>:8080, create a profile, and go to Settings → Sources to enable sites and add any accounts.
FR_ENCRYPTION_KEY somewhere separate. It encrypts every stored site password and session. Losing it loses them, permanently, and they have to be entered again.The image runs the API and the worker in one process, which is right for a single machine and means the content volume has one writer. It is distroless, so there is no shell inside it: administration is API endpoints and subcommands rather than docker compose exec app sh.
Storage as directories
Named Docker volumes work with no preparation but live under /var/lib/docker/volumes, which is awkward to snapshot. To use ordinary directories instead, 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 exists because Docker creates a missing bind-mount source itself, owned by root, and neither container runs as root. It reads PostgreSQL's user id out of the image instead of assuming it, since that differs between the Debian and Alpine variants.
Setting COMPOSE_FILE in .env means plain docker compose up -d picks up the overlay, so no later command needs an extra flag.
Surviving a power cut
sudo systemctl enable --now docker
Both services use restart: unless-stopped, so a container killed by a power cut comes back while one you deliberately stopped stays stopped. PostgreSQL replays its write-ahead log after an unclean shutdown, and the app waits for the database to report healthy. A scrape interrupted mid-flight needs nothing done about it: the job is reclaimed once its lock goes stale, and fetching a chapter again is safe.
Reaching it by name
Add a local DNS record mapping a name to the machine's address, for example in Pi-hole under Settings → Local DNS Records, then use http://fiction.home:8080. A DNS record maps a name to an address only, so the port stays unless something is listening on 80.
80:80 on the same host will simply fail to start. ss -tlnp 'sport = :80' tells you. In practice the app installs to a home screen or desktop icon and nobody types the address again.
Letting the Android app in
The Android app serves its own pages from https://localhost, so the server has to allow that origin explicitly:
FR_CORS_ORIGINS=https://localhost
This is the single most common reason the Android app reports that it cannot reach a server. Without it, every request fails in a way that is indistinguishable from an unreachable host. Set it first, then check the address, cleartext HTTP and VPN routing.
Listing an origin here also grants it the private-network access the browser requires when a page reaches a machine on your local network. The setting is empty by default and is never a wildcard: this API answers to a profile name in a header, so any page you visited could otherwise read and change your library.
Configuration
Everything is an environment variable prefixed FR_. These are the ones worth knowing; the repository documentation carries the complete list.
| Variable | Default | Meaning |
|---|---|---|
FR_DATABASE_URL | required | PostgreSQL connection string. |
FR_ENCRYPTION_KEY | required | 32 random bytes, base64. Encrypts stored site credentials and sessions. |
FR_LISTEN_ADDR | :8080 | Bind address. |
FR_CONTENT_DIR | /data | Root of the content volume, holding chapter text, covers and images. |
FR_INSTANCE_TOKEN | empty | If set, every request must carry it. A shared secret for the whole instance, not a per-person password. |
FR_CORS_ORIGINS | empty | Extra allowed origins, comma-separated and exact-match. The Android app needs https://localhost. |
FR_DEFAULT_CHECK_INTERVAL_MINUTES | 360 | How often a subscribed story is checked for new chapters. |
FR_DISABLED_SOURCES | empty | Sites a fresh instance starts with switched off. A starting position, not an override: Settings owns it afterwards. |
FR_LOCAL_LIBRARY_ENABLED | false | Turns My Books on and locks it on. Unset, the Settings switch decides. |
FR_LOCAL_LIBRARY_DIR | empty | Names the books folder and makes it read-only in the interface. Unset, Settings owns it. |
FR_LOG_LEVEL | info | debug, info, warn or error. |
FR_METRICS_ENABLED | false | Exposes Prometheus metrics at /metrics. |
Per-site rate limits are not environment variables. They live in the database and are edited in Settings → Sources while the server is running.
My Books on a server
Off by default, so an upgrade does not suddenly grow a nav item and start scanning a directory nobody configured. Turn it on in Settings → My Books, or set FR_LOCAL_LIBRARY_ENABLED=true to force it on.
To keep the books in a host folder 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, for the same reason the host-path script exists.
Backups
A backup has three parts and a database dump on its own is not enough. Restoring only the database gives you a library whose chapters will not 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, stored somewhere safe and separate
deploy/docker/backup.sh wraps all three and is the supported route. Restore in the same order: database, content, then start the app.
A slightly stale content backup restored against a newer database loses only the chapters fetched in between, which the next check downloads again. The reverse is harmless. After restoring, Settings → Archive verification can check the content volume without changing it.
Upgrades
docker compose pull && docker compose up -d
Database migrations run automatically under a lock, so two instances starting at once is safe. Migrations stay backwards compatible for one version, so rolling back one release does not require a restore.
TLS and reverse proxies
TLS on a home network is optional. It does make the app installable without a warning and lets the Android app avoid its cleartext exemption. Caddy is the least work:
fiction.home.arpa {
tls internal
reverse_proxy app:8080
}
proxy_buffering off; and proxy_read_timeout 3600s; or progress bars will never move.Sizing
Measured against roughly 100 subscribed stories and 30,000 chapters:
| Resource | Idle | While downloading |
|---|---|---|
| API memory | ~40 MB | ~80 MB |
| Worker memory | ~60 MB | ~200 MB |
| PostgreSQL | ~150 MB | ~250 MB |
| CPU | negligible | under one core; the bottleneck is the rate limiter |
| Content disk | not applicable | ~30 KB per chapter, so about 1 GB per 30,000 chapters, plus images |
A 4 GB mini PC is comfortable.
Security
Identity is a profile name and nothing else. There are no passwords for readers, which is a deliberate choice for a trusted private network and the reason for the rule below.
FR_INSTANCE_TOKENadds a shared secret to every request. It is a speed bump for a home network, not authentication for a public one.- Stored site credentials are encrypted at rest and are never returned by the API.
- Downloaded HTML is sanitised before it is stored, and addresses are checked before they are fetched.
- Logs redact credentials. Still read a log before pasting it into a public issue.