buildmymcpserver/DEPLOY.md
Marco Sadjadi c016bf237b
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
feat(deploy): nginx vhost serves :443 with a self-signed origin cert
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>
2026-05-21 18:10:22 +02:00

8.8 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 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:

  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 + 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]

  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).