- Multi-stage Dockerfiles for web/api/generator (pnpm workspace install, tsx runtime — workspace packages are raw TS, same model as runner-template). - docker-compose.prod.yml: postgres + redis + the three app services. api/generator/web use host networking so the generator's host-port probe is correct and every service shares one address space; api + generator mount the Docker socket. Binds nothing on 80/443 — safe beside other apps. - Optional Traefik reverse proxy in infra/traefik/ (heavily gated — only if the box has no existing proxy). - .env.production.example, .dockerignore, DEPLOY.md (Cloudflare zone, GoDaddy nameserver switch, server deploy, Google Cloud Console OAuth app). - api/generator `start` now runs via tsx; `node dist/index.js` could never resolve the raw-TS workspace imports. All three images verified building clean; the API container boots under tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Deploying buildmymcp.com
End-to-end runbook: domain → DNS → server → live, plus Google login.
This document is written to be executed in order. Steps marked [you] require logging into a third-party account (Cloudflare, GoDaddy, Google) — those must be done by a human; they are not automated here. Steps marked [server] run on the host over SSH.
0. What you are deploying
Five services, defined in docker-compose.prod.yml:
| Service | Role | Network |
|---|---|---|
postgres |
Primary database | bridge → loopback |
redis |
Queue + cache | bridge → loopback |
api |
Fastify control plane + OAuth server (port 4000) | host |
generator |
BullMQ worker — builds & runs generated MCP images | host |
web |
Next.js front end (port 3001) | host |
api and generator mount the Docker socket: the API removes generated
containers, the generator builds and runs them as host siblings on ports
4100–4999.
The stack binds nothing on ports 80/443. It is safe to run alongside the other apps already on the box. A reverse proxy terminates TLS in front of it (step 7).
Server
Per ~/Desktop/DayZ/server_remote/README.md, the box is a Hetzner machine at
213.239.213.217, Linux, root SSH. Other production apps already run on it —
treat ports 80/443 and existing services as occupied until proven otherwise.
Gitea / pipeline — unverified. The request mentioned deploying "via the Gitea pipeline like the other projects." Nothing in the repo or the DayZ server repo confirms Gitea runs on this box, and the DayZ deploy workflow there is a manual
scp+systemctlflow with no CI. Step 6 below gives a Gitea path and a plaingit/rsyncpath — use whichever matches what is actually installed. Confirm withssh root@213.239.213.217 "docker ps | grep -i gitea".
1. Cloudflare — create the zone [you]
- Log in to https://dash.cloudflare.com (your account — do this yourself).
- Add a site →
buildmymcp.com→ pick the Free plan. - Cloudflare scans existing DNS. Before changing anything, write down every
record it finds. If
buildmymcp.comcurrently points anywhere, those records must be recreated here or that service goes dark. - Note the two nameservers Cloudflare assigns (e.g.
xxx.ns.cloudflare.com). You need them in step 3.
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 (🟠) |
If you issue TLS certificates with Let's Encrypt HTTP-01 (step 7, optional Traefik), set the records to DNS only (grey cloud) first, issue the cert, then switch to Proxied. With a Cloudflare Origin Certificate this is not needed — see step 7.
SSL/TLS mode: set to Full (strict) once the origin has a real certificate. Use Full in the interim. Never use Flexible.
2. ⚠️ Order of operations — read before step 3
The single way this deploy can take a site offline:
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 becomes authoritative. Anything not copied into it stops resolving — email (MX), other subdomains, verification TXT records, everything. Copy first, switch second.
3. GoDaddy — point the domain at Cloudflare [you]
Only after step 1's records exist in Cloudflare:
- Log in to https://dcc.godaddy.com (your account — do this yourself).
- Find
buildmymcp.com→ Domain Settings → Nameservers → Change. - Choose Enter my own nameservers (custom).
- Replace GoDaddy's nameservers with the two from Cloudflare (step 1.4).
- Save. Propagation is usually minutes, up to 24 h.
- Cloudflare's dashboard shows the zone as Active when it has taken over.
4. Server prep [server]
ssh root@213.239.213.217
# Docker + compose plugin (skip any that are already present)
docker --version || curl -fsSL https://get.docker.com | sh
docker compose version
# Confirm what already uses 80/443 — do NOT disturb it
ss -ltnp '( sport = :80 or sport = :443 )'
Firewall: only 22, 80, 443 should be open to the internet. The app ports
(3001, 4000, 5440, 6390) must stay private — they are reachable only
over loopback / the proxy.
5. Get the code onto the server [server]
Pick the path that matches the box.
Option A — Gitea pipeline (only if Gitea is actually installed):
push this repo to the Gitea instance, then have its Actions runner (or your
existing deploy pipeline) check out the repo to /opt/buildmymcp and run the
commands in step 6. Mirror whatever the other projects on this box already do.
Option B — plain git / rsync (always works):
mkdir -p /opt/buildmymcp
# from your workstation:
rsync -az --delete --exclude node_modules --exclude .git \
~/Desktop/buildmymcpserver.com/ root@213.239.213.217:/opt/buildmymcp/
6. Configure and start the stack [server]
cd /opt/buildmymcp
# 1. Create the production env file from the template
cp .env.production.example .env.production
openssl rand -hex 32 # paste into SECRETS_ENCRYPTION_KEY
nano .env.production # fill every CHANGE-ME value
# 2. Build and start
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
# 3. Push the database schema (one-time, and after any schema change)
docker compose --env-file .env.production -f docker-compose.prod.yml \
exec api pnpm --filter @bmm/db push
# 4. Watch it come up
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 values that must be correct before first boot:
SECRETS_ENCRYPTION_KEY— real 32-byte hex. The API refuses to boot in production on the all-zero placeholder.DATABASE_URLpassword must matchPOSTGRES_PASSWORD.NEXT_PUBLIC_API_URLis compiled into the web bundle — rebuildwebif you change it (up -d --build web).ANTHROPIC_API_KEY— empty runs mock generation; set it for real Claude output.
Health check: curl http://127.0.0.1:4000/health → {"ok":true,...}.
7. Reverse proxy + TLS [server]
First: is there already a proxy?
ss -ltnp '( sport = :80 or sport = :443 )'
-
Something already listens (the other live apps' proxy): add two vhosts to that proxy and skip the Traefik option:
buildmymcp.com,www.buildmymcp.com→http://127.0.0.1:3001api.buildmymcp.com→http://127.0.0.1:4000
-
Nothing listens on 80/443: use the optional Traefik in
infra/traefik/:cd /opt/buildmymcp/infra/traefik cp .env.example .env # set ACME_EMAIL docker compose --env-file .env -f docker-compose.traefik.yml up -ddynamic.ymlalready routes the three hostnames to the loopback ports.
Let's Encrypt + Cloudflare: HTTP-01 issuance works most reliably while the Cloudflare records are DNS only (grey cloud). Issue the cert, confirm HTTPS, then flip the records to Proxied (orange). Alternative: generate a Cloudflare Origin Certificate (15-year, in the Cloudflare dashboard → SSL/TLS → Origin Server), drop the cert + key into the proxy, and skip ACME entirely. Then set Cloudflare SSL to Full (strict).
Generated MCP servers currently get a
http://RUNNER_HOST:<port>URL and are not yet routed through the proxy by subdomain. Wiring*.mcp.buildmymcp.comto the dynamic runner ports is a follow-up before opening generated servers to the public internet.
8. Google login — Google Cloud Console [you]
- Log in to https://console.cloud.google.com (your account — do this yourself).
- Create a project — e.g.
buildmymcp. - APIs & Services → OAuth consent screen:
- User type: External.
- App name
BuildMyMCP, support email, developer contact. - Scopes:
openid,.../auth/userinfo.email,.../auth/userinfo.profile. - Add yourself as a test user, or Publish the app for public sign-in.
- APIs & Services → Credentials → Create credentials → OAuth client ID:
- Application type: Web application.
- Authorized redirect URI — must be exact:
https://api.buildmymcp.com/v1/auth/google/callback - (For local testing also add
http://localhost:4000/v1/auth/google/callback.)
- Copy the Client ID and Client secret into
.env.production:GOOGLE_OAUTH_ID=...apps.googleusercontent.com GOOGLE_OAUTH_SECRET=... - Restart the API so it picks them up:
docker compose --env-file .env.production -f docker-compose.prod.yml up -d api
The redirect URI is derived from CONTROL_PLANE_PUBLIC_URL. If that is not
https://api.buildmymcp.com, the URI registered in Google must match whatever
it is. When GOOGLE_OAUTH_ID/SECRET are set, the login page shows the
Continue with Google button automatically; when unset it is hidden.
9. Verify live
https://buildmymcp.com— landing page loads over HTTPS.https://api.buildmymcp.com/health—{"ok":true,...}.https://buildmymcp.com/login— magic link and Continue with Google.- Sign in with Google → lands on
/dashboard. https://buildmymcp.com/admin/login— admin login withADMIN_EMAIL/ADMIN_PASSWORD.- Create a server in the wizard → build reaches
live.
10. Operations
cd /opt/buildmymcp
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 (volumes/data preserved)
Rollback: $C down then redeploy the previous commit. Named volumes
(bmm_pg, bmm_redis, bmm_keys, bmm_build_context) survive down, so data
and OAuth signing keys persist. down -v would destroy them — do not use it.
Back up the database before any schema change:
$C exec postgres pg_dump -U bmm bmm > backup-$(date +%F).sql
Known follow-ups (not blockers, but track them)
- Per-server subdomain routing (
*.mcp.buildmymcp.com) for generated MCP servers — not yet wired (step 7). - Magic-link email is printed to the API log in all environments — wire a real transport (Resend / SES) before relying on email sign-in in production.
- CI/CD: if a Gitea pipeline is adopted, the deploy step is exactly the step 6 commands.