394 lines
16 KiB
Markdown
394 lines
16 KiB
Markdown
---
|
|
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>
|