From 9cdee7f84e9eaa7ecdec6c880cc18ad202bcfde3 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Mon, 27 Apr 2026 14:38:39 -0500 Subject: [PATCH] docker: unify per-service Dockerfiles into one multi-stage file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse Dockerfile.web / Dockerfile.collab / Dockerfile.mcp into a single docker/Dockerfile with shared `deps` stage and `web` / `collab` / `mcp` runtime targets. BuildKit hashes the deps stage identically for all three targets, so `pnpm install` runs once instead of three times in parallel — fixes the OOM kill on the Coolify host (CT 107) during `docker compose build`. Both docker-compose.yml (local dev) and docker-compose.coolify.yml now select services via `target:` instead of separate dockerfile paths. Made-with: Cursor --- docker/Dockerfile | 168 ++++++++++++++++++++++++++++++ docker/Dockerfile.collab | 51 --------- docker/Dockerfile.mcp | 57 ---------- docker/Dockerfile.web | 56 ---------- docker/docker-compose.coolify.yml | 15 ++- docker/docker-compose.yml | 9 +- 6 files changed, 186 insertions(+), 170 deletions(-) create mode 100644 docker/Dockerfile delete mode 100644 docker/Dockerfile.collab delete mode 100644 docker/Dockerfile.mcp delete mode 100644 docker/Dockerfile.web diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..27cd8d1 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,168 @@ +# syntax=docker/dockerfile:1 +# +# Single multi-stage Dockerfile for ECHODO. Build via Docker Compose with +# `target:` to select web | collab | mcp. +# +# Why one file instead of three? +# - The `deps` stage runs `pnpm install` ONCE for all 723 workspace packages. +# BuildKit shares it across every downstream target by content hash, so +# web + collab + mcp build in parallel without three concurrent installs +# blowing through the build host's RAM. +# - Per-app build stages (`web-build`, `collab-build`, `mcp-build`) only +# copy the source they actually need. +# - Final runtime stages (`web`, `collab`, `mcp`) pull just the built +# artifacts and the per-app node_modules they need at runtime. + +ARG NODE_VERSION=20.18.1 + + +# ============================================================================ +# base — shared toolchain +# ============================================================================ +FROM node:${NODE_VERSION}-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + + +# ============================================================================ +# deps — shared pnpm install for the entire workspace +# Cached aggressively: invalidates only when package.json or lockfile changes. +# ============================================================================ +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./ +COPY apps/web/package.json ./apps/web/ +COPY apps/collab-server/package.json ./apps/collab-server/ +COPY apps/mcp-server/package.json ./apps/mcp-server/ +COPY packages/database/package.json ./packages/database/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/ai/package.json ./packages/ai/ +RUN pnpm install --frozen-lockfile + + +# ============================================================================ +# web-build — Next.js production build → standalone output +# ============================================================================ +FROM deps AS web-build +COPY apps/web ./apps/web +COPY packages/ai ./packages/ai +COPY packages/database ./packages/database +COPY packages/shared ./packages/shared +COPY docker/next.config.docker.ts ./apps/web/next.config.ts + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +RUN pnpm exec turbo build --filter=@tasks/web + + +# ============================================================================ +# collab-build — tsup build for Hocuspocus server +# ============================================================================ +FROM deps AS collab-build +COPY apps/collab-server ./apps/collab-server +COPY packages/database ./packages/database + +ENV NODE_ENV=production +RUN pnpm exec turbo build --filter=@tasks/collab-server + + +# ============================================================================ +# mcp-build — tsup build for MCP server +# ============================================================================ +FROM deps AS mcp-build +COPY apps/mcp-server ./apps/mcp-server +COPY packages/database ./packages/database +COPY packages/shared ./packages/shared + +ENV NODE_ENV=production +RUN pnpm exec turbo build --filter=@tasks/mcp-server + + +# ============================================================================ +# web — runtime image (Next.js standalone) +# ============================================================================ +FROM node:${NODE_VERSION}-alpine AS web +RUN apk add --no-cache wget \ + && addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +COPY --from=web-build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=web-build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=web-build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public + +USER nextjs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget -qO- --timeout=3 http://127.0.0.1:3000/ >/dev/null || exit 1 + +CMD ["node", "apps/web/server.js"] + + +# ============================================================================ +# collab — runtime image (Hocuspocus / Yjs websocket) +# ============================================================================ +FROM node:${NODE_VERSION}-alpine AS collab +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 collab --ingroup nodejs + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=1234 + +COPY --from=collab-build --chown=collab:nodejs /app/node_modules ./node_modules +COPY --from=collab-build --chown=collab:nodejs /app/packages/database ./packages/database +COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/dist ./apps/collab-server/dist +COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/package.json ./apps/collab-server/package.json +COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/node_modules ./apps/collab-server/node_modules + +USER collab + +EXPOSE 1234 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "const p=+(process.env.PORT||1234);require('net').createConnection(p,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))" + +WORKDIR /app/apps/collab-server +CMD ["node", "dist/index.mjs"] + + +# ============================================================================ +# mcp — runtime image (Model Context Protocol server) +# ============================================================================ +FROM node:${NODE_VERSION}-alpine AS mcp +ARG MCP_SERVER_PORT=3001 +ENV MCP_SERVER_PORT=${MCP_SERVER_PORT} + +RUN apk add --no-cache procps \ + && addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 mcp --ingroup nodejs + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=mcp-build --chown=mcp:nodejs /app/node_modules ./node_modules +COPY --from=mcp-build --chown=mcp:nodejs /app/packages/database ./packages/database +COPY --from=mcp-build --chown=mcp:nodejs /app/packages/shared ./packages/shared +COPY --from=mcp-build --chown=mcp:nodejs /app/apps/mcp-server/dist ./apps/mcp-server/dist +COPY --from=mcp-build --chown=mcp:nodejs /app/apps/mcp-server/package.json ./apps/mcp-server/package.json +COPY --from=mcp-build --chown=mcp:nodejs /app/apps/mcp-server/node_modules ./apps/mcp-server/node_modules + +USER mcp + +WORKDIR /app/apps/mcp-server + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD pgrep -f "node.*dist/index\\.js" > /dev/null || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/docker/Dockerfile.collab b/docker/Dockerfile.collab deleted file mode 100644 index 3df7097..0000000 --- a/docker/Dockerfile.collab +++ /dev/null @@ -1,51 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG NODE_VERSION=20.18.1 -FROM node:${NODE_VERSION}-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate -WORKDIR /app - -# --- Builder --- -# Install deps + build in one stage so pnpm's per-package node_modules -# (containing .bin symlinks like `tsup`) are intact. -FROM base AS builder -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./ -COPY apps/web/package.json ./apps/web/ -COPY apps/collab-server/package.json ./apps/collab-server/ -COPY apps/mcp-server/package.json ./apps/mcp-server/ -COPY packages/database/package.json ./packages/database/ -COPY packages/shared/package.json ./packages/shared/ -COPY packages/ai/package.json ./packages/ai/ -RUN pnpm install --frozen-lockfile - -COPY apps/collab-server ./apps/collab-server -COPY packages/database ./packages/database - -ENV NODE_ENV=production -RUN pnpm exec turbo build --filter=@tasks/collab-server - -# --- Production runner --- -FROM node:${NODE_VERSION}-alpine AS runner -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 collab --ingroup nodejs - -WORKDIR /app - -ENV NODE_ENV=production -ENV PORT=1234 - -COPY --from=builder --chown=collab:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=collab:nodejs /app/packages/database ./packages/database -COPY --from=builder --chown=collab:nodejs /app/apps/collab-server/dist ./apps/collab-server/dist -COPY --from=builder --chown=collab:nodejs /app/apps/collab-server/package.json ./apps/collab-server/package.json -COPY --from=builder --chown=collab:nodejs /app/apps/collab-server/node_modules ./apps/collab-server/node_modules - -USER collab - -EXPOSE 1234 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD node -e "const p=+(process.env.PORT||1234);require('net').createConnection(p,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))" - -WORKDIR /app/apps/collab-server -CMD ["node", "dist/index.mjs"] diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp deleted file mode 100644 index c317459..0000000 --- a/docker/Dockerfile.mcp +++ /dev/null @@ -1,57 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG NODE_VERSION=20.18.1 -FROM node:${NODE_VERSION}-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate -WORKDIR /app - -# --- Builder --- -# Install deps + build in one stage so pnpm's per-package node_modules -# (containing .bin symlinks like `tsup`) are intact. -FROM base AS builder -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./ -COPY apps/web/package.json ./apps/web/ -COPY apps/collab-server/package.json ./apps/collab-server/ -COPY apps/mcp-server/package.json ./apps/mcp-server/ -COPY packages/database/package.json ./packages/database/ -COPY packages/shared/package.json ./packages/shared/ -COPY packages/ai/package.json ./packages/ai/ -RUN pnpm install --frozen-lockfile - -COPY apps/mcp-server ./apps/mcp-server -COPY packages/database ./packages/database -COPY packages/shared ./packages/shared - -ENV NODE_ENV=production -RUN pnpm exec turbo build --filter=@tasks/mcp-server - -# --- Production runner --- -FROM node:${NODE_VERSION}-alpine AS runner -ARG MCP_SERVER_PORT=3001 -ENV MCP_SERVER_PORT=${MCP_SERVER_PORT} - -RUN apk add --no-cache procps \ - && addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 mcp --ingroup nodejs - -WORKDIR /app - -ENV NODE_ENV=production - -COPY --from=builder --chown=mcp:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=mcp:nodejs /app/packages/database ./packages/database -COPY --from=builder --chown=mcp:nodejs /app/packages/shared ./packages/shared -COPY --from=builder --chown=mcp:nodejs /app/apps/mcp-server/dist ./apps/mcp-server/dist -COPY --from=builder --chown=mcp:nodejs /app/apps/mcp-server/package.json ./apps/mcp-server/package.json -COPY --from=builder --chown=mcp:nodejs /app/apps/mcp-server/node_modules ./apps/mcp-server/node_modules - -USER mcp - -WORKDIR /app/apps/mcp-server - -EXPOSE 3001 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD pgrep -f "node.*dist/index\\.js" > /dev/null || exit 1 - -CMD ["node", "dist/index.js"] diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web deleted file mode 100644 index 5053889..0000000 --- a/docker/Dockerfile.web +++ /dev/null @@ -1,56 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG NODE_VERSION=20.18.1 -FROM node:${NODE_VERSION}-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate -WORKDIR /app - -# --- Builder --- -# Install deps + build in one stage so pnpm's per-package node_modules -# (containing .bin symlinks like `next`, `tsup`) are intact. -# pnpm's content-addressable store keeps re-installs fast on cache hits. -FROM base AS builder -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./ -COPY apps/web/package.json ./apps/web/ -COPY apps/collab-server/package.json ./apps/collab-server/ -COPY apps/mcp-server/package.json ./apps/mcp-server/ -COPY packages/database/package.json ./packages/database/ -COPY packages/shared/package.json ./packages/shared/ -COPY packages/ai/package.json ./packages/ai/ -RUN pnpm install --frozen-lockfile - -COPY apps/web ./apps/web -COPY packages/ai ./packages/ai -COPY packages/database ./packages/database -COPY packages/shared ./packages/shared -COPY docker/next.config.docker.ts ./apps/web/next.config.ts - -ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_ENV=production - -RUN pnpm exec turbo build --filter=@tasks/web - -# --- Production runner --- -FROM node:${NODE_VERSION}-alpine AS runner -RUN apk add --no-cache wget \ - && addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs - -WORKDIR /app - -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOSTNAME=0.0.0.0 - -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public - -USER nextjs - -EXPOSE 3000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD wget -qO- --timeout=3 http://127.0.0.1:3000/ >/dev/null || exit 1 - -CMD ["node", "apps/web/server.js"] diff --git a/docker/docker-compose.coolify.yml b/docker/docker-compose.coolify.yml index e0e2ea0..7f7134c 100644 --- a/docker/docker-compose.coolify.yml +++ b/docker/docker-compose.coolify.yml @@ -16,12 +16,19 @@ # - No bundled postgres / redis (uses shared CT 102 services) # - No host port mappings (Coolify's Traefik routes by container labels) # - Uses Coolify SERVICE_FQDN_* magic env vars so Coolify auto-wires Traefik labels +# +# Build strategy: +# All three services share docker/Dockerfile and select runtime via `target:`. +# The shared `deps` stage (pnpm install for the whole workspace) is hashed +# identically across targets, so BuildKit runs it ONCE even when web/collab/mcp +# build in parallel — keeps build-host RAM bounded. services: web: build: context: . - dockerfile: docker/Dockerfile.web + dockerfile: docker/Dockerfile + target: web restart: unless-stopped environment: # Coolify FQDN magic var — set the domain in the Coolify UI for this service. @@ -69,7 +76,8 @@ services: collab: build: context: . - dockerfile: docker/Dockerfile.collab + dockerfile: docker/Dockerfile + target: collab restart: unless-stopped environment: # Separate FQDN for the websocket service, e.g. collab.echodo.stillwell.cloud @@ -96,7 +104,8 @@ services: mcp: build: context: . - dockerfile: docker/Dockerfile.mcp + dockerfile: docker/Dockerfile + target: mcp args: MCP_SERVER_PORT: ${MCP_SERVER_PORT:-3001} restart: unless-stopped diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89abc1c..1408bcb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -47,7 +47,8 @@ services: web: build: context: .. - dockerfile: docker/Dockerfile.web + dockerfile: docker/Dockerfile + target: web restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} @@ -79,7 +80,8 @@ services: collab: build: context: .. - dockerfile: docker/Dockerfile.collab + dockerfile: docker/Dockerfile + target: collab restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} @@ -110,7 +112,8 @@ services: mcp: build: context: .. - dockerfile: docker/Dockerfile.mcp + dockerfile: docker/Dockerfile + target: mcp args: MCP_SERVER_PORT: ${MCP_SERVER_PORT:-3001} restart: unless-stopped