Goodreads is a graveyard. Amazon bought it in 2013, stripped out the API, let the design rot, and turned it into an ad-delivery mechanism wrapped around a reading list. If you care about your reading data — or about not handing your literary tastes to a surveillance capitalist — you need an exit plan.
BookWyrm is that exit plan. It’s an open-source, federated reading tracker built on ActivityPub, the same protocol powering Mastodon, Pixelfed, and PeerTube. Your instance talks to other BookWyrm instances, and your users’ profiles are addressable from anywhere in the Fediverse. You own the data. You control the moderation. You can import your Goodreads history and never look back.
Official repository: https://github.com/bookwyrm-social/bookwyrm
This article covers a production-grade deployment: Docker Compose, nginx reverse proxy, automated TLS, and the federation mechanics that actually matter.
What You’re Getting Into
BookWyrm is a Django application backed by PostgreSQL, Redis, and Celery for async task processing. The ActivityPub layer handles federation — follows, reviews, shelves, and reading updates all become federated objects that other instances can consume.
The stack:
- Django — application server
- PostgreSQL — primary datastore
- Redis — cache, session store, and Celery broker
- Celery — background workers (federation delivery, imports, search indexing)
- nginx — reverse proxy and static file serving
It’s not lightweight. Expect to need at least 2GB RAM for a single-user instance under load, 4GB if you’re running a small community. A 2-vCPU VPS handles it fine.
Prerequisites
- A server running Debian 12 or Ubuntu 22.04+
- A domain name with DNS pointing at your server (e.g.,
books.example.com) - Docker and Docker Compose v2 installed
- Ports 80 and 443 open in your firewall
I’m assuming you already have Docker. If not:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Log out and back in after that.
Directory Layout
Keep everything under one directory. Separation of concerns matters when you’re doing backups.
mkdir -p /opt/bookwyrm/{data/media,data/static,nginx/conf.d,nginx/certs}
cd /opt/bookwyrm
The structure will look like:
/opt/bookwyrm/
├── docker-compose.yml
├── .env
├── nginx/
│ ├── conf.d/
│ │ └── bookwyrm.conf
│ └── certs/ # managed by certbot
└── data/
├── media/ # uploaded images, book covers
└── static/ # collected Django statics
The Environment File
Everything sensitive goes into .env. BookWyrm reads this directly.
# /opt/bookwyrm/.env
# --- Core ---
DOMAIN=books.example.com
[email protected]
# Use "true" to allow registrations, "false" to require an invite code
ALLOW_REGISTRATION=false
# Set to "true" only while bootstrapping; flip to "false" after first run
DEBUG=false
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=replace_this_with_a_real_secret_key
# Protocol — always https in production
PROTOCOL=https
# --- PostgreSQL ---
POSTGRES_PASSWORD=strong_db_password_here
POSTGRES_USER=bookwyrm
POSTGRES_DB=bookwyrm
POSTGRES_HOST=db
# --- Redis ---
REDIS_ACTIVITY_HOST=redis_activity
REDIS_BROKER_HOST=redis_broker
REDIS_PASSWORD=strong_redis_password_here
# --- Email (for notifications and password resets) ---
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
[email protected]
EMAIL_HOST_PASSWORD=smtp_password
EMAIL_USE_TLS=true
EMAIL_SENDER=BookWyrm <[email protected]>
# --- Celery ---
CELERY_WORKER_CONCURRENCY=2
# --- Storage ---
# Leave blank to use local filesystem (data/media)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_STORAGE_BUCKET_NAME=
AWS_S3_REGION_NAME=
AWS_S3_CUSTOM_DOMAIN=
Lock it down:
chmod 600 /opt/bookwyrm/.env
Docker Compose
This is the heart of the deployment. Two Redis instances are intentional — one handles the ActivityPub activity queue, the other is the Celery broker. Mixing them causes hard-to-debug race conditions under load.
# /opt/bookwyrm/docker-compose.yml
version: "3.8"
x-bookwyrm-env: &bookwyrm-env
env_file: .env
services:
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/letsencrypt:ro
- ./data/static:/app/static:ro
- ./data/media:/app/images:ro
# certbot webroot challenge directory
- certbot_webroot:/var/www/certbot:ro
depends_on:
- web
certbot:
image: certbot/certbot:latest
volumes:
- ./nginx/certs:/etc/letsencrypt
- certbot_webroot:/var/www/certbot
# Only runs when explicitly invoked — not a long-running service
profiles: ["certbot"]
entrypoint: >
certbot certonly --webroot
-w /var/www/certbot
-d ${DOMAIN}
--email ${EMAIL}
--agree-tos --non-interactive
web:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
restart: unless-stopped
<<: *bookwyrm-env
command: gunicorn bookwyrm.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60
volumes:
- ./data/media:/app/images
- ./data/static:/app/static
depends_on:
db:
condition: service_healthy
redis_activity:
condition: service_started
redis_broker:
condition: service_started
celery_worker:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
restart: unless-stopped
<<: *bookwyrm-env
command: celery -A celerywyrm worker --loglevel=info -Q high_priority,medium_priority,low_priority,imports
volumes:
- ./data/media:/app/images
depends_on:
- db
- redis_broker
celery_beat:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
restart: unless-stopped
<<: *bookwyrm-env
command: celery -A celerywyrm beat --loglevel=info
depends_on:
- db
- redis_broker
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis_activity:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_activity_data:/data
redis_broker:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_broker_data:/data
volumes:
postgres_data:
redis_activity_data:
redis_broker_data:
certbot_webroot:
nginx Configuration
Start with an HTTP-only config for the initial cert issuance, then upgrade.
# /opt/bookwyrm/nginx/conf.d/bookwyrm.conf
# Redirect HTTP to HTTPS — except for ACME challenges
server {
listen 80;
server_name books.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name books.example.com;
ssl_certificate /etc/letsencrypt/live/books.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/books.example.com/privkey.pem;
# Modern TLS config — no TLS 1.0/1.1
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
# HSTS — commit to https-only for 1 year
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20M;
# Django static files served directly by nginx
location /static/ {
alias /app/static/;
expires 7d;
add_header Cache-Control "public, immutable";
}
# User-uploaded media (book covers, avatars)
location /images/ {
alias /app/images/;
expires 30d;
add_header Cache-Control "public";
}
# Everything else goes to Django
location / {
proxy_pass http://web:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ActivityPub responses can be large JSON objects
proxy_read_timeout 60s;
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 8 8k;
}
}
First Boot Sequence
Get the cert first, before starting the full stack.
cd /opt/bookwyrm
# Start only nginx so certbot can reach the webroot
docker compose up -d nginx
# Issue the certificate
docker compose --profile certbot run --rm certbot
# Verify it worked
ls nginx/certs/live/books.example.com/
# Bring everything up
docker compose up -d
# Run Django migrations
docker compose exec web python manage.py migrate
# Collect static files
docker compose exec web python manage.py collectstatic --no-input
# Create the superuser
docker compose exec web python manage.py createsuperuser
Hit https://books.example.com — you should see the BookWyrm landing page.
Federation: How It Actually Works
BookWyrm implements a subset of ActivityPub. Each user account becomes an Actor — a JSON-LD document hosted at a well-known URL. When your user follows someone on books.another.example, your instance sends a Follow activity to that instance’s inbox. Their instance accepts or rejects it, then delivers future activities (reviews, shelf updates, status messages) to your inbox.
The key URLs to know:
| Path | Purpose |
|---|---|
/.well-known/webfinger |
Discovery — maps @user@domain to Actor URL |
/.well-known/nodeinfo |
Instance metadata (software name, version, user count) |
/user/<handle> |
Actor document |
/user/<handle>/inbox |
Receives activities from remote instances |
/user/<handle>/outbox |
Paginated history of activities |
/user/<handle>/followers |
Followers collection |
/user/<handle>/following |
Following collection |
BookWyrm uses HTTP Signatures to authenticate every federated request. The sending instance signs the HTTP headers with the actor’s private key; the receiving instance verifies against the public key embedded in the actor document. If signature verification fails, the activity is dropped silently.
Interop with Mastodon and Other AP Servers
BookWyrm is a compliant ActivityPub server, so a Mastodon user can follow a BookWyrm user and see their reading updates in their timeline. The reverse is also true — BookWyrm users can follow Mastodon accounts.
Where it gets interesting: BookWyrm sends rich, book-specific activities (ReadingStatus, Review, Quotation) that Mastodon doesn’t understand natively. Mastodon falls back to rendering them as plain Note objects with the text content. So a review written on your BookWyrm instance shows up in a Mastodon follower’s feed as a text post — functional, but it loses the structured book metadata.
BookWyrm handles incoming Mastodon Note activities correctly — they show up as status posts in the local feed.
Blocking and Defederation
From the admin panel (/settings/federation/), you can:
- Block an instance — rejects all incoming activities from that domain and stops delivering to it
- Allow-list mode — only federate with explicitly approved instances (useful for private/family instances)
- Moderation reports — users can report remote content, which surfaces to your admin queue
Set ALLOW_REGISTRATION=false in .env unless you explicitly want open signups. Use invite codes for controlled growth.
Gotchas
HTTP Signatures are unforgiving. If your server’s clock drifts more than about 30 seconds, remote instances will reject your activities with a 401 or silently drop them. Run NTP. On Debian/Ubuntu: systemctl enable --now systemd-timesyncd and verify with timedatectl show.
The celery_beat container must be a singleton. Running multiple instances of celery_beat causes duplicate scheduled tasks — you’ll see duplicate federation deliveries, double emails, and confusing log noise. The Compose config above only spins one, but if you ever scale with Docker Swarm or Kubernetes, pin celery_beat to a single replica with no auto-scaling.
Media volume backup is mandatory. Book covers, user avatars, and any uploaded images live in data/media. The database has pointers to these files; if you lose the volume and restore only the database, every image in the UI breaks. Back up both together.
DEBUG=true leaks your secret key. The Django debug toolbar in DEBUG mode exposes environment variables in error pages. Only use it locally, never on a public-facing instance. The .env setup above has DEBUG=false for exactly this reason.
Webfinger must return the right content type. Some AP implementations are picky — they expect application/jrd+json for WebFinger responses. BookWyrm handles this correctly, but if you put a caching layer in front (Cloudflare, Varnish), make sure it doesn’t mangle Content-Type headers or strip Accept headers from requests. ActivityPub content negotiation depends on the Accept: application/activity+json header to switch between HTML and JSON responses for the same URL.
Postgres autovacuum on activitypub_* tables. The activity tables grow fast on an active federated instance. The default autovacuum settings are conservative. After a few weeks you may see table bloat. Add this to your Postgres init or run it manually:
ALTER TABLE bookwyrm_activitypubobject SET (autovacuum_vacuum_scale_factor = 0.01);
Production Checklist
Automated certificate renewal. Let’s Encrypt certs expire in 90 days. Add a cron job to renew:
# /etc/cron.d/certbot-renew
0 3 * * * root cd /opt/bookwyrm && docker compose --profile certbot run --rm certbot renew --quiet && docker compose exec nginx nginx -s reload
Database backups with pg_dump. Don’t trust Docker volume snapshots alone:
#!/bin/bash
# /opt/bookwyrm/scripts/backup.sh
set -euo pipefail
BACKUP_DIR=/opt/bookwyrm/backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# Dump the database
docker compose -f /opt/bookwyrm/docker-compose.yml exec -T db \
pg_dump -U bookwyrm bookwyrm | gzip > "$BACKUP_DIR/db_$TIMESTAMP.sql.gz"
# Sync media files (rsync to remote, or just archive locally)
tar -czf "$BACKUP_DIR/media_$TIMESTAMP.tar.gz" -C /opt/bookwyrm data/media
# Prune old backups — keep 14 days
find "$BACKUP_DIR" -name "*.gz" -mtime +14 -delete
Tune Gunicorn workers. The formula 2 * CPU_cores + 1 is a starting point. For a 2-core box that’s 5 workers. If you’re CPU-bound on book imports or cover fetching, the Celery worker concurrency setting matters more. Profile under load before tuning blindly.
Rate limiting on the inbox endpoint. Malicious instances can flood your inbox with garbage activities. Add rate limiting in nginx:
# Add to the http block in your main nginx.conf
limit_req_zone $binary_remote_addr zone=ap_inbox:10m rate=30r/m;
# Then in your server block, on the inbox location:
location ~ ^/user/.*/inbox$ {
limit_req zone=ap_inbox burst=10 nodelay;
proxy_pass http://web:8000;
# ... rest of proxy settings
}
Log rotation. Docker’s default json-file logging driver will fill your disk on a busy instance. Either configure log rotation in the Docker daemon or switch to journald:
// /etc/docker/daemon.json
{
"log-driver": "local",
"log-opts": {
"max-size": "20m",
"max-file": "5"
}
}
Importing Your Goodreads Data
Once the instance is up, click your profile → Import. BookWyrm accepts Goodreads CSV exports directly. Export from Goodreads: My Books → Import and export → Export Library.
The import runs as a background Celery task. For large libraries (500+ books) it takes a few minutes. Watch progress at /import/ on your instance.
Book metadata gets matched against OpenLibrary — BookWyrm’s primary data source. If a book isn’t found there, it falls back to a manual entry stub that you can fill in later.
Keeping BookWyrm Updated
The project ships new releases regularly. Update procedure:
cd /opt/bookwyrm
# Pull the new image
docker compose pull
# Run any new migrations before restarting the app
docker compose run --rm web python manage.py migrate
# Restart
docker compose up -d
# Verify the new version is running
docker compose exec web python manage.py version # or check /about on the instance
Pin to a specific tag in production if you need change control:
# Replace "latest" with a specific release tag, e.g.:
image: ghcr.io/bookwyrm-social/bookwyrm:0.7.4
Check the GitHub releases page for changelogs before updating — migrations are occasionally irreversible.
Where to Go From Here
Federation is the killer feature here. Once you’re running, find a few friendly instances and start following people. The BookWyrm wiki maintains a list of public instances. Following across instances works exactly like Mastodon — @[email protected] is all you need.
If you’re running a private instance for a reading group or a library, flip ALLOW_REGISTRATION=false, generate invite codes from the admin panel, and disable federation entirely if you want a fully closed setup. The FEDERATE=false env var turns off all outbound and inbound ActivityPub processing.
Your reading data stays yours. Your domain, your backups, your moderation policies. That’s the whole point.