Last updated: 2026-03-20 Status: Current Audience: Self-hosters, DevOps engineers, contributors planning production deployment
PRDforge ships as a Docker Compose stack designed for single-host deployment. This document covers what to consider when scaling beyond a single user or deploying to production infrastructure.
- Current Architecture
- Connection Pooling
- Authentication & Access Control
- Reverse Proxy & TLS
- Database Scaling
- WebSocket Scaling
- Monitoring & Observability
- Backup & Recovery
- Production Checklist
PRDforge runs 5 Docker containers on a single host:
| Service | Port | Purpose |
|---|---|---|
postgres |
5432 | PostgreSQL 16 — app data + Prefect-style metadata |
mcp-server |
8080 | MCP tool server (Python, FastMCP, Streamable HTTP) |
python-api |
8088 | FastAPI — REST API for dashboard, chat SSE, WebSocket |
redis |
— | Token replay protection (WS jti SET NX EX), pub/sub |
frontend |
3000 | Next.js — dashboard UI |
The asyncpg connection pool defaults:
asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)Suitable for 1–5 concurrent users on a single host.
For higher concurrency (10+ simultaneous users), add external connection pooling:
Place between application containers and PostgreSQL. Use transaction pooling mode:
[databases]
prdforge = host=postgres port=5432 dbname=prdforge
[pgbouncer]
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20
min_pool_size = 5Increase the asyncpg pool size in both the MCP server and Python API:
# For 10-20 concurrent users
asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=30)Watch for connection saturation:
SELECT count(*), state FROM pg_stat_activity
WHERE datname = 'prdforge'
GROUP BY state;PRDforge includes built-in authentication via Better Auth:
| Feature | Status |
|---|---|
| Email/password authentication | Implemented |
| Closed sign-up (admin creates users) | Implemented |
| First-user bootstrap flow | Implemented |
| Password reset (admin-generated tokens) | Implemented |
| RBAC (5 roles: owner → viewer) | Implemented |
| Session-based auth on all endpoints | Implemented |
| Google OAuth | Config ready, needs Cloud Console credentials |
When no users exist (bootstrap table empty), all endpoints are open — no auth enforced. This allows initial setup via MCP tools or the UI. After the first user is created via /api/auth/setup, authentication is enforced on all protected endpoints.
WebSocket connections use short-lived HMAC tokens:
- Client calls
POST /api/ws-token(authenticated, user_id derived from session) - Server mints a token with 120-second TTL
- Client connects to
ws://host/ws/projects/{slug}?token=... - Server verifies HMAC, checks jti uniqueness via Redis (replay protection)
- Server re-checks project membership before accepting
For non-localhost deployment, place a reverse proxy in front of all services:
server {
listen 443 ssl http2;
server_name prdforge.example.com;
ssl_certificate /etc/ssl/prdforge.crt;
ssl_certificate_key /etc/ssl/prdforge.key;
# Frontend (Next.js)
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
# API
location /api/ {
proxy_pass http://127.0.0.1:8088;
proxy_set_header Host $host;
}
# WebSocket
location /ws/ {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
# MCP Server (restrict to internal/VPN)
location /mcp/ {
proxy_pass http://127.0.0.1:8080;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
deny all;
}
}Key considerations:
- WebSocket endpoints need
UpgradeandConnectionheaders - MCP server should be restricted to trusted networks (Claude Code/Desktop connects directly)
- Set
proxy_read_timeouthigh for WebSocket connections and SSE chat streams
For read-heavy workloads, configure PostgreSQL streaming replication and route read-only MCP tools to a replica:
# Read pool (replica)
read_pool = asyncpg.create_pool(REPLICA_URL, min_size=2, max_size=20)
# Write pool (primary)
write_pool = asyncpg.create_pool(PRIMARY_URL, min_size=2, max_size=10)The token_estimates and section_access_log tables grow continuously. Ensure autovacuum is tuned:
-- Check autovacuum stats
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC;Consider periodic pruning for analytics tables:
-- Keep 90 days of token estimates
DELETE FROM token_estimates WHERE created_at < now() - interval '90 days';
-- Keep 90 days of access logs
DELETE FROM section_access_log WHERE created_at < now() - interval '90 days';All critical indexes are created in the migration files. Verify they exist:
SELECT indexname, tablename FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename;WebSocket connections are held in-memory by the Python API process. Presence broadcasts use a local dict. Redis pub/sub is used for cross-process event broadcasting.
If running multiple API instances behind a load balancer:
- Sticky sessions — required for WebSocket connections. Configure your load balancer to route WebSocket upgrades to the same backend.
- Redis pub/sub — already implemented. All project events are published to Redis channels. Multiple API instances subscribe and broadcast to their local connections.
- Token replay protection — already uses Redis
SET NX EX. Works across multiple instances.
| What | Where |
|---|---|
| Pipeline health | Stats tab in the dashboard |
| Token savings | Stats tab — gauge, charts, by-operation breakdown |
| MCP tool activity | mcp_activity table (last 50 entries in Stats tab) |
| Audit trail | audit_events table, /api/projects/{slug}/audit endpoint |
| Tool | Integration point |
|---|---|
| Prometheus | Add /metrics endpoint (TODO — see TODO.md) |
| Grafana | Connect to PostgreSQL directly for custom dashboards |
| Structured logging | Python API and MCP server use Python logging — configure JSON formatter for log aggregation |
All services expose health checks used by Docker Compose:
# PostgreSQL
pg_isready -U prdforge
# MCP Server
curl -sf http://localhost:8080/mcp/
# Python API
curl -sf http://localhost:8088/health
# Redis
redis-cli ping
# Frontend
curl -sf http://localhost:3000/# Dump to file
docker compose exec postgres pg_dump -U prdforge prdforge > backup_$(date +%Y%m%d).sql
# Restore from file
docker compose exec -i postgres psql -U prdforge prdforge < backup_20260320.sqlPostgreSQL data lives in the pgdata Docker volume:
# Find volume location
docker volume inspect prdforge_pgdata --format '{{ .Mountpoint }}'
# Backup volume (stop services first)
docker compose stop
tar czf pgdata_backup.tar.gz -C /var/lib/docker/volumes/prdforge_pgdata/_data .
docker compose up -dPeriodically verify backups can be restored:
# Create a test database
docker compose exec postgres createdb -U prdforge prdforge_test
# Restore backup into test database
docker compose exec -i postgres psql -U prdforge prdforge_test < backup.sql
# Verify
docker compose exec postgres psql -U prdforge prdforge_test -c "SELECT count(*) FROM projects;"
# Cleanup
docker compose exec postgres dropdb -U prdforge prdforge_testBefore deploying to production, verify:
-
WS_TOKEN_SECRETenv var set (not using default dev value) -
BETTER_AUTH_SECRETenv var set - PostgreSQL password changed from default (
prdforge) - MCP server port (8080) not exposed to public internet
- TLS configured via reverse proxy
- First user created via bootstrap flow
- Docker restart policies set to
unless-stopped(default in compose) - PostgreSQL health check passing
- Redis health check passing
- Log rotation configured for Docker containers
- Database backup schedule in place
- asyncpg pool size tuned for expected concurrency
- PgBouncer deployed if >10 concurrent users expected
- Autovacuum verified on analytics tables
- Redis maxmemory configured if running on constrained host
- Health check endpoints monitored
- Database connection count monitored
- Disk space monitored (PostgreSQL data volume)
- Log aggregation configured (optional)