# Phase 17: Docker Deployment - Context

**Gathered:** 2026-04-03
**Status:** Ready for planning

<domain>
## 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

</domain>

<decisions>
## 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)

</decisions>

<canonical_refs>
## 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

</canonical_refs>

<code_context>
## 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.

</code_context>

<specifics>
## 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.

</specifics>

<deferred>
## Deferred Ideas

None — discussion stayed within phase scope.

</deferred>

---

*Phase: 17-docker-deployment*
*Context gathered: 2026-04-03*
