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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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)
|
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
|
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:
|
Plans:
|
||||||
- [x] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route
|
- [ ] 17-01-PLAN.md — Enable standalone output, limit DB pool to 5, remove @vercel/blob, add /api/health endpoint
|
||||||
- [ ] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename
|
- [ ] 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
|
||||||
- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications
|
|
||||||
**UI hint**: no
|
**UI hint**: no
|
||||||
|
|
||||||
## Progress
|
## 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 |
|
| 14. Multi-Signer Schema | v1.2 | 1/1 | Complete | 2026-04-03 |
|
||||||
| 15. Multi-Signer Backend | v1.2 | 3/3 | 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 |
|
| 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 | - |
|
||||||
|
|||||||
170
.planning/phases/17-docker-deployment/17-01-PLAN.md
Normal file
170
.planning/phases/17-docker-deployment/17-01-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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
|
||||||
|
</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
|
||||||
|
|
||||||
|
@teressa-copeland-homes/next.config.ts
|
||||||
|
@teressa-copeland-homes/src/lib/db/index.ts
|
||||||
|
@teressa-copeland-homes/package.json
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Enable standalone output, limit DB pool, remove @vercel/blob</name>
|
||||||
|
<files>teressa-copeland-homes/next.config.ts, teressa-copeland-homes/src/lib/db/index.ts, teressa-copeland-homes/package.json</files>
|
||||||
|
<read_first>
|
||||||
|
- teressa-copeland-homes/next.config.ts
|
||||||
|
- teressa-copeland-homes/src/lib/db/index.ts
|
||||||
|
- teressa-copeland-homes/package.json
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>next.config.ts has standalone output, db pool is limited to 5, @vercel/blob removed from package.json</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create /api/health endpoint with DB connectivity check</name>
|
||||||
|
<files>teressa-copeland-homes/src/app/api/health/route.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- teressa-copeland-homes/src/lib/db/index.ts
|
||||||
|
- teressa-copeland-homes/src/lib/db/schema.ts (first 20 lines, for import pattern)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>GET /api/health endpoint exists, runs SELECT 1 via Drizzle, returns 200/503 based on DB reachability</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
All code changes compile:
|
||||||
|
```bash
|
||||||
|
cd teressa-copeland-homes && npx tsc --noEmit
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-docker-deployment/17-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
343
.planning/phases/17-docker-deployment/17-02-PLAN.md
Normal file
343
.planning/phases/17-docker-deployment/17-02-PLAN.md
Normal file
@@ -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'
|
||||||
|
---
|
||||||
|
|
||||||
|
<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, 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
|
||||||
|
</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>
|
||||||
|
|
||||||
|
</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
|
||||||
|
```
|
||||||
|
</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/
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-docker-deployment/17-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user