2026-05-21 00:37:59 +02:00
|
|
|
|
# Deploying the app
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 00:37:59 +02:00
|
|
|
|
## ⚠️ FIRST: confirm the domain name
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
**These are two different domains.** Decide which one before doing anything:
|
|
|
|
|
|
|
|
|
|
|
|
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
Every hostname below uses `buildmymcp.com` as a placeholder. It must match the
|
|
|
|
|
|
domain you put into Cloudflare in step 1.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 00:37:02 +02:00
|
|
|
|
## 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
|
|
|
|
|
|
4100–4999.
|
|
|
|
|
|
|
|
|
|
|
|
**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.
|