From 86e43d442c3253de77ca860ea4cedda73d2ae091 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 3 Apr 2026 16:46:24 -0600 Subject: [PATCH] docs(17): create phase plan for Docker deployment Two plans in 2 waves: - Plan 01 (Wave 1): standalone output, DB pool limit, remove @vercel/blob, health endpoint - Plan 02 (Wave 2): Dockerfile, docker-compose.yml, .dockerignore, .env.production.example Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .planning/ROADMAP.md | 9 +- .../phases/17-docker-deployment/17-01-PLAN.md | 170 +++++++++ .../phases/17-docker-deployment/17-02-PLAN.md | 343 ++++++++++++++++++ 3 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/17-docker-deployment/17-01-PLAN.md create mode 100644 .planning/phases/17-docker-deployment/17-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 41bf601..da6b25a 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -373,12 +373,11 @@ Plans: 3. The `APP_BASE_URL` variable (renamed from `NEXT_PUBLIC_BASE_URL`) is injected at container runtime — signing link URLs in emails contain the correct production domain, not localhost 4. Uploaded PDF files written inside the container persist after `docker compose down && docker compose up` (named Docker volume mounted at /app/uploads) 5. The Docker image uses `node:20-slim` (Debian-based) — `@napi-rs/canvas` native binary loads without errors at container startup -**Plans**: 3 plans +**Plans**: 2 plans Plans: -- [x] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route -- [ ] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename -- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications +- [ ] 17-01-PLAN.md — Enable standalone output, limit DB pool to 5, remove @vercel/blob, add /api/health endpoint +- [ ] 17-02-PLAN.md — Dockerfile (three-stage, node:20-slim, linux/amd64), docker-compose.yml (env_file, DNS fix, named volume), .dockerignore, .env.production.example **UI hint**: no ## Progress @@ -406,4 +405,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → | 14. Multi-Signer Schema | v1.2 | 1/1 | Complete | 2026-04-03 | | 15. Multi-Signer Backend | v1.2 | 3/3 | Complete | 2026-04-03 | | 16. Multi-Signer UI | v1.2 | 1/4 | Complete | 2026-04-03 | -| 17. Docker Deployment | v1.2 | 0/TBD | Not started | - | +| 17. Docker Deployment | v1.2 | 0/2 | Not started | - | diff --git a/.planning/phases/17-docker-deployment/17-01-PLAN.md b/.planning/phases/17-docker-deployment/17-01-PLAN.md new file mode 100644 index 0000000..17ad324 --- /dev/null +++ b/.planning/phases/17-docker-deployment/17-01-PLAN.md @@ -0,0 +1,170 @@ +--- +phase: 17-docker-deployment +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - teressa-copeland-homes/next.config.ts + - teressa-copeland-homes/src/lib/db/index.ts + - teressa-copeland-homes/package.json + - teressa-copeland-homes/src/app/api/health/route.ts +autonomous: true +requirements: [DEPLOY-01, DEPLOY-02, DEPLOY-04] +must_haves: + truths: + - "next.config.ts has output: 'standalone' so Next.js produces a self-contained server.js" + - "Database connection pool is limited to 5 connections to avoid Neon free tier exhaustion" + - "@vercel/blob is removed from dependencies (dead dependency)" + - "GET /api/health returns 200 with db connectivity check" + artifacts: + - path: "teressa-copeland-homes/next.config.ts" + provides: "Standalone output config" + contains: "output: 'standalone'" + - path: "teressa-copeland-homes/src/lib/db/index.ts" + provides: "Pool-limited database client" + contains: "max: 5" + - path: "teressa-copeland-homes/src/app/api/health/route.ts" + provides: "Health check endpoint" + exports: ["GET"] + key_links: + - from: "teressa-copeland-homes/src/app/api/health/route.ts" + to: "teressa-copeland-homes/src/lib/db/index.ts" + via: "db import for SELECT 1" + pattern: "import.*db.*from" +--- + + +Prepare the application codebase for Docker deployment by making four targeted code changes: enable standalone output, limit the database connection pool, remove the dead @vercel/blob dependency, and add a health check endpoint. + +Purpose: These changes are prerequisites for the Dockerfile and docker-compose.yml (Plan 02). Standalone output is required for the three-stage Docker build. Pool limiting prevents Neon connection exhaustion. The health endpoint enables Docker's HEALTHCHECK directive. + +Output: Modified next.config.ts, db/index.ts, package.json; new /api/health route + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-docker-deployment/17-CONTEXT.md + +@teressa-copeland-homes/next.config.ts +@teressa-copeland-homes/src/lib/db/index.ts +@teressa-copeland-homes/package.json + + + + + + Task 1: Enable standalone output, limit DB pool, remove @vercel/blob + teressa-copeland-homes/next.config.ts, teressa-copeland-homes/src/lib/db/index.ts, teressa-copeland-homes/package.json + + - teressa-copeland-homes/next.config.ts + - teressa-copeland-homes/src/lib/db/index.ts + - teressa-copeland-homes/package.json + + + 1. **next.config.ts** (per D-05): Add `output: 'standalone'` to the NextConfig object. Keep existing `transpilePackages` and `serverExternalPackages` intact. Result: + ```typescript + const nextConfig: NextConfig = { + output: 'standalone', + transpilePackages: ['react-pdf', 'pdfjs-dist'], + serverExternalPackages: ['@napi-rs/canvas'], + }; + ``` + + 2. **src/lib/db/index.ts** (per D-08): Change `postgres(url)` to `postgres(url, { max: 5 })` in the `createDb` function. This limits the connection pool to 5 connections, leaving headroom on Neon's free tier (10 max). Change line 12 from: + ```typescript + const client = postgres(url); + ``` + to: + ```typescript + const client = postgres(url, { max: 5 }); + ``` + + 3. **package.json** (per D-09): Remove `@vercel/blob` from dependencies. Run `npm uninstall @vercel/blob` from the `teressa-copeland-homes` directory. This removes the dead dependency that is imported nowhere in the codebase. Verify no import of `@vercel/blob` exists in src/ after removal. + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && grep -q "output: 'standalone'" next.config.ts && grep -q "max: 5" src/lib/db/index.ts && ! grep -q "@vercel/blob" package.json && echo "PASS" + + + - `grep "output: 'standalone'" teressa-copeland-homes/next.config.ts` returns a match + - `grep "max: 5" teressa-copeland-homes/src/lib/db/index.ts` returns a match + - `grep "@vercel/blob" teressa-copeland-homes/package.json` returns NO match + - `grep -r "@vercel/blob" teressa-copeland-homes/src/` returns NO match + - `transpilePackages` and `serverExternalPackages` still present in next.config.ts + + next.config.ts has standalone output, db pool is limited to 5, @vercel/blob removed from package.json + + + + Task 2: Create /api/health endpoint with DB connectivity check + teressa-copeland-homes/src/app/api/health/route.ts + + - teressa-copeland-homes/src/lib/db/index.ts + - teressa-copeland-homes/src/lib/db/schema.ts (first 20 lines, for import pattern) + + + Create `src/app/api/health/route.ts` (per D-10). This is a public endpoint (no auth) that: + - Runs `SELECT 1` via the Drizzle db client using `db.execute(sql\`SELECT 1\`)` + - Returns `{ ok: true, db: 'connected' }` with status 200 on success + - Returns `{ ok: false, error: message }` with status 503 on failure + - Wraps the DB call in try/catch + + Full file content: + ```typescript + import { db } from '@/lib/db'; + import { sql } from 'drizzle-orm'; + + export async function GET() { + try { + await db.execute(sql`SELECT 1`); + return Response.json({ ok: true, db: 'connected' }); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return Response.json({ ok: false, error: message }, { status: 503 }); + } + } + ``` + + No auth check — this endpoint is intentionally public so Docker HEALTHCHECK can reach it without credentials. + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && test -f src/app/api/health/route.ts && grep -q "SELECT 1" src/app/api/health/route.ts && grep -q "export async function GET" src/app/api/health/route.ts && echo "PASS" + + + - File exists at `teressa-copeland-homes/src/app/api/health/route.ts` + - File exports a GET function + - File contains `SELECT 1` database check + - File returns `{ ok: true, db: 'connected' }` on success + - File returns status 503 on failure + - No auth import or session check present + + GET /api/health endpoint exists, runs SELECT 1 via Drizzle, returns 200/503 based on DB reachability + + + + + +All code changes compile: +```bash +cd teressa-copeland-homes && npx tsc --noEmit +``` + + + +- next.config.ts has `output: 'standalone'` alongside existing config +- db/index.ts creates postgres client with `{ max: 5 }` +- @vercel/blob removed from package.json dependencies +- /api/health route exists and checks DB connectivity +- `npx tsc --noEmit` passes + + + +After completion, create `.planning/phases/17-docker-deployment/17-01-SUMMARY.md` + diff --git a/.planning/phases/17-docker-deployment/17-02-PLAN.md b/.planning/phases/17-docker-deployment/17-02-PLAN.md new file mode 100644 index 0000000..26eb048 --- /dev/null +++ b/.planning/phases/17-docker-deployment/17-02-PLAN.md @@ -0,0 +1,343 @@ +--- +phase: 17-docker-deployment +plan: 02 +type: execute +wave: 2 +depends_on: ["17-01"] +files_modified: + - teressa-copeland-homes/Dockerfile + - teressa-copeland-homes/docker-compose.yml + - teressa-copeland-homes/.dockerignore + - teressa-copeland-homes/.env.production.example + - teressa-copeland-homes/.gitignore +autonomous: true +requirements: [DEPLOY-01, DEPLOY-02, DEPLOY-03, DEPLOY-04, DEPLOY-05] +must_haves: + truths: + - "docker compose up starts the app and /api/health is reachable" + - "SMTP secrets are injected at runtime via env_file, not baked into image" + - "Uploaded PDFs persist across container restarts via named volume" + - "Dockerfile uses node:20-slim with --platform linux/amd64 on all 3 FROM lines" + - "seeds/forms directory is available inside the container for form library imports" + artifacts: + - path: "teressa-copeland-homes/Dockerfile" + provides: "Three-stage Docker build" + contains: "--platform linux/amd64" + - path: "teressa-copeland-homes/docker-compose.yml" + provides: "Production compose config" + contains: "env_file" + - path: "teressa-copeland-homes/.dockerignore" + provides: "Build context exclusions" + contains: "node_modules" + - path: "teressa-copeland-homes/.env.production.example" + provides: "Env var template for production" + contains: "DATABASE_URL" + key_links: + - from: "teressa-copeland-homes/docker-compose.yml" + to: ".env.production" + via: "env_file directive" + pattern: "env_file" + - from: "teressa-copeland-homes/docker-compose.yml" + to: "teressa-copeland-homes/Dockerfile" + via: "build context" + pattern: "build:" + - from: "teressa-copeland-homes/Dockerfile" + to: "server.js" + via: "CMD directive" + pattern: 'CMD.*node.*server.js' +--- + + +Create all Docker deployment files: Dockerfile (three-stage, node:20-slim, linux/amd64), docker-compose.yml (env_file secrets, named uploads volume, SMTP DNS fix), .dockerignore, .env.production.example, and update .gitignore. + +Purpose: This is the deployment infrastructure that makes the app run reliably on the production home server. Secrets are injected at runtime, uploads persist across restarts, and SMTP DNS is fixed. + +Output: Dockerfile, docker-compose.yml, .dockerignore, .env.production.example, updated .gitignore + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-docker-deployment/17-CONTEXT.md +@.planning/phases/17-docker-deployment/17-01-SUMMARY.md +@.planning/research/ARCHITECTURE.md +@.planning/research/PITFALLS.md + +@teressa-copeland-homes/next.config.ts +@teressa-copeland-homes/.gitignore + + + + +Standalone output (from Plan 01): +- next.config.ts has `output: 'standalone'` +- `npm run build` produces `.next/standalone/server.js` + `.next/static/` +- In the runner stage, standalone files are copied to WORKDIR /app +- `process.cwd()` in the container = `/app` +- Therefore uploads path = `/app/uploads` (correct for volume mount) + +Seeds directory (runtime dependency): +- `src/app/api/documents/route.ts` reads from `path.join(process.cwd(), 'seeds', 'forms')` at runtime +- This is the form library import feature — copies PDF from seeds/forms/ to uploads/ +- The seeds/forms/ directory MUST be copied into the runner stage at `/app/seeds/forms/` +- Without it, importing forms from the library returns 404 + +Uploads path in all API routes: +- `const UPLOADS_DIR = path.join(process.cwd(), 'uploads')` — used in 8+ route files +- In standalone container: process.cwd() = /app, so UPLOADS_DIR = /app/uploads +- Volume mount at /app/uploads is correct — no code changes needed + +APP_BASE_URL (already renamed from NEXT_PUBLIC_BASE_URL in Phase 15): +- Server-only env var, read at runtime — NOT baked into build +- Used in send/route.ts and sign/[token]/route.ts for signing URLs +- Must be in .env.production (e.g., APP_BASE_URL=https://teressacopelandhomes.com) + + + + + + + Task 1: Create Dockerfile, .dockerignore, and .env.production.example + teressa-copeland-homes/Dockerfile, teressa-copeland-homes/.dockerignore, teressa-copeland-homes/.env.production.example + + - teressa-copeland-homes/next.config.ts (confirm standalone is set from Plan 01) + - teressa-copeland-homes/package.json (confirm build script name) + - teressa-copeland-homes/.gitignore + + + **1. Create `teressa-copeland-homes/Dockerfile`** (per D-01, D-04): + + Three-stage build. ALL three FROM lines use `--platform linux/amd64`. Uses `node:20-slim` (Debian, NOT Alpine — required for @napi-rs/canvas glibc compatibility). The runner stage copies `seeds/forms/` for the form library import feature. + + Full Dockerfile content: + ```dockerfile + # Stage 1: Install production dependencies + FROM --platform=linux/amd64 node:20-slim AS deps + WORKDIR /app + COPY package.json package-lock.json ./ + RUN npm ci --omit=dev + + # Stage 2: Build the application + FROM --platform=linux/amd64 node:20-slim AS builder + WORKDIR /app + COPY --from=deps /app/node_modules ./node_modules + COPY . . + ENV NODE_ENV=production + RUN npm run build + + # Stage 3: Production runner + FROM --platform=linux/amd64 node:20-slim AS runner + WORKDIR /app + ENV NODE_ENV=production + ENV PORT=3000 + ENV HOSTNAME=0.0.0.0 + + RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + + # Copy standalone server and static assets + COPY --from=builder --chown=nextjs:nodejs /app/public ./public + COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ + COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + + # Copy seeds/forms for form library import feature (runtime dependency) + COPY --from=builder --chown=nextjs:nodejs /app/seeds ./seeds + + # Create uploads directory (will be mounted as volume) + RUN mkdir -p uploads && chown nextjs:nodejs uploads + + USER nextjs + EXPOSE 3000 + + HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + + CMD ["node", "server.js"] + ``` + + Key decisions in this Dockerfile: + - `--platform=linux/amd64` on all 3 FROM lines (D-01) for x86_64 home server + - `node:20-slim` not Alpine (D-01, glibc required for @napi-rs/canvas) + - `npm ci --omit=dev` in deps stage (D-04) — no devDependencies in prod + - Non-root user `nextjs:nodejs` (D-04) + - `seeds/` copied for runtime form library access + - `uploads/` dir pre-created and owned by nextjs user + - HEALTHCHECK uses wget (available in Debian slim, curl is not) + - NO secrets in any ARG or ENV line (D-02) + + **2. Create `teressa-copeland-homes/.dockerignore`**: + + ``` + node_modules + .next + .git + .env* + uploads/ + *.md + .DS_Store + .vscode + .idea + npm-debug.log* + scripts/*.png + drizzle/meta + ``` + + Note: `seeds/` is NOT in .dockerignore — it is needed in the build context for COPY. `drizzle/` is NOT excluded — migration files may be useful for reference (though migrations run from host per D-02). + + **3. Create `teressa-copeland-homes/.env.production.example`** (per D-03): + + ``` + # Database + DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require + + # Authentication + SIGNING_JWT_SECRET=your-jwt-secret-here + AUTH_SECRET=your-auth-secret-here + AGENT_EMAIL=agent@example.com + AGENT_PASSWORD=your-agent-password + + # SMTP (email delivery) + CONTACT_EMAIL_USER=your-smtp-username + CONTACT_EMAIL_PASS=your-smtp-password + CONTACT_SMTP_HOST=smtp.example.com + CONTACT_SMTP_PORT=465 + + # OpenAI (AI field placement) + OPENAI_API_KEY=sk-your-openai-key + + # Application + APP_BASE_URL=https://yourdomain.com + ``` + + Exactly 11 vars as specified in D-03. No BLOB_READ_WRITE_TOKEN (removed in Plan 01). No SKYSLOPE/URE credentials (dev-only). + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && test -f Dockerfile && test -f .dockerignore && test -f .env.production.example && grep -c "platform=linux/amd64" Dockerfile | grep -q "3" && grep -q "node:20-slim" Dockerfile && grep -q "CMD.*node.*server.js" Dockerfile && grep -q "DATABASE_URL" .env.production.example && grep -q "APP_BASE_URL" .env.production.example && ! grep -q "BLOB_READ_WRITE_TOKEN" .env.production.example && echo "PASS" + + + - `teressa-copeland-homes/Dockerfile` exists with exactly 3 `--platform=linux/amd64` FROM lines + - All FROM lines use `node:20-slim` (not Alpine) + - CMD is `["node", "server.js"]` + - No ARG or ENV lines containing secret values + - HEALTHCHECK directive present pointing to /api/health + - `seeds/` directory is copied in runner stage + - `teressa-copeland-homes/.dockerignore` exists, excludes node_modules, .next, .env*, uploads/ + - `teressa-copeland-homes/.env.production.example` exists with all 11 required vars + - No BLOB_READ_WRITE_TOKEN in .env.production.example + - APP_BASE_URL present (not NEXT_PUBLIC_BASE_URL) + + Dockerfile with three-stage build, .dockerignore, and .env.production.example all created with correct content + + + + Task 2: Create docker-compose.yml and update .gitignore + teressa-copeland-homes/docker-compose.yml, teressa-copeland-homes/.gitignore + + - teressa-copeland-homes/Dockerfile (just created in Task 1) + - teressa-copeland-homes/.gitignore + + + **1. Create `teressa-copeland-homes/docker-compose.yml`** (per D-06, D-07): + + Full docker-compose.yml content: + ```yaml + services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3000:3000" + env_file: + - .env.production + environment: + - NODE_OPTIONS=--dns-result-order=ipv4first + dns: + - 8.8.8.8 + - 1.1.1.1 + volumes: + - uploads:/app/uploads + + volumes: + uploads: + ``` + + Key decisions: + - `env_file: .env.production` — secrets injected at runtime, not baked (D-02, D-06) + - `dns: ["8.8.8.8", "1.1.1.1"]` — SMTP DNS fix, prevents EAI_AGAIN errors (D-07) + - `environment: NODE_OPTIONS: --dns-result-order=ipv4first` — companion DNS fix (D-07) + - Named volume `uploads:/app/uploads` — persists PDFs across container restarts (D-06) + - Volume mounts at `/app/uploads` which matches `process.cwd() + '/uploads'` in standalone mode + - `restart: unless-stopped` (D-06) + - Port 3000:3000 (D-06) + - No `healthcheck:` in compose — the Dockerfile already has HEALTHCHECK + - Build context is `.` (the teressa-copeland-homes directory itself) + + **2. Update `teressa-copeland-homes/.gitignore`**: + + Add `.env.production` to the existing `.env*` pattern. The existing `.env*` glob already covers it, but add an explicit comment for clarity. Also ensure `uploads/` is gitignored. Add these lines at the end of the file: + + ``` + # Production uploads (Docker volume on server) + /uploads/ + ``` + + The existing `.env*` pattern on line 34 already covers `.env.production`, so no additional env line is needed. Just verify it's there. + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && test -f docker-compose.yml && grep -q "env_file" docker-compose.yml && grep -q "dns" docker-compose.yml && grep -q "dns-result-order=ipv4first" docker-compose.yml && grep -q "uploads:/app/uploads" docker-compose.yml && grep -q "restart: unless-stopped" docker-compose.yml && grep -q "3000:3000" docker-compose.yml && echo "PASS" + + + - `teressa-copeland-homes/docker-compose.yml` exists + - Contains `env_file: .env.production` (not `environment:` for secrets) + - Contains `dns:` array with 8.8.8.8 and 1.1.1.1 + - Contains `NODE_OPTIONS=--dns-result-order=ipv4first` + - Contains named volume `uploads:/app/uploads` + - Contains `restart: unless-stopped` + - Contains port mapping `3000:3000` + - `.gitignore` includes `/uploads/` entry + - `.gitignore` existing `.env*` pattern covers .env.production + + docker-compose.yml with env_file secrets, DNS fix, named volume, and restart policy; .gitignore updated + + + + + +Verify all Docker files are syntactically valid: +```bash +cd teressa-copeland-homes && docker compose config --quiet 2>&1 || echo "compose config check" +``` + +Verify Dockerfile has exactly 3 platform-targeted FROM lines: +```bash +grep -c "platform=linux/amd64" Dockerfile +``` + +Verify no secrets in Dockerfile: +```bash +! grep -iE "(password|secret|key=)" Dockerfile +``` + + + +- Dockerfile exists with 3x `--platform=linux/amd64 node:20-slim` FROM lines +- docker-compose.yml injects secrets via env_file, not environment block +- docker-compose.yml has DNS fix (dns array + NODE_OPTIONS) +- Named volume mounts at /app/uploads (matches process.cwd() + '/uploads' in standalone) +- seeds/ directory copied into runner stage for form library +- .env.production.example has all 11 required vars, no dead vars +- .dockerignore excludes node_modules, .next, .env*, uploads/ +- .gitignore covers .env.production and uploads/ + + + +After completion, create `.planning/phases/17-docker-deployment/17-02-SUMMARY.md` +