Server recon (read-only SSH) showed the box already runs ~8 apps behind a host-level nginx, with Gitea + an Actions runner. The host-networking design collided with contentra on port 3001. - docker-compose.prod.yml: bridge networking + per-app network, house style; api/web/postgres/redis publish to 127.0.0.1 on verified-free ports (4000/4001/5440/6390); only the generator keeps host networking (no listening port, needs the host namespace for runner-port probing). - Drop the Traefik config; the box uses a host nginx. Add a ready nginx vhost in infra/nginx/buildmymcpserver.conf (listen 80, Cloudflare TLS). - Add .gitea/workflows/deploy.yml mirroring the buildmydiscord pipeline. - Narrow the generated-MCP port range to 4400-4900 (clear of screencraft on 4321). - .env.production.example + DEPLOY.md rewritten for buildmymcpserver.com and the real topology. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
8.4 KiB
Markdown
229 lines
8.4 KiB
Markdown
# 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 | `4400–4900` | MCP runner containers (host ports) |
|
||
|
||
---
|
||
|
||
## 1. Cloudflare — create the zone **[you]**
|
||
|
||
1. Log in to <https://dash.cloudflare.com>.
|
||
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 serves HTTP on :80, like the other apps
|
||
on this box. Never use **Flexible**.)
|
||
|
||
---
|
||
|
||
## 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.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 **[server]**
|
||
|
||
`infra/nginx/buildmymcpserver.conf` is ready. Install it on the host nginx:
|
||
|
||
```bash
|
||
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. The vhost is `listen 80` only;
|
||
Cloudflare provides TLS.
|
||
|
||
---
|
||
|
||
## 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:
|
||
```bash
|
||
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
|
||
|
||
```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:<port>` 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)**.
|