# Architecture: Multi-Signer Extension + Docker Deployment

**Project:** teressa-copeland-homes
**Milestone:** v1.2 — Multi-Signer Support + Deployment Hardening
**Researched:** 2026-04-03
**Confidence:** HIGH — based on direct codebase inspection + official Docker/Next.js documentation

---

## Summary

The existing system is a clean, well-factored single-signer flow. Every document has exactly one signing token, one recipient, and one atomic "mark used" operation. Multi-signer requires four categories of change:

1. **Schema:** Tag fields to signers, expand signingTokens to identify whose token it is, replace the single-recipient model with a per-signer recipients structure.
2. **Completion detection:** Replace the single `usedAt` → `status = 'Signed'` trigger with a "all tokens claimed" check after each signing submission.
3. **Final PDF assembly:** The per-signer merge model (each signer embeds into the same prepared PDF, sequentially via advisory lock) accumulates signatures into `signedFilePath`. A completion pass fires after the last signer claims their token.
4. **Migration:** Existing signed documents must remain intact — achieved by treating the absence of `signerEmail` on a field as "legacy single-signer" (same as the existing `type` coalescing pattern already used in `getFieldType`).

Docker deployment has a distinct failure mode from local dev: **env vars that exist in `.env.local` are absent in the Docker container unless explicitly provided at runtime.** The email-sending failure in production Docker is caused by `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, and `CONTACT_EMAIL_PASS` never reaching the container. The fix is `env_file` injection at `docker compose up` time, not Docker Secrets (which mount as files, not env vars, and require app-side entrypoint shim code that adds no security benefit for a single-server deployment).

---

## Part 1: Multi-Signer Schema Changes

### 1. `SignatureFieldData` JSONB — add optional `signerEmail`

**Current shape (from `src/lib/db/schema.ts`):**
```typescript
interface SignatureFieldData {
  id: string;
  page: number;
  x: number;
  y: number;
  width: number;
  height: number;
  type?: SignatureFieldType;  // optional — v1.0 had no type
}
```

**New shape:**
```typescript
interface SignatureFieldData {
  id: string;
  page: number;
  x: number;
  y: number;
  width: number;
  height: number;
  type?: SignatureFieldType;
  signerEmail?: string;  // NEW — optional; absent = legacy single-signer or agent-owned field
}
```

**Backward compatibility:** The `signerEmail` field is optional. Existing documents stored in `signature_fields` JSONB have no `signerEmail`. The signing page already filters fields via `isClientVisibleField()`. A new `getSignerEmail(field, fallbackEmail)` helper mirrors `getFieldType()` and returns `field.signerEmail ?? fallbackEmail` — where `fallbackEmail` is the document's legacy single-recipient email. This keeps existing signed documents working without a data backfill.

**No SQL migration needed** for the JSONB column itself — it is already `jsonb`, schema-less at the DB level.

---

### 2. `signingTokens` table — add `signerEmail` column

**Current:**
```sql
CREATE TABLE signing_tokens (
  jti         text PRIMARY KEY,
  document_id text NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  created_at  timestamp DEFAULT now() NOT NULL,
  expires_at  timestamp NOT NULL,
  used_at     timestamp
);
```

**New column to add:**
```sql
ALTER TABLE signing_tokens ADD COLUMN signer_email text;
```

**Drizzle schema change:**
```typescript
export const signingTokens = pgTable('signing_tokens', {
  jti: text('jti').primaryKey(),
  documentId: text('document_id').notNull()
    .references(() => documents.id, { onDelete: 'cascade' }),
  signerEmail: text('signer_email'),   // NEW — null for legacy tokens
  createdAt: timestamp('created_at').defaultNow().notNull(),
  expiresAt: timestamp('expires_at').notNull(),
  usedAt: timestamp('used_at'),
});
```

**Why not store field IDs in the token?** Field filtering should happen server-side by matching `field.signerEmail === tokenRow.signerEmail`. Storing field IDs in the token creates a second source of truth and complicates migration. The signing GET endpoint already fetches the document's `signatureFields` and filters them — adding a `signerEmail` comparison is a one-line change.

**Backward compatibility:** `signer_email` is nullable. Existing tokens have `null`. The signing endpoint uses `tokenRow.signerEmail` to filter fields; `null` falls back to `isClientVisibleField()` (current behavior).

---

### 3. `documents` table — add `signers` JSONB column

**Current problem:** `assignedClientId` is a single-value text column. Signers in multi-signer are identified by email, not necessarily by a `clients` row (requirement: "signers may not be in clients table"). The current `emailAddresses` JSONB column holds email strings but lacks per-signer identity (name, signing status, token linkage).

**Decision: add a `signers` JSONB column; leave `assignedClientId` in place for legacy**

```sql
ALTER TABLE documents ADD COLUMN signers jsonb;
```

**New TypeScript type:**
```typescript
export interface DocumentSigner {
  email: string;
  name?: string;         // display name for email greeting, optional
  tokenJti?: string;     // populated at send time — links token back to signer record
  signedAt?: string;     // ISO timestamp — populated when their token is claimed
}
```

**Drizzle:**
```typescript
// In documents table:
signers: jsonb('signers').$type<DocumentSigner[]>(),
```

**Why JSONB array instead of a new table?** A `document_signers` join table would be cleanest long-term, but for a solo-agent app with document-level granularity and no need to query "all documents this email signed across the system", JSONB avoids an extra join on every document fetch. The `tokenJti` field on each signer record gives the bidirectional link without the join table.

**Why keep `assignedClientId`?** It is still used by the send route to resolve the `clients` row for `client.email` and `client.name`. For multi-signer, the agent provides emails directly in the `signers` array. The two flows coexist:
- Legacy: `assignedClientId` is set, `signers` is null → single-signer behavior
- New: `signers` is set (non-null, non-empty) → multi-signer behavior

The send route checks `if (doc.signers?.length) { /* multi-signer path */ } else { /* legacy path */ }`.

**`emailAddresses` column:** Currently stores `[client.email, ...ccAddresses]`. In multi-signer this is superseded by `signers[].email`. The column can remain and be ignored for new documents, or populated with all signer emails for audit reading consistency.

---

### 4. `auditEvents` — new event types

**Current enum values:**
```
document_prepared | email_sent | link_opened | document_viewed | signature_submitted | pdf_hash_computed
```

**New values to add:**

| Event Type | When Fired | Metadata |
|---|---|---|
| `signer_email_sent` | Per-signer email sent (supplements `email_sent` for multi-signer) | `{ signerEmail, tokenJti }` |
| `signer_signed` | Per-signer token claimed | `{ signerEmail }` |
| `document_completed` | All signers have signed — triggers final notification | `{ signerCount, mergedFilePath }` |

**Backward compatibility:** Postgres enums cannot have values removed, only added. The existing `email_sent` and `signature_submitted` events stay in the enum and continue to be fired for legacy single-signer documents. New multi-signer documents fire the new, more specific events. Adding values to a Postgres enum requires raw SQL that Drizzle cannot auto-generate:

```sql
ALTER TYPE audit_event_type ADD VALUE 'signer_email_sent';
ALTER TYPE audit_event_type ADD VALUE 'signer_signed';
ALTER TYPE audit_event_type ADD VALUE 'document_completed';
```

**Important:** In Postgres 12+, `ALTER TYPE ... ADD VALUE` can run inside a transaction. In Postgres < 12 it cannot. Write migration with `-- statement-breakpoint` between each ALTER to prevent Drizzle from wrapping them in a single transaction.

---

### 5. `documents` table — completion tracking columns unchanged

Multi-signer "completion" is no longer a single event. The existing columns serve all needs:

| Column | Current use | Multi-signer use |
|---|---|---|
| `status` | Draft → Sent → Viewed → Signed | "Signed" now means ALL signers complete. Status transitions to Signed only after `document_completed` fires. |
| `signedAt` | Timestamp of the single signing | Timestamp of completion (last signer claimed) — same semantic, set later. |
| `signedFilePath` | Path to the merged signed PDF | Accumulator path — updated by each signer as they embed; final value = completed PDF. |
| `pdfHash` | SHA-256 of signed PDF | Same — hash of the final merged PDF. |

Per-signer completion is tracked in `signers[].signedAt` (the JSONB array). No new columns required.

---

## Part 2: Multi-Signer Data Flow

### Field Tagging (Agent UI)

```
Agent places field on PDF canvas
  ↓
