feat(deploy): production Dockerfiles, compose stack, and runbook
- Multi-stage Dockerfiles for web/api/generator (pnpm workspace install, tsx runtime — workspace packages are raw TS, same model as runner-template). - docker-compose.prod.yml: postgres + redis + the three app services. api/generator/web use host networking so the generator's host-port probe is correct and every service shares one address space; api + generator mount the Docker socket. Binds nothing on 80/443 — safe beside other apps. - Optional Traefik reverse proxy in infra/traefik/ (heavily gated — only if the box has no existing proxy). - .env.production.example, .dockerignore, DEPLOY.md (Cloudflare zone, GoDaddy nameserver switch, server deploy, Google Cloud Console OAuth app). - api/generator `start` now runs via tsx; `node dist/index.js` could never resolve the raw-TS workspace imports. All three images verified building clean; the API container boots under tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b098c5d33
commit
8a7ffe673d
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@ -0,0 +1,34 @@
|
||||
# Dependencies — reinstalled inside the image
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build output / caches
|
||||
.next
|
||||
**/.next
|
||||
dist
|
||||
**/dist
|
||||
.turbo
|
||||
**/.turbo
|
||||
*.tsbuildinfo
|
||||
**/*.tsbuildinfo
|
||||
coverage
|
||||
|
||||
# Generated MCP build contexts — recreated at runtime in a volume
|
||||
build-context
|
||||
|
||||
# Secrets — never bake into an image (injected via env_file at runtime)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
|
||||
# OAuth signing keys — persisted in a named volume, not the image
|
||||
keys
|
||||
|
||||
# Local / VCS noise
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
75
.env.production.example
Normal file
75
.env.production.example
Normal file
@ -0,0 +1,75 @@
|
||||
# ============================================================================
|
||||
# Production environment for buildmymcp.com
|
||||
# Copy to .env.production on the server and fill every value marked CHANGE-ME.
|
||||
# Never commit the filled file — .env.production is gitignored.
|
||||
#
|
||||
# Used two ways by docker-compose.prod.yml:
|
||||
# 1. compose interpolation -> docker compose --env-file .env.production ...
|
||||
# 2. container env -> env_file: .env.production
|
||||
# ============================================================================
|
||||
|
||||
# ---- Core ----
|
||||
NODE_ENV=production
|
||||
|
||||
# ---- Postgres (the compose file owns the container) ----
|
||||
POSTGRES_USER=bmm
|
||||
POSTGRES_PASSWORD=CHANGE-ME-strong-db-password
|
||||
POSTGRES_DB=bmm
|
||||
POSTGRES_PORT=5440
|
||||
|
||||
# ---- Redis ----
|
||||
REDIS_PORT=6390
|
||||
|
||||
# ---- 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
|
||||
|
||||
# ---- 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
|
||||
# Used to build the Google OAuth redirect URI and as the JWKS origin.
|
||||
CONTROL_PLANE_PUBLIC_URL=https://api.buildmymcp.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
|
||||
|
||||
# ---- Crypto ----
|
||||
# REQUIRED in production. The API refuses to boot on the all-zero placeholder.
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
|
||||
|
||||
# ---- Admin bootstrap (upserted idempotently on API boot) ----
|
||||
ADMIN_EMAIL=marco.frangiskatos@gmail.com
|
||||
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
|
||||
ADMIN_NAME=Marco Frangiskatos
|
||||
|
||||
# ---- Anthropic (empty = mock generation; set for real Claude generation) ----
|
||||
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
|
||||
GOOGLE_OAUTH_ID=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ---- OAuth signing keys (RS256 JWKS) ----
|
||||
# Auto-generated on first boot into this dir; persisted in the bmm_keys volume.
|
||||
OAUTH_KEY_DIR=./keys
|
||||
|
||||
# ---- Runner / Generator ----
|
||||
# Host used in a generated server's public URL (http://RUNNER_HOST:<port>).
|
||||
# Generated MCP containers bind host ports in RUNNER_PORT_RANGE_*.
|
||||
# NOTE: per-server subdomain routing through the proxy 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
|
||||
|
||||
# ---- Observability (optional) ----
|
||||
SENTRY_DSN=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
290
DEPLOY.md
Normal file
290
DEPLOY.md
Normal file
@ -0,0 +1,290 @@
|
||||
# Deploying buildmymcp.com
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
33
apps/api/Dockerfile
Normal file
33
apps/api/Dockerfile
Normal file
@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Control plane (Fastify). Runs via tsx — workspace packages are consumed as raw
|
||||
# TypeScript, so there is no separate compile step (same model as runner-template).
|
||||
# Build context must be the repo root: docker build -f apps/api/Dockerfile .
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ---- deps: install the whole workspace from the lockfile ----
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/generator/package.json apps/generator/
|
||||
COPY apps/runner-template/package.json apps/runner-template/
|
||||
COPY packages/auth/package.json packages/auth/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/llm/package.json packages/llm/
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- runtime ----
|
||||
FROM deps AS runtime
|
||||
# docker CLI: the API stops/removes generated MCP containers via the host daemon.
|
||||
RUN apk add --no-cache docker-cli
|
||||
ENV NODE_ENV=production
|
||||
COPY . .
|
||||
WORKDIR /app/apps/api
|
||||
EXPOSE 4000
|
||||
HEALTHCHECK --interval=20s --timeout=4s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://localhost:4000/health || exit 1
|
||||
CMD ["pnpm", "start"]
|
||||
@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
34
apps/generator/Dockerfile
Normal file
34
apps/generator/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Generator worker (BullMQ). Renders generated MCP servers, builds their Docker
|
||||
# images and runs them as sibling containers on the host daemon.
|
||||
# Build context must be the repo root: docker build -f apps/generator/Dockerfile .
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ---- deps ----
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/generator/package.json apps/generator/
|
||||
COPY apps/runner-template/package.json apps/runner-template/
|
||||
COPY packages/auth/package.json packages/auth/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/llm/package.json packages/llm/
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- runtime ----
|
||||
FROM deps AS runtime
|
||||
# docker CLI: the worker shells out to `docker build` / `docker run` against the
|
||||
# host daemon (socket mounted in compose). apps/runner-template is copied below
|
||||
# and used as the build context template for every generated server.
|
||||
RUN apk add --no-cache docker-cli
|
||||
ENV NODE_ENV=production
|
||||
COPY . .
|
||||
# build-context is a mounted volume at runtime; create the dir so the path exists.
|
||||
RUN mkdir -p /app/build-context
|
||||
WORKDIR /app/apps/generator
|
||||
CMD ["pnpm", "start"]
|
||||
@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
37
apps/web/Dockerfile
Normal file
37
apps/web/Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Web app (Next.js 15). NEXT_PUBLIC_API_URL is inlined into the client bundle at
|
||||
# BUILD time — it must be passed as a build arg, not just a runtime env var.
|
||||
# Build context must be the repo root: docker build -f apps/web/Dockerfile .
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ---- deps ----
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/generator/package.json apps/generator/
|
||||
COPY apps/runner-template/package.json apps/runner-template/
|
||||
COPY packages/auth/package.json packages/auth/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/llm/package.json packages/llm/
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- build ----
|
||||
FROM deps AS build
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY . .
|
||||
RUN pnpm --filter @bmm/web build
|
||||
|
||||
# ---- runtime ----
|
||||
FROM build AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app/apps/web
|
||||
EXPOSE 3001
|
||||
CMD ["pnpm", "start"]
|
||||
100
docker-compose.prod.yml
Normal file
100
docker-compose.prod.yml
Normal file
@ -0,0 +1,100 @@
|
||||
# Production stack for buildmymcp.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.
|
||||
|
||||
name: buildmymcp
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-bmm}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.production}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-bmm}
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5440}:5432"
|
||||
volumes:
|
||||
- bmm_pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmm} -d ${POSTGRES_DB:-bmm}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "127.0.0.1:${REDIS_PORT:-6390}:6379"
|
||||
volumes:
|
||||
- bmm_redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file: .env.production
|
||||
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
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file: .env.production
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
bmm_pg:
|
||||
bmm_redis:
|
||||
bmm_keys:
|
||||
bmm_build_context:
|
||||
3
infra/traefik/.env.example
Normal file
3
infra/traefik/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# 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
|
||||
37
infra/traefik/docker-compose.traefik.yml
Normal file
37
infra/traefik/docker-compose.traefik.yml
Normal file
@ -0,0 +1,37 @@
|
||||
# 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:
|
||||
32
infra/traefik/dynamic.yml
Normal file
32
infra/traefik/dynamic.yml
Normal file
@ -0,0 +1,32 @@
|
||||
# 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"
|
||||
Loading…
Reference in New Issue
Block a user