Files
red/.planning/phases/17-docker-deployment/17-02-PLAN.md

394 lines
16 KiB
Markdown
Raw Normal View History

---
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
- teressa-copeland-homes/DEPLOYMENT.md
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"
- "DEPLOYMENT.md documents the full deploy workflow including migration step"
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"
- path: "teressa-copeland-homes/DEPLOYMENT.md"
provides: "Deployment instructions with migration step"
contains: "drizzle-kit migrate"
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'
---
<objective>
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, update .gitignore, and create DEPLOYMENT.md documenting the migration step.
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, DEPLOYMENT.md
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key facts the executor needs about standalone mode and uploads -->
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)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Dockerfile, .dockerignore, and .env.production.example</name>
<files>teressa-copeland-homes/Dockerfile, teressa-copeland-homes/.dockerignore, teressa-copeland-homes/.env.production.example</files>
<read_first>
- 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
</read_first>
<action>
**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).
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>Dockerfile with three-stage build, .dockerignore, and .env.production.example all created with correct content</done>
</task>
<task type="auto">
<name>Task 2: Create docker-compose.yml and update .gitignore</name>
<files>teressa-copeland-homes/docker-compose.yml, teressa-copeland-homes/.gitignore</files>
<read_first>
- teressa-copeland-homes/Dockerfile (just created in Task 1)
- teressa-copeland-homes/.gitignore
</read_first>
<action>
**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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>docker-compose.yml with env_file secrets, DNS fix, named volume, and restart policy; .gitignore updated</done>
</task>
<task type="auto">
<name>Task 3: Create DEPLOYMENT.md with migration and startup instructions</name>
<files>teressa-copeland-homes/DEPLOYMENT.md</files>
<action>
Create `teressa-copeland-homes/DEPLOYMENT.md` documenting the production deployment workflow (per D-02, CONTEXT.md domain item 9).
The file should contain these sections in order:
**Prerequisites** — Git, Docker, Docker Compose installed on server.
**Step 1: Configure environment** — Copy `.env.production.example` to `.env.production` and fill in real values for all 11 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).
**Step 2: Run database migration** — From the project directory (not inside the container), run:
```
DATABASE_URL=<your-neon-url> npx drizzle-kit migrate
```
This must complete before starting the container. Migrations are NOT run inside Docker (per D-02).
**Step 3: Build and start** — Run:
```
docker compose up -d --build
```
**Step 4: Verify** — Run:
```
curl http://localhost:3000/api/health
```
Expected response: `{"ok":true,"db":"connected"}`
**Updating** — To deploy a new version: `git pull`, re-run migration if schema changed, then `docker compose up -d --build`.
Keep the file concise — no more than ~50 lines. No badges, no table of contents, no verbose prose.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && test -f DEPLOYMENT.md && grep -q "env.production.example" DEPLOYMENT.md && grep -q "drizzle-kit migrate" DEPLOYMENT.md && grep -q "docker compose up" DEPLOYMENT.md && grep -q "api/health" DEPLOYMENT.md && echo "PASS"</automated>
</verify>
<done>DEPLOYMENT.md exists at teressa-copeland-homes/DEPLOYMENT.md covering env setup, migration from host, docker compose startup, and health check verification</done>
</task>
</tasks>
<verification>
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
```
Verify DEPLOYMENT.md covers all required steps:
```bash
grep -q "drizzle-kit migrate" DEPLOYMENT.md && grep -q "docker compose up" DEPLOYMENT.md && grep -q "env.production" DEPLOYMENT.md && echo "DEPLOYMENT.md complete"
```
</verification>
<success_criteria>
- 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/
- DEPLOYMENT.md documents: env setup, migration from host, docker compose up, health check
</success_criteria>
<output>
After completion, create `.planning/phases/17-docker-deployment/17-02-SUMMARY.md`
</output>