Files
red/.planning/research/ARCHITECTURE.md
2026-04-03 14:47:06 -06:00

667 lines
30 KiB
Markdown

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