buildmymcpserver/DEPLOY.md
Marco Sadjadi c016bf237b
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
feat(deploy): nginx vhost serves :443 with a self-signed origin cert
Lets Cloudflare run in Full mode (encrypted Cloudflare<->origin) instead
of Flexible (plaintext origin hop). Full (strict) is a later swap to a
Cloudflare Origin Certificate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:10:22 +02:00

237 lines
8.8 KiB
Markdown
Raw Permalink 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 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 | `44004900` | 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 nginx vhost listens on :443 with a
self-signed cert (step 5), so Cloudflare↔origin is encrypted. Never use
**Flexible**. For **Full (strict)**, replace the self-signed cert with a
Cloudflare Origin Certificate.
---
## 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 + origin cert **[server]**
The vhost serves :80 and :443; the :443 listener needs an origin certificate.
A self-signed cert is enough for Cloudflare **Full** mode:
```bash
mkdir -p /etc/ssl/buildmymcpserver
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/ssl/buildmymcpserver/origin.key \
-out /etc/ssl/buildmymcpserver/origin.crt \
-subj "/CN=buildmymcpserver.com"
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.
---
## 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 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)**.