2026-05-21 17:48:57 +02:00
|
|
|
|
# Deploying buildmymcpserver.com
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
End-to-end runbook for the production deploy on the shared Hetzner box.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 0. The target box — what is already there
|
2026-05-21 00:37:59 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
`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, …).
|
2026-05-21 00:37:59 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
Verified house pattern — this deploy follows it exactly:
|
2026-05-21 00:37:59 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
- 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`.
|
2026-05-21 00:37:59 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
**Do not** start anything that binds `:80`/`:443` — the host nginx owns them.
|
2026-05-21 00:37:59 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
### Ports this deploy uses (all verified free on the box)
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
| 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) |
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 1. Cloudflare — create the zone **[you]**
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
### 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 (🟠) |
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
**SSL/TLS mode:** **Full**. (The origin serves HTTP on :80, like the other apps
|
|
|
|
|
|
on this box. Never use **Flexible**.)
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 2. ⚠️ Order of operations
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
> **Recreate ALL existing DNS records in Cloudflare (step 1) BEFORE changing the
|
|
|
|
|
|
> nameservers at GoDaddy (step 3).**
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
Once GoDaddy points at Cloudflare, Cloudflare's zone is authoritative. Anything
|
|
|
|
|
|
not copied into it — MX, TXT, other subdomains — stops resolving. Copy first.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 3. GoDaddy — point the domain at Cloudflare **[you]**
|
|
|
|
|
|
|
|
|
|
|
|
Only after step 1's records exist in Cloudflare:
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 4. Deploy the stack **[server]**
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
The app is installed at `/opt/buildmymcpserver`. To deploy or redeploy by hand:
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-05-21 17:48:57 +02:00
|
|
|
|
cd /opt/buildmymcpserver
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
# First time only: create the env file and fill every CHANGE-ME value
|
2026-05-21 00:37:02 +02:00
|
|
|
|
cp .env.production.example .env.production
|
2026-05-21 17:48:57 +02:00
|
|
|
|
openssl rand -hex 32 # -> SECRETS_ENCRYPTION_KEY
|
|
|
|
|
|
nano .env.production
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
# Build + start (this is exactly what the Gitea pipeline runs)
|
2026-05-21 00:37:02 +02:00
|
|
|
|
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
# First time only: create the database schema
|
2026-05-21 00:37:02 +02:00
|
|
|
|
docker compose --env-file .env.production -f docker-compose.prod.yml \
|
2026-05-21 17:48:57 +02:00
|
|
|
|
exec -T api pnpm --filter @bmm/db push
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
# Status / logs
|
2026-05-21 00:37:02 +02:00
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
`.env.production` essentials:
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
- `SECRETS_ENCRYPTION_KEY` — real 32-byte hex. The API **refuses to boot** in
|
|
|
|
|
|
production on the all-zero placeholder.
|
2026-05-21 17:48:57 +02:00
|
|
|
|
- `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`.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
Health check: `curl http://127.0.0.1:4000/health` → `{"ok":true,...}`.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 5. nginx vhost **[server]**
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
`infra/nginx/buildmymcpserver.conf` is ready. Install it on the host nginx:
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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
|
2026-05-21 00:37:02 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
`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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 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`:
|
2026-05-21 00:37:02 +02:00
|
|
|
|
```
|
|
|
|
|
|
GOOGLE_OAUTH_ID=...apps.googleusercontent.com
|
|
|
|
|
|
GOOGLE_OAUTH_SECRET=...
|
|
|
|
|
|
```
|
2026-05-21 17:48:57 +02:00
|
|
|
|
6. Apply: `docker compose --env-file .env.production -f docker-compose.prod.yml up -d api`.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 7. Verify live
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
- `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`.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 8. Gitea pipeline (continuous deploy)
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
`.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:
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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.
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
---
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 9. Operations
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-05-21 17:48:57 +02:00
|
|
|
|
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
|
2026-05-21 00:37:02 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
**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`
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
---
|
2026-05-21 00:37:02 +02:00
|
|
|
|
|
2026-05-21 17:48:57 +02:00
|
|
|
|
## 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)**.
|