From 94044cc49ad288bc0728aa866b4e544a96fae941 Mon Sep 17 00:00:00 2001 From: Thomas Richter Date: Sun, 1 Feb 2026 13:03:07 +0100 Subject: [PATCH] docs(06): research phase domain Phase 6: Deployment - Standard stack identified (adapter-node, node:22-alpine) - Multi-stage Dockerfile patterns documented - Native module (better-sqlite3, sharp) considerations - SQLite WAL mode and Docker volume best practices - Environment variable configuration patterns - Health check and backup script examples --- .planning/phases/06-deployment/06-RESEARCH.md | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 .planning/phases/06-deployment/06-RESEARCH.md diff --git a/.planning/phases/06-deployment/06-RESEARCH.md b/.planning/phases/06-deployment/06-RESEARCH.md new file mode 100644 index 0000000..c1b17c4 --- /dev/null +++ b/.planning/phases/06-deployment/06-RESEARCH.md @@ -0,0 +1,494 @@ +# Phase 6: Deployment - Research + +**Researched:** 2026-02-01 +**Domain:** Docker containerization, SvelteKit production deployment +**Confidence:** HIGH + +## Summary + +This phase containerizes the TaskPlaner SvelteKit application for production deployment with Docker. The research confirms a well-established pattern for deploying SvelteKit with adapter-node: multi-stage Alpine builds, runtime environment variables, health checks via API routes, and named volumes for data persistence. + +The key technical considerations are: +1. Switching from adapter-auto to adapter-node for production Node.js server +2. Native module compilation (better-sqlite3, sharp) requiring npm install inside the container +3. SQLite WAL mode working correctly with Docker named volumes (single host, same filesystem) +4. Environment variable configuration via adapter-node's built-in support + +**Primary recommendation:** Use a two-stage Dockerfile with node:22-alpine, run npm install in the builder stage to compile native modules correctly, and configure all runtime behavior via environment variables with sensible defaults. + +## Standard Stack + +The established libraries/tools for this domain: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| @sveltejs/adapter-node | ^5.x | SvelteKit Node.js server | Official adapter, supports all env vars natively | +| node:22-alpine | LTS | Docker base image | Small (~40MB vs 350MB+), includes 'node' user | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| dumb-init | ^1.2.5 | PID 1 init process | Optional: proper signal handling in containers | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| node:alpine | node:slim (debian) | Debian avoids musl issues, but 3x larger image | +| Built-in signals | tini/dumb-init | adapter-node handles SIGTERM natively, may not need | + +**Installation:** +```bash +npm install -D @sveltejs/adapter-node +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +project-root/ +├── Dockerfile # Multi-stage build +├── docker-compose.yml # Single-service compose +├── .dockerignore # Build context exclusions +├── .env.example # Environment variable template +├── backup.sh # Volume backup script +├── build/ # (gitignored) Production build output +└── data/ # (gitignored) Local dev data + ├── db/ + │ └── taskplaner.db + └── uploads/ + ├── originals/ + └── thumbnails/ +``` + +### Pattern 1: Multi-Stage Dockerfile +**What:** Separate build dependencies from runtime to minimize image size +**When to use:** Always for production Docker deployments +**Example:** +```dockerfile +# Source: https://gist.github.com/aradalvand/04b2cad14b00e5ffe8ec96a3afbb34fb +# Stage 1: Build +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build +RUN npm prune --production + +# Stage 2: Production +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app/build build/ +COPY --from=builder /app/node_modules node_modules/ +COPY package.json . +USER node +EXPOSE 3000 +ENV NODE_ENV=production +CMD ["node", "build"] +``` + +### Pattern 2: SvelteKit Health Check Endpoint +**What:** API route returning 200 for container health checks +**When to use:** Docker HEALTHCHECK, Kubernetes probes, load balancer checks +**Example:** +```typescript +// src/routes/health/+server.ts +// Source: https://svelte.dev/docs/kit/routing +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return new Response('ok', { status: 200 }); +}; +``` + +### Pattern 3: Runtime Environment Configuration +**What:** Configure app behavior via environment variables at runtime +**When to use:** All deployments - allows same image for different environments +**Example:** +```bash +# adapter-node reads these automatically +# Source: https://svelte.dev/docs/kit/adapter-node +PORT=3000 +HOST=0.0.0.0 +ORIGIN=http://localhost:3000 +BODY_SIZE_LIMIT=10M + +# Reverse proxy headers (when behind nginx/traefik) +PROTOCOL_HEADER=x-forwarded-proto +HOST_HEADER=x-forwarded-host +ADDRESS_HEADER=x-forwarded-for +XFF_DEPTH=1 +``` + +### Pattern 4: Named Volume for Data Persistence +**What:** Docker-managed volume mounted to container data directory +**When to use:** Any stateful data (database, uploads) +**Example:** +```yaml +# docker-compose.yml +services: + app: + volumes: + - taskplaner_data:/data + +volumes: + taskplaner_data: +``` + +### Anti-Patterns to Avoid +- **Copying node_modules from host:** Native modules compiled for host OS won't work in Alpine container. Always run npm install inside Dockerfile. +- **Bind mounts for production data:** Use named volumes for better portability and Docker management. +- **Running as root:** Security risk. Use `USER node` in production stage. +- **Hardcoded configuration:** Makes the image environment-specific. Use env vars. +- **Installing dev dependencies in production:** Bloats image. Use `npm prune --production` or `npm ci --only=production`. + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Environment variables | Custom config parser | adapter-node env vars | Built-in, documented, handles edge cases | +| Graceful shutdown | Signal handlers | adapter-node SHUTDOWN_TIMEOUT | Built-in, handles request draining | +| Health checks | Complex readiness probe | Simple /health route | Docker just needs HTTP 200 | +| Process management | supervisor/pm2 | Docker restart policies | Container-native, simpler | +| Logging to files | File appenders | stdout/stderr | Docker captures, aggregators consume | + +**Key insight:** adapter-node provides most production concerns out of the box. Don't add middleware or wrappers - use its environment variables. + +## Common Pitfalls + +### Pitfall 1: Native Module Architecture Mismatch +**What goes wrong:** App crashes with "Cannot find module" or "symbol not found" errors for better-sqlite3 or sharp +**Why it happens:** node_modules from macOS/Windows host copied to Linux container +**How to avoid:** +1. Add `node_modules` to `.dockerignore` +2. Run `npm ci` inside Dockerfile builder stage +3. For sharp on Alpine, npm will download musl-compatible prebuilt binaries automatically (sharp >= 0.33) +**Warning signs:** Errors mentioning `linuxmusl`, `glibc`, or module loading failures + +### Pitfall 2: SQLite WAL File Permissions +**What goes wrong:** "database is locked" or "read-only" errors +**Why it happens:** SQLite creates `-wal` and `-shm` files with same permissions as main db file. If user/group mismatch, can't access. +**How to avoid:** +1. Run container as `node` user (uid 1000) +2. Use named volumes (Docker manages permissions) +3. Ensure app creates data directories with correct ownership +**Warning signs:** Works first time, fails on restart; "attempt to write a readonly database" + +### Pitfall 3: ORIGIN Environment Variable Missing +**What goes wrong:** CSRF errors on form submissions: "Cross-site POST form submissions are forbidden" +**Why it happens:** SvelteKit validates Origin header against expected origin +**How to avoid:** Always set `ORIGIN` env var to match how users access the app +**Warning signs:** Forms work in development but fail in production + +### Pitfall 4: Missing Reverse Proxy Headers +**What goes wrong:** App sees internal Docker IPs instead of real client IPs; redirects go to wrong protocol/host +**Why it happens:** Default behavior ignores X-Forwarded-* headers for security +**How to avoid:** Set `PROTOCOL_HEADER`, `HOST_HEADER`, `ADDRESS_HEADER` only when behind trusted proxy +**Warning signs:** Logs show 172.x.x.x instead of real IPs; HTTPS redirects to HTTP + +### Pitfall 5: Volume Data Not Persisting +**What goes wrong:** Data disappears after container restart +**Why it happens:** Using anonymous volumes or not declaring volume in compose file +**How to avoid:** +1. Declare named volume in `volumes:` section of docker-compose.yml +2. Use consistent mount path in container +3. Don't use `docker-compose down -v` unless intentionally deleting data +**Warning signs:** Fresh database on each restart + +### Pitfall 6: Health Check Curl Not Installed +**What goes wrong:** Container marked unhealthy despite app running +**Why it happens:** Alpine doesn't include curl by default +**How to avoid:** Use wget (included in Alpine) or node for health checks +**Warning signs:** Container in "unhealthy" state but app responds normally + +## Code Examples + +Verified patterns from official sources: + +### svelte.config.js with adapter-node +```javascript +// Source: https://svelte.dev/docs/kit/adapter-node +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + out: 'build', + precompress: true, + envPrefix: 'TASKPLANER_' + }) + } +}; + +export default config; +``` + +### Complete Dockerfile +```dockerfile +# Stage 1: Build +FROM node:22-alpine AS builder +WORKDIR /app + +# Copy package files first for layer caching +COPY package*.json ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npm run build + +# Remove dev dependencies +RUN npm prune --production + +# Stage 2: Production +FROM node:22-alpine + +# Create app directory +WORKDIR /app + +# Copy built app and production dependencies +COPY --from=builder /app/build build/ +COPY --from=builder /app/node_modules node_modules/ +COPY package.json . + +# Create data directory and set ownership +RUN mkdir -p /data/db /data/uploads/originals /data/uploads/thumbnails \ + && chown -R node:node /data /app + +# Switch to non-root user +USER node + +# Environment defaults +ENV NODE_ENV=production +ENV PORT=3000 +ENV TASKPLANER_DATA_DIR=/data + +EXPOSE 3000 + +# Health check using wget (available in Alpine) +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +CMD ["node", "build"] +``` + +### docker-compose.yml +```yaml +# Source: https://docs.docker.com/reference/compose-file/ +services: + taskplaner: + build: . + container_name: taskplaner + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + volumes: + - taskplaner_data:/data + environment: + - NODE_ENV=production + - PORT=3000 + - TASKPLANER_DATA_DIR=/data + - ORIGIN=${ORIGIN:-http://localhost:3000} + # Uncomment when behind reverse proxy: + # - PROTOCOL_HEADER=x-forwarded-proto + # - HOST_HEADER=x-forwarded-host + # - ADDRESS_HEADER=x-forwarded-for + # - XFF_DEPTH=1 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + taskplaner_data: +``` + +### .dockerignore +``` +# Source: https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/ +# Dependencies - rebuild inside container +node_modules + +# Build outputs +build +.svelte-kit + +# Development files +.git +.gitignore +.env +.env.* +*.md +*.log + +# IDE/Editor +.vscode +.idea + +# Docker files (prevent recursion) +Dockerfile +.dockerignore +docker-compose*.yml + +# Test files +*.test.ts +*.spec.ts +coverage +``` + +### .env.example +```bash +# TaskPlaner Environment Configuration +# Copy to .env and customize for your deployment + +# === Server Configuration === +# Port the server listens on (inside container, map with docker-compose ports) +PORT=3000 + +# === Data Storage === +# Directory for database and uploads (must match volume mount) +TASKPLANER_DATA_DIR=/data + +# === Production URL === +# Required: The full URL where users access the app +# Used for CSRF validation and generating absolute URLs +ORIGIN=http://localhost:3000 + +# === Reverse Proxy (uncomment when behind nginx/traefik) === +# PROTOCOL_HEADER=x-forwarded-proto +# HOST_HEADER=x-forwarded-host +# ADDRESS_HEADER=x-forwarded-for +# XFF_DEPTH=1 + +# === Request Limits === +# Maximum request body size (default: 512kb) +# Supports K, M, G suffixes +BODY_SIZE_LIMIT=10M + +# === Logging === +# Log level: debug, info, warn, error +# TASKPLANER_LOG_LEVEL=info +``` + +### backup.sh +```bash +#!/bin/bash +# Source: https://docs.docker.com/engine/storage/volumes/ +# Backup TaskPlaner data volume + +set -e + +BACKUP_DIR="${BACKUP_DIR:-./backups}" +CONTAINER_NAME="${CONTAINER_NAME:-taskplaner}" +VOLUME_NAME="${VOLUME_NAME:-taskplaner_data}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/taskplaner_backup_${TIMESTAMP}.tar.gz" + +# Create backup directory if needed +mkdir -p "$BACKUP_DIR" + +echo "Backing up TaskPlaner data..." +echo "Volume: $VOLUME_NAME" +echo "Output: $BACKUP_FILE" + +# Create backup using temporary container +docker run --rm \ + -v "${VOLUME_NAME}:/data:ro" \ + -v "$(pwd)/${BACKUP_DIR}:/backup" \ + alpine:latest \ + tar czf "/backup/taskplaner_backup_${TIMESTAMP}.tar.gz" -C /data . + +echo "Backup complete: $BACKUP_FILE" +echo "Size: $(du -h "$BACKUP_FILE" | cut -f1)" +``` + +### Health Check Endpoint +```typescript +// src/routes/health/+server.ts +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { entries } from '$lib/server/db/schema'; + +export const GET: RequestHandler = async () => { + try { + // Verify database connectivity + db.select().from(entries).limit(1).all(); + return new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }); + } catch (error) { + return new Response('unhealthy', { + status: 503, + headers: { 'Content-Type': 'text/plain' } + }); + } +}; +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| adapter-auto | adapter-node for Docker | SvelteKit 2.x | Explicit Node.js server, env var support | +| COPY node_modules | npm ci in Dockerfile | Always best practice | Correct native module binaries | +| curl in healthcheck | wget (Alpine native) | Alpine best practice | No extra packages needed | +| Root user | node:node (uid 1000) | Security standard | Reduced attack surface | +| JSON config files | Environment variables | 12-factor app | Same image, different configs | + +**Deprecated/outdated:** +- `adapter-node@1.x`: Use 5.x for SvelteKit 2 compatibility +- Installing sharp with `--platform` flags: sharp 0.33+ auto-detects musl +- Manual signal handling: adapter-node handles SIGTERM/SIGINT natively + +## Open Questions + +Things that couldn't be fully resolved: + +1. **JSON logging integration** + - What we know: Pino is the standard for JSON logging in Node.js + - What's unclear: Pino has bundling issues with SvelteKit production builds (Worker Threads) + - Recommendation: Defer structured logging to a future enhancement. Console.log output goes to stdout and Docker captures it. Can add pino later if needed. + +2. **Drizzle migrations in production** + - What we know: Currently using `drizzle-kit push` for development + - What's unclear: Best practice for running migrations in Docker container + - Recommendation: For single-user app, continue using push mode via db initialization in hooks.server.ts. Schema is simple enough. Could add migration script later if needed. + +3. **Specific extended environment variables** + - What we know: CONTEXT.md says "extended config scope" beyond PORT/DATA_DIR + - What's unclear: Exactly which additional env vars users need + - Recommendation: Start with essentials (PORT, DATA_DIR, ORIGIN, BODY_SIZE_LIMIT, log level). Add more based on user feedback. + +## Sources + +### Primary (HIGH confidence) +- [SvelteKit adapter-node documentation](https://svelte.dev/docs/kit/adapter-node) - Environment variables, configuration, graceful shutdown +- [Docker official volumes documentation](https://docs.docker.com/engine/storage/volumes/) - Named volumes, backup procedures +- [Docker Compose services documentation](https://docs.docker.com/reference/compose-file/services/) - Health checks, configuration +- [Sharp installation documentation](https://sharp.pixelplumbing.com/install/) - Cross-platform, musl support + +### Secondary (MEDIUM confidence) +- [SvelteKit Dockerfile gist by aradalvand](https://gist.github.com/aradalvand/04b2cad14b00e5ffe8ec96a3afbb34fb) - Multi-stage build pattern +- [Snyk Node.js Docker best practices](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/) - Security, .dockerignore +- [Node.js docker-node best practices](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md) - Non-root user, Alpine considerations + +### Tertiary (LOW confidence) +- Various Medium/DEV articles on SvelteKit Docker deployment - General patterns confirmed by official docs + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - adapter-node is official, well-documented +- Architecture: HIGH - Multi-stage builds, named volumes are established Docker patterns +- Pitfalls: HIGH - Native modules, SQLite permissions are well-known issues with documented solutions +- Code examples: HIGH - Based on official documentation + +**Research date:** 2026-02-01 +**Valid until:** 2026-03-01 (30 days - Docker/SvelteKit patterns are stable)