FieldPlacer shows signer email selector (from doc.signers[])
  ↓
SignatureFieldData.signerEmail = "buyer@example.com"
  ↓
PUT /api/documents/[id]/fields persists to signatureFields JSONB
```

**Chicken-and-egg consideration:** The agent must know the signer list before tagging fields. Resolution: the PreparePanel collects signer emails first (a new multi-signer entry UI replaces the single email textarea). These are saved to `documents.signers` via `PUT /api/documents/[id]/signers`. The FieldPlacer palette then offers a signer email selector when placing a client-visible field.

**Unassigned client fields:** If `signerEmail` is absent on a client-visible field in a multi-signer document, behavior must be defined. Recommended: block sending until all client-signature and initials fields have a `signerEmail`. The UI shows a warning. Text, checkbox, and date fields do not require a signer tag (they are embedded at prepare time and never shown to signers).

### Token Creation (Send)

```
Agent clicks "Prepare and Send"
  ↓
POST /api/documents/[id]/prepare
  - embeds agent signatures, text fills → preparedFilePath
  - reads doc.signers[] to confirm signer list exists
  ↓
POST /api/documents/[id]/send
  - if doc.signers?.length: multi-signer path
      Promise.all(doc.signers.map(signer => {
        createSigningToken(documentId, signer.email)
          → INSERT signing_tokens (jti, document_id, signer_email, expires_at)
        sendSigningRequestEmail({ to: signer.email, signingUrl: /sign/[token] })
        logAuditEvent('signer_email_sent', { signerEmail: signer.email, tokenJti: jti })
      }))
      update doc.signers[*].tokenJti
      set documents.status = 'Sent'
  - else: legacy single-signer path (unchanged)
