fix(deploy): rework prod artifacts to match the actual Hetzner box

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>
This commit is contained in:
Marco Sadjadi
2026-05-21 17:48:57 +02:00
parent a54f6218a7
commit c7e6537c64
8 changed files with 313 additions and 337 deletions

352
DEPLOY.md
View File

@@ -1,78 +1,51 @@
# Deploying the app
# Deploying buildmymcpserver.com
End-to-end runbook: domain → DNS → server → live, plus Google login.
End-to-end runbook for the production deploy on the shared Hetzner box.
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.
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.
---
## ⚠️ FIRST: confirm the domain name
## 0. The target box — what is already there
The request said **`buildmymcp.com`**. But the open GoDaddy tab and this
repository are both named **`buildmymcpserver.com`** — that is the domain you
appear to actually own.
`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, …).
**These are two different domains.** Decide which one before doing anything:
Verified house pattern — this deploy follows it exactly:
- If the live domain is **`buildmymcpserver.com`** — do a find-and-replace of
`buildmymcp.com``buildmymcpserver.com` across this file and
`.env.production.example` before you start. `api.buildmymcp.com` becomes
`api.buildmymcpserver.com`, etc.
- If you intend to register and use the shorter **`buildmymcp.com`** — register
it at GoDaddy first, then this file is correct as written.
- 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`.
Every hostname below uses `buildmymcp.com` as a placeholder. It must match the
domain you put into Cloudflare in step 1.
**Do not** start anything that binds `:80`/`:443` — the host nginx owns them.
---
### Ports this deploy uses (all verified free on the box)
## 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"`.
| 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> (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.
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
@@ -82,26 +55,18 @@ treat ports 80/443 and existing services as occupied until proven otherwise.**
| 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**.
**SSL/TLS mode:** **Full**. (The origin serves HTTP on :80, like the other apps
on this box. Never use **Flexible**.)
---
## 2. ⚠️ Order of operations — read before step 3
The single way this deploy can take a site offline:
## 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 becomes authoritative.
Anything not copied into it stops resolving — email (MX), other subdomains,
verification TXT records, everything. Copy first, switch second.
Once GoDaddy points at Cloudflare, Cloudflare's zone is authoritative. Anything
not copied into it — MX, TXT, other subdomains — stops resolving. Copy first.
---
@@ -109,202 +74,155 @@ verification TXT records, everything. Copy first, switch second.
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.
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. Server prep **[server]**
## 4. Deploy the stack **[server]**
The app is installed at `/opt/buildmymcpserver`. To deploy or redeploy by hand:
```bash
ssh root@213.239.213.217
cd /opt/buildmymcpserver
# 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
# First time only: create the env file and fill every CHANGE-ME value
cp .env.production.example .env.production
openssl rand -hex 32 # paste into SECRETS_ENCRYPTION_KEY
nano .env.production # fill every CHANGE-ME value
openssl rand -hex 32 # -> SECRETS_ENCRYPTION_KEY
nano .env.production
# 2. Build and start
# Build + start (this is exactly what the Gitea pipeline runs)
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)
# First time only: create the database schema
docker compose --env-file .env.production -f docker-compose.prod.yml \
exec api pnpm --filter @bmm/db push
exec -T api pnpm --filter @bmm/db push
# 4. Watch it come up
# 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` values that must be correct before first boot:
`.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 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.
- `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,...}`.
---
## 7. Reverse proxy + TLS **[server]**
## 5. nginx vhost **[server]**
**First: is there already a proxy?**
`infra/nginx/buildmymcpserver.conf` is ready. Install it on the host nginx:
```bash
ss -ltnp '( sport = :80 or sport = :443 )'
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
```
- **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.
`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.
---
## 8. Google login — Google Cloud Console **[you]**
## 6. 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`:
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. Restart the API so it picks them up:
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
docker compose --env-file .env.production -f docker-compose.prod.yml up -d api
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.
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.
Until then, deploy by hand with the step 4 command — it is byte-identical to
what the pipeline runs.
---
## 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
## 9. Operations
```bash
cd /opt/buildmymcp
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 (volumes/data preserved)
$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` 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.
**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 database before any schema change:**
```bash
$C exec postgres pg_dump -U bmm bmm > backup-$(date +%F).sql
```
**Back up the DB before a schema change:**
`$C exec -T postgres pg_dump -U bmm bmm > backup-$(date +%F).sql`
---
## Known follow-ups (not blockers, but track them)
## Known follow-ups
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.
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)**.