From 8a7ffe673defbe284b09f9bcc1d1ca1280592bdb Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 21 May 2026 00:37:02 +0200 Subject: [PATCH] feat(deploy): production Dockerfiles, compose stack, and runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .dockerignore | 34 +++ .env.production.example | 75 ++++++ DEPLOY.md | 290 +++++++++++++++++++++++ apps/api/Dockerfile | 33 +++ apps/api/package.json | 2 +- apps/generator/Dockerfile | 34 +++ apps/generator/package.json | 2 +- apps/web/Dockerfile | 37 +++ docker-compose.prod.yml | 100 ++++++++ infra/traefik/.env.example | 3 + infra/traefik/docker-compose.traefik.yml | 37 +++ infra/traefik/dynamic.yml | 32 +++ 12 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.production.example create mode 100644 DEPLOY.md create mode 100644 apps/api/Dockerfile create mode 100644 apps/generator/Dockerfile create mode 100644 apps/web/Dockerfile create mode 100644 docker-compose.prod.yml create mode 100644 infra/traefik/.env.example create mode 100644 infra/traefik/docker-compose.traefik.yml create mode 100644 infra/traefik/dynamic.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d6b2a5f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..ebdc234 --- /dev/null +++ b/.env.production.example @@ -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:). +# 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= diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..6662ae3 --- /dev/null +++ b/DEPLOY.md @@ -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 (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 (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:` 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 (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. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..d55f9d9 --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/package.json b/apps/api/package.json index 5df4b28..b6b8b5f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" }, diff --git a/apps/generator/Dockerfile b/apps/generator/Dockerfile new file mode 100644 index 0000000..4fbeb80 --- /dev/null +++ b/apps/generator/Dockerfile @@ -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"] diff --git a/apps/generator/package.json b/apps/generator/package.json index 7e7060e..302a0dc 100644 --- a/apps/generator/package.json +++ b/apps/generator/package.json @@ -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" }, diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..8545106 --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..65fb1c7 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/infra/traefik/.env.example b/infra/traefik/.env.example new file mode 100644 index 0000000..2a45497 --- /dev/null +++ b/infra/traefik/.env.example @@ -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 diff --git a/infra/traefik/docker-compose.traefik.yml b/infra/traefik/docker-compose.traefik.yml new file mode 100644 index 0000000..03cdae9 --- /dev/null +++ b/infra/traefik/docker-compose.traefik.yml @@ -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: diff --git a/infra/traefik/dynamic.yml b/infra/traefik/dynamic.yml new file mode 100644 index 0000000..8d213bf --- /dev/null +++ b/infra/traefik/dynamic.yml @@ -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"