```

### Signing Page (Per Signer)

```
Signer opens /sign/[token]
  ↓
GET /api/sign/[token]
  - verifySigningToken(token) → { documentId, jti }
  - fetch tokenRow (jti) → tokenRow.signerEmail
  - fetch doc.signatureFields
  - if tokenRow.signerEmail:
      filter fields where field.signerEmail === tokenRow.signerEmail AND isClientVisibleField
    else:
      filter fields with isClientVisibleField (legacy path — unchanged)
  - return { status: 'pending', document: { ...doc, signatureFields: filteredFields } }
  ↓
Signer sees only their fields; draws signatures; submits
  ↓
POST /api/sign/[token]
  1. Verify JWT
  2. Atomic claim: UPDATE signing_tokens SET used_at = NOW() WHERE jti = ? AND used_at IS NULL
     → 0 rows = 409 already-signed
  3. Acquire Postgres advisory lock on document ID (prevents concurrent PDF writes)
  4. Read current accumulatorPath = doc.signedFilePath ?? doc.preparedFilePath
  5. Embed this signer's signatures into accumulatorPath → write to new path
     (clients/{id}/{uuid}_partial.pdf, updated with atomic rename)
  6. Update doc.signedFilePath = new path
  7. Update doc.signers[signerEmail].signedAt = now
  8. Release advisory lock
  9. Check completion: COUNT(signing_tokens WHERE document_id = ? AND used_at IS NOT NULL)
                       vs COUNT(signing_tokens WHERE document_id = ?)
  10a. Not all signed: logAuditEvent('signer_signed'); return 200
  10b. All signed (completion):
       - Compute pdfHash of final signedFilePath
       - UPDATE documents SET status='Signed', signedAt=now, pdfHash=hash
       - logAuditEvent('signer_signed')
       - logAuditEvent('document_completed', { signerCount, mergedFilePath })
       - sendAgentNotificationEmail (all signed)
       - sendAllSignersCompletionEmail (each signer receives final PDF link)
       - return 200
```

### Advisory Lock Implementation

```typescript
// Within the signing POST, wrap the PDF write in an advisory lock:
await db.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${documentId}))`);
// All subsequent DB operations in this transaction hold the lock.
// Lock released automatically when transaction commits or rolls back.
```

Drizzle's `db.execute(sql`...`)` supports raw SQL. `pg_advisory_xact_lock` is a session-level transaction lock — safe for this use case.

