buildmymcpserver/DEPLOY.md
Marco Sadjadi c7e6537c64 fix(deploy): rework prod artifacts to match the actual Hetzner box
Server recon (read-only SSH) showed the box already runs ~8 apps behind a
host-level nginx, with Gitea + an Actions runner. The host-networking
design collided with contentra on port 3001.

- docker-compose.prod.yml: bridge networking + per-app network, house
  style; api/web/postgres/redis publish to 127.0.0.1 on verified-free
  ports (4000/4001/5440/6390); only the generator keeps host networking
  (no listening port, needs the host namespace for runner-port probing).
- Drop the Traefik config; the box uses a host nginx. Add a ready nginx
  vhost in infra/nginx/buildmymcpserver.conf (listen 80, Cloudflare TLS).
- Add .gitea/workflows/deploy.yml mirroring the buildmydiscord pipeline.
- Narrow the generated-MCP port range to 4400-4900 (clear of screencraft
  on 4321).
- .env.production.example + DEPLOY.md rewritten for buildmymcpserver.com
  and the real topology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:57 +02:00

8.4 KiB
Raw Blame History

Deploying buildmymcpserver.com

End-to-end runbook for the production deploy on the shared Hetzner box.

Steps marked [you] require logging into a third-party account (Cloudflare, GoDaddy, Google) — those must be done by a human. Steps marked [server] run on the box over SSH.


0. The target box — what is already there

213.239.213.217 — Debian 12, Docker 29 + Compose v5, 62 GB RAM, 151 GB free. It is a shared box running ~8 other production apps (buildmydiscord, savesphere, ava, contentra, screencraft, helixmind, prishtina-bot, …).

Verified house pattern — this deploy follows it exactly:

  • Each app lives in /opt/<app> and runs via docker compose on a bridge network, publishing ports to 127.0.0.1.
  • A host-level nginx owns :80 / :443. Each app has a vhost in /etc/nginx/sites-enabled/ that proxies its domain to its loopback port.
  • TLS is terminated by Cloudflare (proxied DNS); origins serve plain HTTP.
  • Gitea runs on the box (gitea-gitea-1, web on 127.0.0.1:3020, SSH on :2222) with an Actions runner labelled hetzner. Apps deploy via a .gitea/workflows/deploy.yml that does git fetch + docker compose up.

Do not start anything that binds :80/:443 — the host nginx owns them.

Ports this deploy uses (all verified free on the box)

Service Host bind Notes
web 127.0.0.1:4001 nginx → buildmymcpserver.com
api 127.0.0.1:4000 nginx → api.buildmymcpserver.com
postgres 127.0.0.1:5440 loopback only
redis 127.0.0.1:6390 loopback only
generated 44004900 MCP runner containers (host ports)

1. Cloudflare — create the zone [you]

  1. Log in to https://dash.cloudflare.com.
  2. Add a sitebuildmymcpserver.comFree plan.
  3. Cloudflare scans existing DNS. Write down every record it finds first — anything not recreated in Cloudflare stops resolving after step 3.
  4. Note the two nameservers Cloudflare assigns.

DNS records to create in Cloudflare

Type Name Content Proxy
A @ 213.239.213.217 Proxied (🟠)
A api 213.239.213.217 Proxied (🟠)
A www 213.239.213.217 Proxied (🟠)

SSL/TLS mode: Full. (The origin serves HTTP on :80, like the other apps on this box. Never use Flexible.)


2. ⚠️ Order of operations

Recreate ALL existing DNS records in Cloudflare (step 1) BEFORE changing the nameservers at GoDaddy (step 3).

Once GoDaddy points at Cloudflare, Cloudflare's zone is authoritative. Anything not copied into it — MX, TXT, other subdomains — stops resolving. Copy first.


3. GoDaddy — point the domain at Cloudflare [you]

Only after step 1's records exist in Cloudflare:

  1. Log in to https://dcc.godaddy.com.
  2. buildmymcpserver.comDomain Settings → Nameservers → Change.
  3. Enter my own nameservers (custom) → the two from Cloudflare.
  4. Save. Propagation: minutes, up to 24 h. Cloudflare shows the zone Active when it has taken over.

4. Deploy the stack [server]

The app is installed at /opt/buildmymcpserver. To deploy or redeploy by hand:

cd /opt/buildmymcpserver

# First time only: create the env file and fill every CHANGE-ME value
cp .env.production.example .env.production
openssl rand -hex 32       # -> SECRETS_ENCRYPTION_KEY
nano .env.production

