Lets Cloudflare run in Full mode (encrypted Cloudflare<->origin) instead of Flexible (plaintext origin hop). Full (strict) is a later swap to a Cloudflare Origin Certificate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.8 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 nginx vhost listens on :443 with a self-signed cert (step 5), so Cloudflare↔origin is encrypted. Never use Flexible. For Full (strict), replace the self-signed cert with a Cloudflare Origin Certificate.
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 + origin cert [server]
The vhost serves :80 and :443; the :443 listener needs an origin certificate. A self-signed cert is enough for Cloudflare Full mode:
mkdir -p /etc/ssl/buildmymcpserver
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/ssl/buildmymcpserver/origin.key \
-out /etc/ssl/buildmymcpserver/origin.crt \
-subj "/CN=buildmymcpserver.com"
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.
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).