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.
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: 512min Compose. Chromium crashes on large pages without it. - Set
FR_BROWSER_PROFILE_DIRto 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.
| Variable | Default | Meaning |
|---|---|---|
FR_DATABASE_URL | required | PostgreSQL connection string. |
FR_ENCRYPTION_KEY | required | 32 random bytes, base64. Encrypts saved site logins and notification addresses. |
FR_LISTEN_ADDR | :8080 | The address to listen on. |
FR_CONTENT_DIR | /data | Where chapter text, covers and images are stored. |
FR_INSTANCE_TOKEN | empty | If set, every request has to carry it. It's one shared secret for the whole server. |
FR_CORS_ORIGINS | empty | Extra allowed origins, separated by commas. 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 that start switched off on a new server. After that, Settings decides. |
FR_LOCAL_LIBRARY_ENABLED | false | Turns My Books on and keeps it on. If unset, the switch in Settings decides. |
FR_LOCAL_LIBRARY_DIR | empty | Sets the books folder and stops it being changed in Settings. |
FR_TUTORIAL_ENABLED | true | Whether the guided tour starts by itself for a new profile. |
FR_LOG_LEVEL | info | debug, info, warn or error. |
FR_METRICS_ENABLED | false | Exposes 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
}
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:
| Resource | Idle | While downloading |
|---|---|---|
| App memory | about 100 MB | about 280 MB |
| PostgreSQL | about 150 MB | about 250 MB |
| CPU | negligible | under one core |
| Disk | about 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.
FR_INSTANCE_TOKENadds 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.