Deployment
Production compose
docker compose -f docker-compose.yml up -d
This is the same file used in Getting Started, without the
dev-only docker-compose.override.yml hot-reload mounts. The frontend
container serves the built React app via Nginx and proxies /api to the
backend — it publishes no host port by default, since it’s meant to sit
behind a reverse proxy.
Reverse proxy
Point a reverse proxy at the frontend container on port 80, terminating
TLS there. With Traefik, for example, add
labels to the frontend service:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.shoebill.rule=Host(`your-domain.example`)"
- "traefik.http.routers.shoebill.entrypoints=web"
- "traefik.http.routers.shoebill-secure.rule=Host(`your-domain.example`)"
- "traefik.http.routers.shoebill-secure.entrypoints=web-secure"
- "traefik.http.routers.shoebill-secure.tls=true"
- "traefik.http.routers.shoebill-secure.tls.certresolver=letsencrypt"
networks: [shoebill_internal, shoebill] # shoebill = external network shared with Traefik
For local testing without any reverse proxy, add a direct port mapping instead:
frontend:
ports:
- "8080:80"
Volumes and persistence
Volume |
Contents |
|---|---|
|
All application data |
|
The Beat scheduler’s persisted schedule, so scheduled tasks don’t reset on restart |
|
Downloaded Piper TTS voice models, cached after first use per language (only used with |
|
Generated podcast episode MP3s |
Back up the Postgres volume; everything else is reproducible from
config + migrations. docker-compose.tts.yml (a separate stack — see
Configuration) has its own tts_voices volume for the same purpose
as piper-voices when TTS_PROVIDER=network.
Note
Backend/Celery containers run as UID 1000, not root. If celerybeat-data
already exists owned by root (e.g. from an older setup, or a manual
docker volume create), the container will fail to write to it on
startup — remove it and let Docker recreate it with the correct ownership:
docker volume rm shoebill_feed_celerybeat-data.
Scaling notes
celery-worker(fetch + default queues) can run with higher concurrency safely — fetching is I/O-bound.celery-worker-process(LLM processing) is deliberately concurrency 1 when backed by a single local Ollama instance/GPU — see Architecture for why. If you’re running Anthropic (or multiple Ollama instances behind a load balancer), raising this concurrency is reasonable.Fetches for identical sources shared across users are automatically deduplicated (see Architecture), so adding more users doesn’t multiply outbound HTTP requests to the same feeds.
celery-worker-podcastruns on its ownpodcastqueue specifically so episode generation (an LLM call plus CPU-bound TTS synthesis and ffmpeg encoding, potentially tens of seconds per episode) never delayscelery-worker-process’s news pipeline.TTS synthesis itself can be moved off
celery-worker-podcastentirely — see “Running TTS synthesis on separate hardware” in Configuration for offloading it to a standalone, optionally GPU-backed container.
Health checks and auto-recovery
postgres, redis, and backend have always had Docker healthchecks. The
four Celery services (celery-worker, celery-worker-process,
celery-worker-podcast, celery-beat) do too. This matters because
restart: unless-stopped alone only restarts a container when it actually
exits — a worker that’s alive but stuck (e.g. it lost its Redis broker
connection and never reconnected) keeps showing Up in docker ps forever,
silently not processing anything, with nothing to notice or recover it.
The three worker services check in via Celery’s own control bus
(celery inspect ping), which is answered by the worker’s control thread
independently of whatever its pool child is currently executing — so the
check stays fast even mid-task, including celery-worker-process sitting
on a long-running Ollama call:
celery-worker-process:
healthcheck:
test: ["CMD-SHELL", "celery -A app.tasks.celery_app inspect ping -d celery@$$(hostname) --timeout 10 2>&1 | grep -q OK"]
interval: 60s
timeout: 15s
retries: 3
start_period: 30s
celery-beat has no equivalent control-ping RPC — there’s no
consumer/task-execution machinery to answer one — so its healthcheck is
weaker: a plain /proc scan confirming the process is still resident,
not that it’s still ticking:
celery-beat:
healthcheck:
test: ["CMD", "python", "-c", "import os,sys; sys.exit(0 if any(b'beat' in open(f'/proc/{p}/cmdline','rb').read() for p in os.listdir('/proc') if p.isdigit()) else 1)"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
Both checks avoid pgrep/ps deliberately — the backend image is
python:3.12-slim with only libpq-dev gcc ffmpeg installed, no
procps — so anything shelled out to in a healthcheck needs to be either
celery itself or plain Python.
Upgrading
Migrations run automatically as part of the backend image’s startup. Pull the new images, then:
docker compose pull
docker compose up -d
If you’re running SHOEBILL_TAG=stable, this picks up whatever the most
recent tagged release published; on latest, it picks up the newest
build from main.
See Development for how migrations are written, if you’re running a fork with local schema changes (which requires building from source instead of using the published images).