All checks were successful
Deploy to Production / deploy (push) Successful in 1m38s
Two overlapping bugs were killing OAuth discovery for every external MCP client (Claude Desktop, Cursor, etc.): 1. worker.ts injected PUBLIC_URL=http://<RUNNER_HOST>:<port> into the runner container even when MCP_DOMAIN was set. Result: the runner's /.well-known/oauth-protected-resource advertised an unreachable URL and the WWW-Authenticate header pointed at a non-HTTPS loopback address. Claude Desktop refused to follow the discovery chain. Now derives PUBLIC_URL from the same computePublicUrl() helper that builds the user-visible URL stored in mcp_servers.public_url, so the container's self-reported resource matches its actual route. 2. docker-compose.prod.yml never mounted /opt/buildmymcpserver/runner-map into the api / generator containers. The .conf snippet written by the generator landed in an ephemeral container path; the host inotify watcher saw an empty directory and produced an empty runner-map.combined. Result: nginx 404'd every /<slug>/* request, the runner was unreachable from the public domain, and OAuth discovery couldn't even begin. Mount added to both services. Existing weather server has the wrong PUBLIC_URL baked in and must be recreated after deploy. No customers yet. export computePublicUrl from deploy.ts so worker.ts can call it.
132 lines
4.2 KiB
YAML
132 lines
4.2 KiB
YAML
# Production stack for buildmymcpserver.com — Linux host only.
|
|
#
|
|
# Run with:
|
|
# docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
|
#
|
|
# Topology — matches the house pattern on the shared Hetzner box:
|
|
# - Bridge networking + per-app network, like every other app on the box.
|
|
# - api / web / postgres / redis publish to 127.0.0.1 only. The host nginx
|
|
# reverse-proxies the public domains to these loopback ports. Nothing here
|
|
# binds 0.0.0.0:80/443 — the box's existing nginx owns those.
|
|
# - generator uses host networking: it has no listening port of its own (no
|
|
# collision risk) and it must allocate + probe host ports for the MCP
|
|
# containers it spawns, which is only correct in the host namespace.
|
|
# - api + generator mount the Docker socket: the API removes generated
|
|
# containers, the generator builds + runs them as host siblings.
|
|
#
|
|
# Ports are picked to not collide with the other apps already on this box.
|
|
|
|
name: buildmymcpserver
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
container_name: bmm-postgres
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-bmm}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.production}
|
|
POSTGRES_DB: ${POSTGRES_DB:-bmm}
|
|
ports:
|
|
- "127.0.0.1:${POSTGRES_PORT:-5440}:5432"
|
|
volumes:
|
|
- bmm_pg:/var/lib/postgresql/data
|
|
networks: [bmm-network]
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmm} -d ${POSTGRES_DB:-bmm}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 20
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: bmm-redis
|
|
restart: unless-stopped
|
|
command: ["redis-server", "--appendonly", "yes"]
|
|
ports:
|
|
- "127.0.0.1:${REDIS_PORT:-6390}:6379"
|
|
volumes:
|
|
- bmm_redis:/data
|
|
networks: [bmm-network]
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 20
|
|
|
|
api:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/api/Dockerfile
|
|
container_name: bmm-api
|
|
restart: unless-stopped
|
|
env_file: .env.production
|
|
ports:
|
|
- "127.0.0.1:${API_PORT:-4000}:4000"
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- bmm_keys:/app/apps/api/keys
|
|
# Per-runner nginx snippets — written by the generator, deleted by the
|
|
# api when a server is removed. The host-side systemd watcher combines
|
|
# them into runner-map.combined + reloads nginx. Without this mount the
|
|
# snippet files land in an ephemeral container path and the path-routed
|
|
# /<slug>/* endpoints return 404 from nginx — breaking OAuth discovery
|
|
# for every external MCP client.
|
|
- /opt/buildmymcpserver/runner-map:/var/runner-map
|
|
networks: [bmm-network]
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
|
|
web:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/web/Dockerfile
|
|
args:
|
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
|
|
container_name: bmm-web
|
|
restart: unless-stopped
|
|
env_file: .env.production
|
|
ports:
|
|
- "127.0.0.1:${WEB_PORT:-4001}:3001"
|
|
networks: [bmm-network]
|
|
depends_on:
|
|
- api
|
|
|
|
generator:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/generator/Dockerfile
|
|
container_name: bmm-generator
|
|
restart: unless-stopped
|
|
network_mode: host
|
|
env_file: .env.production
|
|
environment:
|
|
# Host networking — reach the DBs via their published loopback ports
|
|
# instead of the compose-network service names.
|
|
DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm}
|
|
REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390}
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- bmm_build_context:/app/build-context
|
|
# Same runner-map mount as the api — generator drops the snippet on
|
|
# deploy, watcher concatenates, nginx reloads.
|
|
- /opt/buildmymcpserver/runner-map:/var/runner-map
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
|
|
networks:
|
|
bmm-network:
|
|
driver: bridge
|
|
|
|
volumes:
|
|
bmm_pg:
|
|
bmm_redis:
|
|
bmm_keys:
|
|
bmm_build_context:
|