# Build + start (this is exactly what the Gitea pipeline runs)
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build

# First time only: create the database schema
docker compose --env-file .env.production -f docker-compose.prod.yml \
  exec -T api pnpm --filter @bmm/db push

# Status / logs
docker compose --env-file .env.production -f docker-compose.prod.yml ps
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f api

.env.production essentials:

  • SECRETS_ENCRYPTION_KEY — real 32-byte hex. The API refuses to boot in production on the all-zero placeholder.
  • DATABASE_URL password must equal POSTGRES_PASSWORD.
  • NEXT_PUBLIC_API_URL is compiled into the web bundle — after changing it, rebuild web: ... up -d --build web.

Health check: curl http://127.0.0.1:4000/health{"ok":true,...}.


5. nginx vhost [server]

infra/nginx/buildmymcpserver.conf is ready. Install it on the host nginx:

cp /opt/buildmymcpserver/infra/nginx/buildmymcpserver.conf \
   /etc/nginx/sites-available/buildmymcpserver
ln -sf /etc/nginx/sites-available/buildmymcpserver \
   /etc/nginx/sites-enabled/buildmymcpserver
nginx -t && systemctl reload nginx

nginx -t must pass before the reload — a reload of a bad config is rejected, so the other live sites are never at risk. The vhost is listen 80 only; Cloudflare provides TLS.


6. Google login — Google Cloud Console [you]

  1. Log in to https://console.cloud.google.com.
  2. Create a project — e.g. buildmymcpserver.
  3. APIs & Services → OAuth consent screen: External; app name BuildMyMCPServer; scopes openid, userinfo.email, userinfo.profile; add yourself as a test user or Publish.
  4. Credentials → Create credentials → OAuth client ID → Web application. Authorized redirect URI — exactly:
    https://api.buildmymcpserver.com/v1/auth/google/callback
    
  5. Put the Client ID + secret into .env.production:
    GOOGLE_OAUTH_ID=...apps.googleusercontent.com
    GOOGLE_OAUTH_SECRET=...
    
  6. Apply: docker compose --env-file .env.production -f docker-compose.prod.yml up -d api.

When GOOGLE_OAUTH_ID/SECRET are set the Continue with Google button appears automatically; when unset it stays hidden and magic-link login is used.


7. Verify live

  • https://buildmymcpserver.com — landing page over HTTPS.
  • https://api.buildmymcpserver.com/health{"ok":true,...}.
  • /login — magic link, plus Continue with Google once step 6 is done.
  • /admin/login — admin via ADMIN_EMAIL / ADMIN_PASSWORD.
  • Wizard → create a server → build reaches live.

8. Gitea pipeline (continuous deploy)

.gitea/workflows/deploy.yml is in the repo and mirrors the buildmydiscord pattern (runs-on: hetzner, git fetch + docker compose up -d --build + health check). To activate it:

  1. Create a repo on the box's Gitea (https://<gitea-host>), e.g. DancingTedDanson/buildmymcpserver.
  2. On the box, add it as a remote and push:
    cd /opt/buildmymcpserver
    git remote add gitea <gitea-ssh-url>
    git push gitea main
    
  3. From then on, every push to main rebuilds and redeploys automatically.

Until then, deploy by hand with the step 4 command — it is byte-identical to what the pipeline runs.


9. Operations

cd /opt/buildmymcpserver
C="docker compose --env-file .env.production -f docker-compose.prod.yml"

$C ps                    # status
$C logs -f generator     # tail a service
$C up -d --build         # redeploy after a code change
$C up -d --build web     # rebuild only web (e.g. NEXT_PUBLIC_API_URL changed)
$C restart api           # restart one service
$C down                  # stop the stack — named volumes (data) survive

Rollback: $C down, check out the previous commit, redeploy. Volumes bmm_pg / bmm_redis / bmm_keys / bmm_build_context survive down. down -v destroys them — never use it.

Back up the DB before a schema change: $C exec -T postgres pg_dump -U bmm bmm > backup-$(date +%F).sql


Known follow-ups

  1. Generated-server routing. Generated MCP servers get a http://buildmymcpserver.com:<port> URL on ports 44004900. Those ports are not opened on the firewall and not proxied by subdomain — wire *.mcp.buildmymcpserver.com through nginx before exposing generated servers publicly.
  2. Magic-link email is printed to the API log, not sent. Wire a real transport (Resend / SES) before relying on email sign-in.
  3. Cloudflare SSL — once confirmed working on Full, an optional hardening step is a Cloudflare Origin Certificate + nginx listen 443 ssl for Full (strict).