diff --git a/.env.production.example b/.env.production.example index ebdc234..09001a4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,5 +1,5 @@ # ============================================================================ -# Production environment for buildmymcp.com +# Production environment for buildmymcpserver.com # Copy to .env.production on the server and fill every value marked CHANGE-ME. # Never commit the filled file — .env.production is gitignored. # @@ -15,26 +15,30 @@ NODE_ENV=production POSTGRES_USER=bmm POSTGRES_PASSWORD=CHANGE-ME-strong-db-password POSTGRES_DB=bmm + +# ---- Host ports (loopback only — picked free on the shared box) ---- POSTGRES_PORT=5440 - -# ---- Redis ---- REDIS_PORT=6390 +API_PORT=4000 +WEB_PORT=4001 -# ---- Connection strings (host-networked services reach the DBs on loopback) ---- -DATABASE_URL=postgresql://bmm:CHANGE-ME-strong-db-password@127.0.0.1:5440/bmm -REDIS_URL=redis://127.0.0.1:6390 +# ---- Connection strings ---- +# api + web reach the DBs over the compose network (service names). +# The generator overrides these to 127.0.0.1 (it uses host networking). +DATABASE_URL=postgresql://bmm:CHANGE-ME-strong-db-password@postgres:5432/bmm +REDIS_URL=redis://redis:6379 # ---- API ---- PORT=4000 # ---- Public URLs (must match the Cloudflare DNS records) ---- -NEXT_PUBLIC_APP_URL=https://buildmymcp.com -NEXT_PUBLIC_API_URL=https://api.buildmymcp.com +NEXT_PUBLIC_APP_URL=https://buildmymcpserver.com +NEXT_PUBLIC_API_URL=https://api.buildmymcpserver.com # Used to build the Google OAuth redirect URI and as the JWKS origin. -CONTROL_PLANE_PUBLIC_URL=https://api.buildmymcp.com +CONTROL_PLANE_PUBLIC_URL=https://api.buildmymcpserver.com # Reachable by generated MCP containers — must be public so they can resolve it. -CONTROL_PLANE_URL=https://api.buildmymcp.com -OAUTH_ISSUER=https://api.buildmymcp.com +CONTROL_PLANE_URL=https://api.buildmymcpserver.com +OAUTH_ISSUER=https://api.buildmymcpserver.com # ---- Crypto ---- # REQUIRED in production. The API refuses to boot on the all-zero placeholder. @@ -52,7 +56,7 @@ ANTHROPIC_API_KEY= # ---- Google OAuth ("Continue with Google") ---- # Google Cloud Console -> APIs & Services -> Credentials -> OAuth client (Web). # Authorized redirect URI must be EXACTLY: -# https://api.buildmymcp.com/v1/auth/google/callback +# https://api.buildmymcpserver.com/v1/auth/google/callback GOOGLE_OAUTH_ID= GOOGLE_OAUTH_SECRET= @@ -62,13 +66,14 @@ OAUTH_KEY_DIR=./keys # ---- Runner / Generator ---- # Host used in a generated server's public URL (http://RUNNER_HOST:). -# Generated MCP containers bind host ports in RUNNER_PORT_RANGE_*. -# NOTE: per-server subdomain routing through the proxy is not wired yet — a +# Generated MCP containers bind host ports in RUNNER_PORT_RANGE_* — this range +# is kept clear of every other app already running on the box. +# NOTE: per-server subdomain routing through nginx is not wired yet — a # generated server is currently reachable at the host port directly. Treat # public exposure of generated servers as a follow-up before GA. See DEPLOY.md. -RUNNER_HOST=buildmymcp.com -RUNNER_PORT_RANGE_START=4100 -RUNNER_PORT_RANGE_END=4999 +RUNNER_HOST=buildmymcpserver.com +RUNNER_PORT_RANGE_START=4400 +RUNNER_PORT_RANGE_END=4900 # ---- Observability (optional) ---- SENTRY_DSN= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..98cb479 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,36 @@ +name: Deploy to Production +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: bmm-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: hetzner + steps: + - name: Pull from Gitea + rebuild containers + run: | + set -eo pipefail + : "${HOME:=/root}" + export HOME + cd /opt/buildmymcpserver + git fetch gitea main + git reset --hard gitea/main + docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build + docker system prune -f + + - name: Health check + run: | + set -e + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then echo "API healthy after $i attempts"; exit 0; fi + echo "wait $i/30 (got $code)" + sleep 5 + done + docker logs bmm-api --tail 60 || true + exit 1 diff --git a/DEPLOY.md b/DEPLOY.md index 24a06fe..cb0812a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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/` 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 -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"`. +| 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 (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 . +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 (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 . +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:` 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 (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 . +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://`), 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 + 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:` 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)**. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 65fb1c7..35e6d53 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,25 +1,27 @@ -# Production stack for buildmymcp.com — Linux host only. +# Production stack for buildmymcpserver.com — Linux host only. # # Run with: # docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build # -# Topology notes: -# - api / web / generator use host networking. The generator allocates host -# ports (4100-4999) for generated MCP containers and probes them with a local -# socket bind — that probe is only correct in the host network namespace. -# Host networking also keeps every service on one address space (127.0.0.1). -# - postgres / redis stay on the compose bridge network and publish to loopback -# only, so the host-networked services reach them at 127.0.0.1. -# - api and generator mount the Docker socket: the API removes containers, the -# generator builds + runs them. Generated MCP containers are host siblings. -# - Nothing here binds 0.0.0.0:80/443. Front this with the box's existing -# reverse proxy, or the optional one in infra/traefik/. See DEPLOY.md. +# Topology — matches the house pattern on the shared Hetzner box: +# - Bridge networking + per-app network, like every other app on the box. +# - api / web / postgres / redis publish to 127.0.0.1 only. The host nginx +# reverse-proxies the public domains to these loopback ports. Nothing here +# binds 0.0.0.0:80/443 — the box's existing nginx owns those. +# - generator uses host networking: it has no listening port of its own (no +# collision risk) and it must allocate + probe host ports for the MCP +# containers it spawns, which is only correct in the host namespace. +# - api + generator mount the Docker socket: the API removes generated +# containers, the generator builds + runs them as host siblings. +# +# Ports are picked to not collide with the other apps already on this box. -name: buildmymcp +name: buildmymcpserver services: postgres: image: postgres:16-alpine + container_name: bmm-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-bmm} @@ -29,6 +31,7 @@ services: - "127.0.0.1:${POSTGRES_PORT:-5440}:5432" volumes: - bmm_pg:/var/lib/postgresql/data + networks: [bmm-network] healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmm} -d ${POSTGRES_DB:-bmm}"] interval: 5s @@ -37,12 +40,14 @@ services: redis: image: redis:7-alpine + container_name: bmm-redis restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] ports: - "127.0.0.1:${REDIS_PORT:-6390}:6379" volumes: - bmm_redis:/data + networks: [bmm-network] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -53,28 +58,15 @@ services: build: context: . dockerfile: apps/api/Dockerfile + container_name: bmm-api restart: unless-stopped - network_mode: host env_file: .env.production + ports: + - "127.0.0.1:${API_PORT:-4000}:4000" volumes: - /var/run/docker.sock:/var/run/docker.sock - bmm_keys:/app/apps/api/keys - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - - generator: - build: - context: . - dockerfile: apps/generator/Dockerfile - restart: unless-stopped - network_mode: host - env_file: .env.production - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - bmm_build_context:/app/build-context + networks: [bmm-network] depends_on: postgres: condition: service_healthy @@ -87,11 +79,40 @@ services: dockerfile: apps/web/Dockerfile args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production} + container_name: bmm-web + restart: unless-stopped + env_file: .env.production + ports: + - "127.0.0.1:${WEB_PORT:-4001}:3001" + networks: [bmm-network] + depends_on: + - api + + generator: + build: + context: . + dockerfile: apps/generator/Dockerfile + container_name: bmm-generator restart: unless-stopped network_mode: host env_file: .env.production + environment: + # Host networking — reach the DBs via their published loopback ports + # instead of the compose-network service names. + DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm} + REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - bmm_build_context:/app/build-context depends_on: - - api + postgres: + condition: service_healthy + redis: + condition: service_healthy + +networks: + bmm-network: + driver: bridge volumes: bmm_pg: diff --git a/infra/nginx/buildmymcpserver.conf b/infra/nginx/buildmymcpserver.conf new file mode 100644 index 0000000..ad8886a --- /dev/null +++ b/infra/nginx/buildmymcpserver.conf @@ -0,0 +1,68 @@ +# nginx vhost for buildmymcpserver.com — install on the host nginx: +# scp this to /etc/nginx/sites-available/buildmymcpserver +# ln -s /etc/nginx/sites-available/buildmymcpserver /etc/nginx/sites-enabled/ +# nginx -t && systemctl reload nginx +# +# TLS is terminated by Cloudflare (proxied DNS records). The origin serves +# plain HTTP on :80 — same pattern as the other Cloudflare-fronted apps here. +# Set the Cloudflare SSL/TLS mode to "Full" for this zone. + +# --- Web app: buildmymcpserver.com --- +server { + listen 80; + listen [::]:80; + server_name buildmymcpserver.com www.buildmymcpserver.com; + + client_max_body_size 12M; + + location / { + proxy_pass http://127.0.0.1:4001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 120s; + } +} + +# --- Control plane API: api.buildmymcpserver.com --- +server { + listen 80; + listen [::]:80; + server_name api.buildmymcpserver.com; + + client_max_body_size 12M; + + # Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade + # headers and a long read timeout; buffering off so frames are not held. + location /v1/builds/ { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + } + + location / { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 120s; + } +} diff --git a/infra/traefik/.env.example b/infra/traefik/.env.example deleted file mode 100644 index 2a45497..0000000 --- a/infra/traefik/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Copy to infra/traefik/.env — used only by docker-compose.traefik.yml. -# Email Let's Encrypt uses for expiry notices. -ACME_EMAIL=marco.frangiskatos@gmail.com diff --git a/infra/traefik/docker-compose.traefik.yml b/infra/traefik/docker-compose.traefik.yml deleted file mode 100644 index 03cdae9..0000000 --- a/infra/traefik/docker-compose.traefik.yml +++ /dev/null @@ -1,37 +0,0 @@ -# OPTIONAL reverse proxy — use ONLY if the server has no existing proxy. -# -# !! DANGER: this binds host ports 80 and 443. If another reverse proxy -# !! (nginx / Caddy / another Traefik) is already serving the other live apps -# !! on this box, starting this WILL conflict and can take those apps offline. -# !! Check first: sudo ss -ltnp '( sport = :80 or sport = :443 )' -# !! If something already listens there, DO NOT run this. Instead add a vhost -# !! to the existing proxy pointing at 127.0.0.1:3001 (web) and 127.0.0.1:4000 -# !! (api). See DEPLOY.md. -# -# Run with: -# docker compose --env-file .env -f docker-compose.traefik.yml up -d - -name: buildmymcp-traefik - -services: - traefik: - image: traefik:v3.2 - restart: unless-stopped - network_mode: host - command: - - --providers.file.filename=/etc/traefik/dynamic.yml - - --providers.file.watch=true - - --entrypoints.web.address=:80 - - --entrypoints.websecure.address=:443 - - --entrypoints.web.http.redirections.entrypoint.to=websecure - - --entrypoints.web.http.redirections.entrypoint.scheme=https - - --certificatesresolvers.le.acme.httpchallenge=true - - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web - - --certificatesresolvers.le.acme.email=${ACME_EMAIL:?set ACME_EMAIL in infra/traefik/.env} - - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json - volumes: - - ./dynamic.yml:/etc/traefik/dynamic.yml:ro - - bmm_letsencrypt:/letsencrypt - -volumes: - bmm_letsencrypt: diff --git a/infra/traefik/dynamic.yml b/infra/traefik/dynamic.yml deleted file mode 100644 index 8d213bf..0000000 --- a/infra/traefik/dynamic.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Traefik file-provider routes. The app stack uses host networking, so it has -# no Docker labels for Traefik to discover — routes are declared statically here. -# Targets are loopback ports owned by docker-compose.prod.yml. - -http: - routers: - bmm-web: - rule: "Host(`buildmymcp.com`) || Host(`www.buildmymcp.com`)" - entryPoints: - - websecure - service: bmm-web - tls: - certResolver: le - - bmm-api: - rule: "Host(`api.buildmymcp.com`)" - entryPoints: - - websecure - service: bmm-api - tls: - certResolver: le - - services: - bmm-web: - loadBalancer: - servers: - - url: "http://127.0.0.1:3001" - - bmm-api: - loadBalancer: - servers: - - url: "http://127.0.0.1:4000"