# 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/` 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 | `4400–4900` | MCP runner containers (host ports) | --- ## 1. Cloudflare — create the zone **[you]** 1. Log in to . 2. **Add a site** → `buildmymcpserver.com` → **Free** 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 . 2. `buildmymcpserver.com` → **Domain 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: ```bash 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: ```bash 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 . 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://`), e.g. `DancingTedDanson/buildmymcpserver`. 2. On the box, add it as a remote and push: ```bash cd /opt/buildmymcpserver git remote add gitea 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 ```bash 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:` URL on ports 4400–4900. 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)**.