Self-Host BookWyrm: Federated Reading Tracker with Full ActivityPub Interop

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.

👁 Views: 202,921 · Unique visitors: 57,427