---

## Part 3: Migration Strategy

### Existing signed documents — no action required

The `signerEmail` field is absent from all existing `signatureFields` JSONB. For existing tokens, `signer_email = null`. The signing endpoint's null path falls through to `isClientVisibleField()` — identical to current behavior. Existing documents never enter multi-signer code paths.

### Migration file (single file, order matters)

Write as `drizzle/0010_multi_signer.sql`:

```sql
-- 1. Expand signing_tokens
ALTER TABLE "signing_tokens" ADD COLUMN "signer_email" text;

-- statement-breakpoint

-- 2. Add signers JSONB to documents
ALTER TABLE "documents" ADD COLUMN "signers" jsonb;

-- statement-breakpoint

-- 3. Expand audit event enum
-- Must be outside a transaction in Postgres < 12 — use statement-breakpoint
ALTER TYPE "audit_event_type" ADD VALUE 'signer_email_sent';

-- statement-breakpoint

ALTER TYPE "audit_event_type" ADD VALUE 'signer_signed';

-- statement-breakpoint

ALTER TYPE "audit_event_type" ADD VALUE 'document_completed';
```

**No backfill required.** Existing rows have `null` for new columns, which is the correct legacy sentinel value at every call site.

### TypeScript changes after migration

1. Add `signerEmail?: string` to `SignatureFieldData` interface
2. Add `DocumentSigner` interface
3. Add `signers` column to `documents` Drizzle table definition
4. Add `signerEmail` to `signingTokens` Drizzle table definition
5. Add three values to `auditEventTypeEnum` array in schema
6. Add `getSignerEmail(field, fallback)` helper function

All changes are additive. No existing function signatures break.

---

## Part 4: Multi-Signer Build Order

Each step is independently deployable. Deploy schema migration first, then backend changes, then UI.

```
Step 1: DB migration (0010_multi_signer.sql)
  → System: DB ready. App unchanged. No user impact.

Step 2: Schema TypeScript + token layer
  - Add DocumentSigner type, signerEmail to SignatureFieldData
  - Update signingTokens and documents Drizzle definitions
  - Update createSigningToken(documentId, signerEmail?)
  - Add auditEventTypeEnum new values
  → System: Token creation accepts signer email. All existing behavior unchanged.

Step 3: Signing GET endpoint — field filtering
  - Read tokenRow.signerEmail
  - Filter signatureFields by signerEmail (null → legacy)
  → System: Signing page shows correct fields per signer. Legacy tokens unaffected.

Step 4: Signing POST endpoint — accumulator + completion
  - Add advisory lock
  - Add accumulator path logic
  - Add completion check
  - Add document_completed event + notifications
  → System: Multi-signer signing flow complete end-to-end. Single-signer legacy unchanged.

Step 5: Send route — per-signer token loop
  - Detect doc.signers vs legacy
  - Loop: create token + send email per signer
  - Log signer_email_sent per signer
  → System: New documents get per-signer tokens. Old documents still use legacy path.

Step 6: New endpoint — PUT /api/documents/[id]/signers
  - Validate email array
  - Update documents.signers JSONB
  → System: Agent can set signer list from UI.

Step 7: UI — PreparePanel signer list
  - Replace single email textarea with name+email rows (add/remove)
  - Call PUT /api/documents/[id]/signers on change
  - Warn if client-visible fields lack signerEmail
  → System: Agent can define signers before placing fields.

Step 8: UI — FieldPlacer signer tagging
  - Add signerEmail selector per client-visible field
  - Color-code placed fields by signer
  - Pass signerEmail through persistFields
  → System: Full multi-signer field placement.

Step 9: Email — completion notifications
  - sendAllSignersCompletionEmail in signing-mailer.tsx
  - Update sendAgentNotificationEmail for completion context
  → System: All parties notified and receive final PDF link.

Step 10: End-to-end verification
  - Test with two signers on a real Utah form
  - Verify field isolation, sequential PDF accumulation, final hash
```

---

## Part 5: Multi-Signer Components — New vs Modified

### Modified

