# 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 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 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)**.
