# Phase 17: Docker Deployment - Context **Gathered:** 2026-04-03 **Status:** Ready for planning ## Phase Boundary Infrastructure-only phase. No new features, no schema changes, no UI changes. Deliverables: 1. `Dockerfile` — three-stage, `node:20-slim`, `--platform linux/amd64` 2. `docker-compose.yml` — named uploads volume, `env_file`, SMTP DNS fix 3. `.dockerignore` — exclude dev files from image 4. `next.config.ts` — add `output: 'standalone'` 5. `/api/health` route — returns 200 + DB ping 6. `src/lib/db/index.ts` — add Neon pool limit (`max: 5`) 7. `package.json` — remove dead `@vercel/blob` dependency 8. `.env.production.example` — template for production env vars (no real secrets) 9. Deployment notes (brief README or DEPLOYMENT.md) — document migration step ## Implementation Decisions ### CPU Architecture - **D-01:** All Dockerfile `FROM` lines use `--platform linux/amd64`. The home server is x86_64. Mac (ARM) builds correctly cross-compile with this flag. Applies to all three stages (deps, builder, runner). ### Migration Strategy - **D-02:** Database migrations are run from the developer's machine before deploying the container — **not** in the container entrypoint or CMD. The container CMD is simply `node server.js` (or `node .next/standalone/server.js`). A brief deployment note documents: "Before starting container, run `DATABASE_URL=... npx drizzle-kit migrate` from the project directory." ### Production Env Vars - **D-03:** `.env.production.example` includes ONLY the vars needed for the running app. Skyslope/URE credentials (`SKYSLOPE_LAST_NAME`, `SKYSLOPE_NRDS_ID`, `URE_USERNAME`, `URE_PASSWORD`) are excluded — they are dev-only forms scraping tools, not needed at runtime. `BLOB_READ_WRITE_TOKEN` is also excluded (dead dependency being removed). **Required production vars:** ``` DATABASE_URL= SIGNING_JWT_SECRET= AUTH_SECRET= AGENT_EMAIL= AGENT_PASSWORD= CONTACT_EMAIL_USER= CONTACT_EMAIL_PASS= CONTACT_SMTP_HOST= CONTACT_SMTP_PORT= OPENAI_API_KEY= APP_BASE_URL= ``` ### Dockerfile Structure - **D-04:** Three-stage build pattern: 1. **deps** (`--platform linux/amd64 node:20-slim AS deps`) — install production dependencies only (`npm ci --omit=dev`) 2. **builder** (`--platform linux/amd64 node:20-slim AS builder`) — copy source + deps, run `npm run build` with `output: 'standalone'` 3. **runner** (`--platform linux/amd64 node:20-slim AS runner`) — copy `.next/standalone`, `.next/static`, `public`, set non-root user, `CMD ["node", "server.js"]` - **D-05:** `output: 'standalone'` added to `next.config.ts`. Keeps existing `transpilePackages` and `serverExternalPackages` config intact. ### Docker Compose - **D-06:** `docker-compose.yml` service definition: - `env_file: .env.production` (NOT `environment:` key — secrets stay out of compose file) - Named volume `uploads:/app/uploads` (persistent across container restarts) - `dns: ["8.8.8.8", "1.1.1.1"]` (SMTP DNS fix — prevents `EAI_AGAIN` errors on external SMTP) - `environment: NODE_OPTIONS: --dns-result-order=ipv4first` (companion to DNS fix) - Port mapping `3000:3000` - `restart: unless-stopped` ### SMTP DNS Fix - **D-07:** Docker's internal DNS resolver intermittently fails on external hostnames (`EAI_AGAIN`). Fix is already documented — add `dns` array and `NODE_OPTIONS` to the app service. No code changes needed in the mailer itself. ### Neon Connection Pool - **D-08:** `src/lib/db/index.ts` currently calls `postgres(url)` with no `max` parameter (default 10 connections). Change to `postgres(url, { max: 5 })` to leave headroom for migrations and overlapping deploys on Neon's free tier. ### @vercel/blob Removal - **D-09:** Remove `@vercel/blob` from `package.json` dependencies. Research confirmed it is imported nowhere in the codebase — dead dependency. `BLOB_READ_WRITE_TOKEN` excluded from `.env.production.example` (D-03). ### Health Endpoint - **D-10:** New App Router route `GET /api/health` returns `{ ok: true, db: 'connected' }` with status 200 when the database is reachable. Runs a lightweight `SELECT 1` via Drizzle. Returns `{ ok: false }` with status 503 if DB unreachable. No auth required. ### Claude's Discretion - Exact `.dockerignore` entries (exclude `node_modules`, `.next`, `.git`, `uploads`, `.env*`, `*.md` docs, etc.) - Whether DEPLOYMENT.md is a separate file or a section in the existing README - Health check `HEALTHCHECK` directive in Dockerfile (optional Docker-native health check) ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### Files Being Modified / Created - `teressa-copeland-homes/next.config.ts` — add `output: 'standalone'` - `teressa-copeland-homes/src/lib/db/index.ts` — add pool `max: 5` - `teressa-copeland-homes/package.json` — remove `@vercel/blob` - New: `teressa-copeland-homes/Dockerfile` - New: `teressa-copeland-homes/docker-compose.yml` - New: `teressa-copeland-homes/.dockerignore` - New: `teressa-copeland-homes/src/app/api/health/route.ts` - New: `teressa-copeland-homes/.env.production.example` ### Research - `.planning/research/ARCHITECTURE.md` — Docker Compose patterns, standalone output, Neon pool - `.planning/research/PITFALLS.md` — Alpine incompatibility, NEXT_PUBLIC_BASE_URL build-time baking, `@vercel/blob` dead dep, DNS issues ### Schema (DB connection) - `teressa-copeland-homes/src/lib/db/index.ts` — current db client setup ## Existing Code Insights ### Current State - `next.config.ts` — has `transpilePackages` and `serverExternalPackages`; no `output` field yet - No `Dockerfile`, no `docker-compose.yml`, no `.dockerignore` — all new files - `.env.local` exists (dev only); no `.env.production` — agent creates `.env.production.example` - `src/lib/db/index.ts` — uses `postgres(url)` (no pool limit) - `@vercel/blob` in `package.json` but imported nowhere ### Key Constraint - `@napi-rs/canvas` declared as `serverExternalPackages` in next.config.ts — this confirms it's a native binary. Must use `node:20-slim` (Debian glibc), NOT Alpine. `--platform linux/amd64` ensures correct binary architecture. ## Specific Ideas - The uploads directory is currently `path.join(process.cwd(), 'uploads')` in the app code. In standalone mode, `process.cwd()` points to `.next/standalone/`. The Docker volume should mount at `/app/.next/standalone/uploads` OR the code should reference an absolute path via an env var. The planner should check this and use the correct mount path. - `.env.production` (the actual secrets file) is created by Teressa on the server — it is never committed. Only `.env.production.example` is committed. ## Deferred Ideas None — discussion stayed within phase scope. --- *Phase: 17-docker-deployment* *Context gathered: 2026-04-03*