Deployment Runbook
This page covers running MinusPod in production: health monitoring, backups, updates, and the common operational issues. For first-time install see Installation; for the complete environment variable reference see Environment Variables.
Prerequisites
Section titled “Prerequisites”- Docker (NVIDIA runtime for GPU image; not required for the CPU image)
- 8 GB RAM minimum; 16 GB+ recommended for
medium/large-v3faster-whisper models or long episodes - CUDA-capable GPU with NVIDIA driver 525 or newer (GPU image only; CPU image runs without one). An older driver surfaces as a CUDA init error on the first transcription. PyTorch ships a CUDA 12.9 build covering Turing (sm_75) through Blackwell (sm_120). Older cards (Maxwell, Pascal, Volta) still transcribe, but PyTorch warns about the architecture and VRAM-based chunk sizing may fall back to defaults.
- An LLM API key (Anthropic, OpenRouter, OpenAI-compatible, or an Ollama instance)
The GPU image is ttlequals0/minuspod:<version> and :latest. The CPU image is ttlequals0/minuspod:<version>-cpu and :cpu. See Installation for variant selection.
Supported topology
Section titled “Supported topology”The supported topology is one MinusPod container with multiple gunicorn workers and one local persistent data volume. SQLite coordinates those workers. Put remote transcription replicas behind their own endpoint for more transcription capacity.
Multiple MinusPod containers or hosts sharing the same SQLite database and data directory are not supported. Redis can share rate limits across workers or hosts, but it does not make the application database, process ownership records, or file operations safe for multiple app containers.
Minimum production environment
Section titled “Minimum production environment”The full reference is in Environment Variables. The three worth setting on day one:
| Variable | Why |
|---|---|
ANTHROPIC_API_KEY (or other provider key) |
Required for ad detection |
BASE_URL |
Public URL embedded in generated RSS feeds |
MINUSPOD_MASTER_PASSPHRASE |
Encrypts provider keys and downloadable backups at rest. Store it separately from the backup. |
Behind a reverse proxy or Cloudflare tunnel, also set MINUSPOD_TRUSTED_PROXY_COUNT=1 (higher for multi-hop chains); see Client IP for login lockout.
Sub-path hosting
Section titled “Sub-path hosting”To serve MinusPod at https://host/minuspod/ instead of the host root, set MINUSPOD_URL_PREFIX=/minuspod and make BASE_URL end with the same prefix, for example BASE_URL=https://host/minuspod. The UI is then at /minuspod/ui/, the API at /minuspod/api/v1/, and feeds at /minuspod/<slug>. One image serves any prefix; there is no rebuild.
The app accepts the prefix whether or not the proxy strips it. Apache, forwarding the prefix unchanged:
ProxyPass /minuspod/ http://127.0.0.1:8000/minuspod/ProxyPassReverse /minuspod/ http://127.0.0.1:8000/minuspod/nginx, forwarding the prefix unchanged (a proxy_pass with a URI part such as http://127.0.0.1:8000/ strips it, which also works):
location /minuspod/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme;}BASE_URLmust include the prefix. Startup logs a warning when it does not, and feed URLs are wrong until it does.- Adding a prefix to an existing install changes feed GUIDs the same way any other
BASE_URLchange does, so subscribers may see the feed as new. - The container healthcheck keeps calling the unprefixed
/api/v1/health, which the app still answers. - The first prefix segment cannot be
ui,api,episodes,health,opmlorlogin.
Health monitoring
Section titled “Health monitoring”# Check health (no auth required)curl http://localhost:8000/api/v1/health
# Expected response{ "status": "healthy", "checks": { "database": true, "storage": true }, "version": "X.Y.Z"}A non-200 response or "status": "degraded" means one of the checks failed; inspect the container logs to find which.
Common issues
Section titled “Common issues”Episode stuck in processing
Section titled “Episode stuck in processing”# Authenticate once; this saves the session and CSRF cookiescurl -sS -c cookies.txt -X POST http://localhost:8000/api/v1/auth/login \ -H 'Content-Type: application/json' \ --data '{"password":"your-application-password"}'
# Check current processing statuscurl -b cookies.txt http://localhost:8000/api/v1/status
# Cancel stuck episodeCSRF=$(awk '$6 == "minuspod_csrf" { print $7 }' cookies.txt)curl -b cookies.txt -H "X-CSRF-Token: $CSRF" -X POST \ http://localhost:8000/api/v1/feeds/{slug}/episodes/{id}/cancel
# Or restart the containerdocker-compose restartCancellation is durable across gunicorn workers. A request can return HTTP 202 while the owner reaches its next cancellation check. The episode resets after that worker stops. If it dies, the next startup or admission pass marks the run interrupted and returns the episode and queue row to pending without using a retry attempt.
Out of memory
Section titled “Out of memory”First distinguish exhausted system RAM from CUDA VRAM using container logs, docker stats minuspod, and nvidia-smi on NVIDIA hosts.
| Backend | Action |
|---|---|
| faster-whisper | Reduce the model size in Settings > Transcription. WHISPER_MODEL seeds unset settings; editing it does not replace a saved choice. |
| Parakeet | The model size is fixed. WHISPER_MODEL=tiny and Whisper compute-type changes have no effect. Free memory used by other workloads, use the CPU image with WHISPER_DEVICE=cpu, or select a remote transcriber. |
| Remote API | Check memory on the transcription server as well as MinusPod. Reduce request/episode concurrency in the Whisper Pool settings when enabled. |
| Any backend | Increase the container memory limit only if the host has capacity. Include audio buffers and any local LLM in the budget. |
See Installation > Parakeet sizing and resource measurement for capacity guidance. Episode duration alone does not establish peak memory.
A worker killed by the kernel’s OOM handler is recovered at the next restart or maintenance pass. A failed full/LLM reprocess preserves the episode’s previous results.
Claude API errors
Section titled “Claude API errors”- Rate limited - Built-in exponential backoff, wait 60s
- Authentication - Check ANTHROPIC_API_KEY is valid
- Timeout - Episode may be too long, try smaller segments
GPU not detected
Section titled “GPU not detected”# Check NVIDIA runtimedocker info | grep -i nvidia
# Check GPU visibility in containerdocker exec minuspod nvidia-smiIf GPU not available, set WHISPER_DEVICE=cpu (slower but works).
Backup and recovery
Section titled “Backup and recovery”Scheduled backups are available under Settings > Data & Security and are off by default. They create private, plaintext SQLite snapshots. The API path below creates an encrypted envelope when a master passphrase is configured.
On-demand SQLite backup (API)
Section titled “On-demand SQLite backup (API)”# Authenticated download via the API. Rate-limited to 6 requests/hour.curl -sS -b cookies.txt \ -o minuspod-backup-$(date +%Y%m%d-%H%M%S).db.enc \ http://localhost:8000/api/v1/system/backupWhen MINUSPOD_MASTER_PASSPHRASE is set, new downloads use the self-contained, authenticated MPBK02 envelope and require the passphrase that created them. Legacy MPBK01 downloads also require an intact database from the same instance for its KDF salt. See the authoritative restore and passphrase rotation procedure. Append ?encrypted=false only when another protection layer covers the plaintext file.
Manual filesystem backup
Section titled “Manual filesystem backup”# Stop the container to flush any in-flight writesdocker-compose stop
# Snapshot the data directory (database, processed audio, status file)tar -czvf minuspod-backup-$(date +%Y%m%d).tar.gz data/
docker-compose startRestore
Section titled “Restore”Stop the service with docker-compose stop, then follow Restore and passphrase rotation. Migrations run on startup and support an older snapshot.
SQLite diagnostics
Section titled “SQLite diagnostics”Settings > Data & Security > Database Stats shows journal mode, busy timeout, database and WAL sizes, free pages, slow statements, long transactions, and commit timing. Instrumentation counters cover the responding worker process and reset when it restarts.
Run a passive checkpoint from that panel or with POST /api/v1/system/database/checkpoint. A successful response reports logPages, checkpointedPages, and durationMs. HTTP 409 with busy: true means active readers prevented a complete checkpoint; it does not corrupt or discard the WAL.
Updating
Section titled “Updating”# GPU imagedocker pull ttlequals0/minuspod:latestdocker-compose up -d
# CPU imagedocker pull ttlequals0/minuspod:cpudocker-compose -f docker-compose.cpu.yml up -dDatabase migrations run automatically on startup. Take a backup (see above) before pulling a major version.
The shipped Compose files allow 360 seconds for container shutdown. Keep MINUSPOD_STOP_GRACE_PERIOD longer than GUNICORN_GRACEFUL_TIMEOUT (330 seconds), which in turn must outlast MINUSPOD_SHUTDOWN_DRAIN_SECONDS (300 seconds). Shutdown stops new admission and gives active processing that drain window before workers exit; interrupted work remains pending for recovery.
Portainer and similar stack managers keep a saved Compose definition. Pulling a repository file or image does not update it. When an upgrade changes settings such as stop_grace_period, copy the change into the live stack before redeploying.
# View all logsdocker logs minuspod
# Follow logsdocker logs -f minuspod
# Last 100 linesdocker logs --tail 100 minuspodResource usage
Section titled “Resource usage”Use the installation sizing tables as the capacity reference. Whisper estimates do not apply to Parakeet or a separate transcription server, and model disk size is not runtime memory use.
Measure cold startup and a complete episode with the resource-check procedure, including other GPU users and local LLMs.
Cloudflare tunnel (optional)
Section titled “Cloudflare tunnel (optional)”For remote access without port forwarding, set TUNNEL_TOKEN and MINUSPOD_TRUSTED_PROXY_COUNT=1 in .env, then run docker-compose --profile tunnel up -d. Setup steps and what breaks without the proxy count are in Remote access and security.
Upload size behind a proxy
Section titled “Upload size behind a proxy”MinusPod’s own limit is the Max upload size setting (default 1024 MB; see Local upload size limit). A proxy in front of it can reject a larger upload first, and that rejection never reaches MinusPod.
- Cloudflare caps request bodies at 100 MB on the Free, Pro and Business plans and returns its own 413. Uploads larger than that need a direct route: a VPN, a LAN address, or an Enterprise plan.
- nginx defaults
client_max_body_sizeto 1 MB. Match it to the MinusPod setting:
location / { client_max_body_size 1g; proxy_request_buffering off;}- Traefik and Caddy do not cap request body size by default.
Security notes
Section titled “Security notes”- Set
MINUSPOD_MASTER_PASSPHRASEto encrypt provider API keys at rest. Without it they sit as plaintext in the SQLite DB. See Security & Storage. - Password, setup token, and public-processing policy: see Security recommendations, Provider admission controls, and Environment Variables.
- Bind
MINUSPOD_BIND_ADDRESS=127.0.0.1when a local proxy, VPN, or wrapper is the only intended entry point. SESSION_COOKIE_SECURE=autofollowsBASE_URL. Set it explicitly only for an unusual proxy setup.- RSS URLs are public by default. Authenticated Feeds adds an existing global credential or a revocable key scoped to one feed. Treat every credential-bearing URL like a password.
- Cloudflare Tunnel or a VPN is recommended for remote access. Direct port-forwarding works but skips Cloudflare’s WAF.
- The compose file runs the container with
no-new-privilegesandcap_drop: ALL, then adds back only the capabilities the entrypoint needs to drop root and fix volume ownership (SETUID,SETGID,CHOWN,DAC_OVERRIDE,FOWNER). Keep thecap_addblock as-is: removing it leaves a barecap_drop: ALL, which crash-loops the container before gunicorn starts. The same block is mirrored indocker-compose.cpu.yml.