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>
8.4 KiB
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 viadocker composeon a bridge network, publishing ports to127.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 on127.0.0.1:3020, SSH on:2222) with an Actions runner labelledhetzner. Apps deploy via a.gitea/workflows/deploy.ymlthat doesgit 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 | 4400–4900 |
MCP runner containers (host ports) |
1. Cloudflare — create the zone [you]
- Log in to https://dash.cloudflare.com.
- Add a site →
buildmymcpserver.com→ Free plan. - Cloudflare scans existing DNS. Write down every record it finds first — anything not recreated in Cloudflare stops resolving after step 3.
- 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:
- Log in to https://dcc.godaddy.com.
buildmymcpserver.com→ Domain Settings → Nameservers → Change.- Enter my own nameservers (custom) → the two from Cloudflare.
- 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_URLpassword must equalPOSTGRES_PASSWORD.NEXT_PUBLIC_API_URLis 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]
- Log in to https://console.cloud.google.com.
- Create a project — e.g.
buildmymcpserver. - APIs & Services → OAuth consent screen: External; app name
BuildMyMCPServer; scopesopenid,userinfo.email,userinfo.profile; add yourself as a test user or Publish. - Credentials → Create credentials → OAuth client ID → Web application.
Authorized redirect URI — exactly:
https://api.buildmymcpserver.com/v1/auth/google/callback - Put the Client ID + secret into
.env.production:GOOGLE_OAUTH_ID=...apps.googleusercontent.com GOOGLE_OAUTH_SECRET=... - 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 viaADMIN_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:
- Create a repo on the box's Gitea (
https://<gitea-host>), e.g.DancingTedDanson/buildmymcpserver. - On the box, add it as a remote and push:
cd /opt/buildmymcpserver git remote add gitea <gitea-ssh-url> git push gitea main - From then on, every push to
mainrebuilds 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
- Generated-server routing. Generated MCP servers get a
http://buildmymcpserver.com:<port>URL on ports 4400–4900. Those ports are not opened on the firewall and not proxied by subdomain — wire*.mcp.buildmymcpserver.comthrough nginx before exposing generated servers publicly. - Magic-link email is printed to the API log, not sent. Wire a real transport (Resend / SES) before relying on email sign-in.
- Cloudflare SSL — once confirmed working on Full, an optional
hardening step is a Cloudflare Origin Certificate + nginx
listen 443 sslfor Full (strict).