buildmymcpserver/DEPLOY.md
Marco Sadjadi 8a7ffe673d feat(deploy): production Dockerfiles, compose stack, and runbook
- 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>
2026-05-21 00:37:02 +02:00

291 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
41004999.
**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` + `systemctl` flow with no CI. Step 6 below gives a
> Gitea path **and** a plain `git`/`rsync` path — use whichever matches what is
> actually installed. Confirm with `ssh root@213.239.213.217 "docker ps | grep -i gitea"`.
---
## 1. Cloudflare — create the zone **[you]**
1. Log in to <https://dash.cloudflare.com> (your account — do this yourself).
2. **Add a site**`buildmymcp.com` → pick the **Free** plan.
3. Cloudflare scans existing DNS. **Before changing anything, write down every
record it finds.** If `buildmymcp.com` currently points anywhere, those
records must be recreated here or that service goes dark.
4. 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:
1. Log in to <https://dcc.godaddy.com> (your account — do this yourself).
2. Find `buildmymcp.com`**Domain Settings****Nameservers****Change**.
3. Choose **Enter my own nameservers (custom)**.
4. Replace GoDaddy's nameservers with the two from Cloudflare (step 1.4).
5. Save. Propagation is usually minutes, up to 24 h.
6. Cloudflare's dashboard shows the zone as **Active** when it has taken over.
---
## 4. Server prep **[server]**
```bash
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):
```bash
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]**
```bash
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_URL` password must match `POSTGRES_PASSWORD`.
- `NEXT_PUBLIC_API_URL` is compiled into the web bundle — rebuild `web` if 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?**
```bash
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:3001`
- `api.buildmymcp.com``http://127.0.0.1:4000`
- **Nothing listens** on 80/443: use the optional Traefik in `infra/traefik/`:
```bash
cd /opt/buildmymcp/infra/traefik
cp .env.example .env # set ACME_EMAIL
docker compose --env-file .env -f docker-compose.traefik.yml up -d
```
`dynamic.yml` already 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.com`
> to the dynamic runner ports is a follow-up before opening generated servers to
> the public internet.
---
## 8. Google login — Google Cloud Console **[you]**
1. Log in to <https://console.cloud.google.com> (your account — do this yourself).
2. **Create a project** — e.g. `buildmymcp`.
3. **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.
4. **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`.)
5. Copy the **Client ID** and **Client secret** into `.env.production`:
```
GOOGLE_OAUTH_ID=...apps.googleusercontent.com
GOOGLE_OAUTH_SECRET=...
```
6. Restart the API so it picks them up:
```bash
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 with `ADMIN_EMAIL` /
`ADMIN_PASSWORD`.
- Create a server in the wizard → build reaches `live`.
---
## 10. Operations
```bash
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:**
```bash
$C exec postgres pg_dump -U bmm bmm > backup-$(date +%F).sql
```
---
## Known follow-ups (not blockers, but track them)
1. Per-server subdomain routing (`*.mcp.buildmymcp.com`) for generated MCP
servers — not yet wired (step 7).
2. 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.
3. CI/CD: if a Gitea pipeline is adopted, the deploy step is exactly the step 6
commands.