| Component | File | Nature of Change |
|---|---|---|
| `SignatureFieldData` interface | `src/lib/db/schema.ts` | Add `signerEmail?: string` |
| `auditEventTypeEnum` | `src/lib/db/schema.ts` | Add 3 new values |
| `signingTokens` Drizzle table | `src/lib/db/schema.ts` | Add `signerEmail` column |
| `documents` Drizzle table | `src/lib/db/schema.ts` | Add `signers` column |
| `createSigningToken()` | `src/lib/signing/token.ts` | Add `signerEmail?` param; INSERT includes it |
| GET `/api/sign/[token]` | `src/app/api/sign/[token]/route.ts` | Signer-aware field filtering (null path = legacy) |
| POST `/api/sign/[token]` | `src/app/api/sign/[token]/route.ts` | Accumulator PDF logic, advisory lock, completion check, completion notifications |
| POST `/api/documents/[id]/send` | `src/app/api/documents/[id]/send/route.ts` | Per-signer token + email loop; legacy path preserved |
| `PreparePanel` | `src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` | Multi-signer list entry UI (replaces single textarea) |
| `FieldPlacer` | `src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` | Signer email selector on field place; per-signer color coding |
| `signing-mailer.tsx` | `src/lib/signing/signing-mailer.tsx` | Add `sendAllSignersCompletionEmail` function |

### New

| Component | File | Purpose |
|---|---|---|
| `DocumentSigner` interface | `src/lib/db/schema.ts` | Shape of `documents.signers[]` JSONB entries |
| `getSignerEmail()` helper | `src/lib/db/schema.ts` | Returns `field.signerEmail ?? fallback`; mirrors `getFieldType()` pattern |
| PUT `/api/documents/[id]/signers` | `src/app/api/documents/[id]/signers/route.ts` | Save/update signer list on the document |
| Migration file | `drizzle/0010_multi_signer.sql` | All DB schema changes in one file |

### Not Changed

| Component | Reason |
|---|---|
| `embedSignatureInPdf()` | Works on any path; accumulator pattern reuses it as-is |
| `verifySigningToken()` | JWT payload unchanged; `signerEmail` is DB-only, not a JWT claim |
| `logAuditEvent()` | Accepts any enum value; new values are additive |
| `isClientVisibleField()` | Logic unchanged; still used for legacy null-signer tokens |
| GET `/api/sign/[token]/pdf` | Serves prepared PDF; no signer-specific logic needed |
| `clients` table | Signers are email-identified, not FK-linked |
| `preparePdf()` / prepare endpoint | Unchanged; accumulation happens during signing, not preparation |

---

## Part 6: Docker Compose — Secrets and Environment Variables

### The Core Problem

