This guide covers adding BookWyrm to an existing Docker Compose stack where Caddy is the reverse proxy. Unlike the official BookWyrm docker-compose, this approach does not use certbot and adds a standalone nginx container for static files.
See BookWyrm with Cloudflare Tunnel if you are using the official BookWyrm docker-compose instead.
``` Browser – HTTPS –> Caddy – HTTP –> nginx:80 –> /static/ served directly
```
Caddy must point at nginx, not at Gunicorn directly. If Caddy points at Gunicorn, static file requests return HTML with the wrong MIME type and the site has no styles.
Add to your .env file:
``` BOOKS_SECRET_KEY=<long random string> BOOKS_POSTGRES_PASSWORD=<password> BOOKS_REDIS_ACTIVITY_PASSWORD=<password> BOOKS_REDIS_BROKER_PASSWORD=<password> RESEND_API_KEY=<your email provider API key> ```
Generate a secret key with: ```bash python3 -c “import secrets; print(secrets.token_hex(50))” ```
Create nginx-books/nginx.conf alongside your docker-compose.yml:
```nginx server {
listen 80; client_max_body_size 10M;
resolver 127.0.0.11 valid=30s;
location /static/ {
alias /app/static/;
}
location /images/ {
alias /app/images/;
}
location / {
set $upstream bookwyrm-web:8000;
proxy_pass http://$upstream;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_redirect off;
}
} ```
The resolver 127.0.0.11 line and the set $upstream variable are required. Without them, nginx tries to resolve bookwyrm-web at startup before that container exists and crashes with host not found in upstream. Using a variable forces nginx to resolve the name at request time via Docker's internal DNS.
The service name bookwyrm-web must match exactly what your Gunicorn service is named in docker-compose.yml.
```yaml
bookwyrm-db:
image: postgres:16-alpine
container_name: bookwyrm-db
restart: unless-stopped
networks: [your_net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bookwyrm"]
interval: 10s
timeout: 5s
retries: 5
environment:
POSTGRES_USER: bookwyrm
POSTGRES_PASSWORD: ${BOOKS_POSTGRES_PASSWORD}
POSTGRES_DB: bookwyrm
volumes:
- bookwyrm_postgres:/var/lib/postgresql/data
bookwyrm-redis-activity:
image: redis:7-alpine
container_name: bookwyrm-redis-activity
restart: unless-stopped
networks: [your_net]
command: redis-server --requirepass ${BOOKS_REDIS_ACTIVITY_PASSWORD}
volumes:
- bookwyrm_redis_activity:/data
bookwyrm-redis-broker:
image: redis:7-alpine
container_name: bookwyrm-redis-broker
restart: unless-stopped
networks: [your_net]
command: redis-server --requirepass ${BOOKS_REDIS_BROKER_PASSWORD}
volumes:
- bookwyrm_redis_broker:/data
bookwyrm-web:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
container_name: bookwyrm-web
restart: unless-stopped
networks: [your_net]
command: gunicorn bookwyrm.wsgi:application --bind 0.0.0.0:8000 --workers 3
depends_on:
bookwyrm-db:
condition: service_healthy
environment:
DOMAIN: books.example.com
EMAIL: admin@example.com
SECRET_KEY: ${BOOKS_SECRET_KEY}
PORT: "8000"
USE_HTTPS: "true"
ALLOWED_HOSTS: books.example.com
POSTGRES_HOST: bookwyrm-db
POSTGRES_PORT: "5432"
POSTGRES_USER: bookwyrm
POSTGRES_PASSWORD: ${BOOKS_POSTGRES_PASSWORD}
POSTGRES_DB: bookwyrm
REDIS_ACTIVITY_HOST: bookwyrm-redis-activity
REDIS_ACTIVITY_PORT: "6379"
REDIS_ACTIVITY_PASSWORD: ${BOOKS_REDIS_ACTIVITY_PASSWORD}
REDIS_BROKER_HOST: bookwyrm-redis-broker
REDIS_BROKER_PORT: "6379"
REDIS_BROKER_PASSWORD: ${BOOKS_REDIS_BROKER_PASSWORD}
EMAIL_HOST: smtp.resend.com
EMAIL_PORT: "587"
EMAIL_HOST_USER: resend
EMAIL_HOST_PASSWORD: ${RESEND_API_KEY}
EMAIL_USE_TLS: "true"
DEFAULT_FROM_EMAIL: noreply@example.com
volumes:
- bookwyrm_static:/app/static
- bookwyrm_media:/app/images
bookwyrm-nginx:
image: nginx:alpine
container_name: bookwyrm-nginx
restart: unless-stopped
networks: [your_net]
depends_on:
- bookwyrm-web
volumes:
- ./nginx-books/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- bookwyrm_static:/app/static:ro
- bookwyrm_media:/app/images:ro
bookwyrm-celery-worker:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
container_name: bookwyrm-celery-worker
restart: unless-stopped
networks: [your_net]
command: celery -A celerywyrm worker -l INFO
depends_on:
- bookwyrm-web
environment:
# same env vars as bookwyrm-web above
bookwyrm-celery-beat:
image: ghcr.io/bookwyrm-social/bookwyrm:latest
container_name: bookwyrm-celery-beat
restart: unless-stopped
networks: [your_net]
command: celery -A celerywyrm beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
depends_on:
- bookwyrm-web
environment:
# same env vars as bookwyrm-web above -- EMAIL_HOST is required even for workers
```
Add named volumes to your volumes: section:
```yaml volumes:
bookwyrm_postgres: bookwyrm_redis_activity: bookwyrm_redis_broker: bookwyrm_static: bookwyrm_media:
```
Point Caddy at nginx, not at Gunicorn:
``` books.example.com {
reverse_proxy bookwyrm-nginx:80 {
header_up Host {host}
}
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
}
} ```
Verify that Caddy's live running config actually points to nginx. caddy reload can silently leave the old config in place. Check with:
```bash docker exec <caddy-container> curl -s http://localhost:2019/config/ ```
If the output shows the old Gunicorn upstream, restart the Caddy container fully:
```bash docker compose up -d –force-recreate caddy ```
If nginx crashes immediately with host not found in upstream “bookwyrm-web:8000”, the nginx config is using a static upstream block instead of the resolver approach. See the nginx config section above for the correct config using resolver 127.0.0.11 and set $upstream.
The warning can not modify /etc/nginx/conf.d/default.conf (read-only file system?) is harmless. The config is mounted as :ro intentionally.
Last updated: 2026-06-21