The existing email failure in Docker production is a **runtime env var injection gap**: `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, `CONTACT_EMAIL_PASS` (and `CONTACT_SMTP_PORT`) exist in `.env.local` during development but are never passed to the Docker container at runtime. The nodemailer transporter in `src/lib/signing/signing-mailer.tsx` reads these directly from `process.env`. When they are undefined, `nodemailer.createTransport()` silently creates a transporter with no credentials, and `sendMail()` fails at send time.

### Why Not Docker Secrets (file-based)?

Docker Compose's `secrets:` block mounts secret values as files at `/run/secrets/<name>` inside the container. This is designed for Docker Swarm and serves a specific security model (encrypted transport in Swarm, no env var exposure in `docker inspect`). It requires the application to read from the filesystem instead of `process.env`. Bridging this to `process.env` requires an entrypoint shell script that reads each `/run/secrets/` file and exports its value before starting `node server.js`.

For this deployment (single VPS, secrets are SSH-managed files on the server, not Swarm), the file-based secrets approach adds complexity with no meaningful security benefit over a properly permissioned `.env.production` file. **Use `env_file` injection, not `secrets:`.**

**Decision:** Confirmed approach is `env_file:` with a server-side `.env.production` file (not committed to git, permissions 600, owned by deploy user).

### Environment Variable Classification

Not all env vars are equal. Next.js has two distinct categories:

| Category | Prefix | When Evaluated | Who Can Read | Example |
|---|---|---|---|---|
| Server-only runtime | (none) | At request time, via `process.env` | API routes, Server Components, Route Handlers | `DATABASE_URL`, `CONTACT_SMTP_HOST`, `OPENAI_API_KEY` |
| Public build-time | `NEXT_PUBLIC_` | At `next build` — inlined into JS bundle | Client-side code | (none in this app) |

This app has **no `NEXT_PUBLIC_` variables.** All secrets are server-only and evaluated at request time. They do not need to be present at `docker build` time — only at `docker run` / `docker compose up` time. This is the ideal case: the same Docker image can run in any environment by providing different `env_file` values.

**Verified:** Next.js App Router server-side code (`process.env.X` in API routes, Server Components) reads env vars at request time when the route is dynamically rendered. Source: [Next.js deploying docs](https://nextjs.org/docs/app/getting-started/deploying), [vercel/next.js docker-compose example](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose).

### Required Secrets for Production

Derived from `.env.local` inspection — all server-only, none `NEXT_PUBLIC_`:

```
DATABASE_URL          — Neon PostgreSQL connection string
SIGNING_JWT_SECRET    — JWT signing key for signing tokens
AUTH_SECRET           — Next Auth / Iron Session secret
AGENT_EMAIL           — Agent login email
AGENT_PASSWORD        — Agent login password hash seed
BLOB_READ_WRITE_TOKEN — Vercel Blob storage token
CONTACT_EMAIL_USER    — SMTP username (fixes email delivery bug)
CONTACT_EMAIL_PASS    — SMTP password (fixes email delivery bug)
CONTACT_SMTP_HOST     — SMTP host (fixes email delivery bug)
CONTACT_SMTP_PORT     — SMTP port (fixes email delivery bug)
OPENAI_API_KEY        — GPT-4.1 for AI field placement
```

`SKYSLOPE_*` and `URE_*` credentials are script-only (seed/scrape scripts), not needed in the production container.

### Compose File Structure

**`docker-compose.yml`** (production):

```yaml
services:
  app:
    build:
      context: ./teressa-copeland-homes
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "3000:3000"
    env_file:
      - .env.production          # server-side secrets — NOT committed to git
    environment:
      NODE_ENV: production
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider",
             "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
```

**`.env.production`** (on server, never committed):

```bash
DATABASE_URL=postgres://...
SIGNING_JWT_SECRET=...
AUTH_SECRET=...
AGENT_EMAIL=teressa@...
AGENT_PASSWORD=...
BLOB_READ_WRITE_TOKEN=...
CONTACT_EMAIL_USER=...
CONTACT_EMAIL_PASS=...
CONTACT_SMTP_HOST=smtp.fastmail.com
CONTACT_SMTP_PORT=465
OPENAI_API_KEY=sk-...
```

**`.gitignore`** must include:
```
.env.production
.env.production.local
.env.local
```

### Dockerfile — next.config.ts Change Required

The current `next.config.ts` does not set `output: 'standalone'`. The standalone output is required for the official multi-stage Docker pattern — it produces a self-contained `server.js` with only necessary files, yielding a ~60-80% smaller production image compared to copying all of `node_modules`.

**Change needed in `next.config.ts`:**
```typescript
const nextConfig: NextConfig = {
  output: 'standalone',          // ADD THIS
  transpilePackages: ['react-pdf', 'pdfjs-dist'],
  serverExternalPackages: ['@napi-rs/canvas'],
};
```

**Caution:** `@napi-rs/canvas` is a native addon. Verify the production base image (Debian slim recommended, not Alpine) has the required glibc version. Alpine uses musl libc which is incompatible with pre-built `@napi-rs/canvas` binaries. The official canary Dockerfile uses `node:24-slim` (Debian).

### Dockerfile — Recommended Pattern

Three-stage build based on the [official Next.js canary example](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile):

```dockerfile
ARG NODE_VERSION=20-slim

# Stage 1: Install dependencies
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund

# Stage 2: Build
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
# No NEXT_PUBLIC_ vars needed — all secrets are server-only runtime vars
RUN npm run build

# Stage 3: Production runner
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node
EXPOSE 3000
CMD ["node", "server.js"]
```

**No `ARG`/`ENV` lines for secrets in the Dockerfile.** Secrets are never baked into the image. They arrive exclusively at runtime via `env_file:` in the Compose file.

### Health Check Endpoint

Required for the Compose `healthcheck:` to work. Create at `src/app/api/health/route.ts`:

```typescript
export async function GET() {
  return Response.json({ status: 'ok', uptime: process.uptime() });
}
```

`wget` is available in the `node:20-slim` (Debian) image. `curl` is not installed by default in slim images; `wget` is more reliable without a separate `RUN apt-get install curl`.

### `.dockerignore`

Prevents secrets and large dirs from entering the build context:

```
node_modules
.next
.env*
uploads/
seeds/
scripts/
drizzle/
*.png
*.pdf
```

### Deployment Procedure (First Time)

```
1. SSH into VPS
2. git clone (or git pull) the repo
3. Create .env.production with all secrets (chmod 600 .env.production)
4. Run database migration: docker compose run --rm app npm run db:migrate
   (or run migration against Neon directly before starting)
5. docker compose build
6. docker compose up -d
7. docker compose logs -f app  (verify email sends on first signing test)
```

### Common Pitfall: `db:migrate` in Container

Drizzle `db:migrate` reads `DATABASE_URL` from env. In the container, this is provided via `env_file:`. Run migration as a one-off:

```bash
docker compose run --rm app node -e "
  const { drizzle } = require('drizzle-orm/neon-http');
  const { migrate } = require('drizzle-orm/neon-http/migrator');
  // ...
"
```

Or more practically: run `npx drizzle-kit migrate` from the host with `DATABASE_URL` set in the shell, pointing at the production Neon database, before deploying the new container. This avoids needing `drizzle-kit` inside the production image.

---

## Part 7: Multi-Signer Key Risks and Mitigations

| Risk | Severity | Mitigation |
|---|---|---|
| Two signers submit simultaneously, both read same PDF | HIGH | Postgres advisory lock `pg_advisory_xact_lock(hashtext(documentId))` on signing POST |
| Accumulator path tracking lost between signers | MEDIUM | `documents.signedFilePath` always tracks current accumulator; null = use preparedFilePath |
| Agent sends before all fields tagged to signers | MEDIUM | PreparePanel validates: block send if any client-visible field has no `signerEmail` in a multi-signer document |
| `ALTER TYPE ADD VALUE` in Postgres < 12 fails in transaction | MEDIUM | Use `-- statement-breakpoint` between each ALTER; verify Postgres version |
| Resending to a signer (token expired) | LOW | Issue new token via a resend endpoint; existing tokens remain valid |
| Legacy documents break | LOW | `signerEmail` optional at every layer; null path = unchanged behavior throughout |

## Part 8: Docker Key Risks and Mitigations

| Risk | Severity | Mitigation |
|---|---|---|
| `.env.production` committed to git | HIGH | `.gitignore` entry required; never add to repo |
| `@napi-rs/canvas` binary incompatible with Alpine | HIGH | Use `node:20-slim` (Debian), not `node:20-alpine` |
| Secrets baked into Docker image layer | MEDIUM | Zero `ARG`/`ENV` secret lines in Dockerfile; all secrets via `env_file:` at compose up |
| `standalone` output omits required files | MEDIUM | Test locally with `output: 'standalone'` before pushing; watch for missing static assets |
| Health check uses `curl` (not in slim image) | LOW | Use `wget` in healthcheck command — present in Debian slim |
| Migration runs against wrong DB | LOW | Run `drizzle-kit migrate` from host against Neon URL before container start; never inside production image |

---

## Sources

- [Docker Compose Secrets — Official Docs](https://docs.docker.com/compose/how-tos/use-secrets/) — HIGH confidence
- [Next.js with-docker official example — Dockerfile (canary)](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile) — HIGH confidence
- [Next.js with-docker-compose official example (canary)](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose) — HIGH confidence
- [Next.js env var classification (runtime vs build-time)](https://nextjs.org/docs/pages/guides/environment-variables) — HIGH confidence
- Direct codebase inspection: `src/lib/signing/signing-mailer.tsx`, `src/lib/db/schema.ts`, `.env.local` key names, `next.config.ts` — HIGH confidence

---
*Architecture research for: teressa-copeland-homes v1.2*
*Researched: 2026-04-03*
