Files
taskplaner/.planning/phases/06-deployment/06-RESEARCH.md
Thomas Richter 94044cc49a 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
2026-02-01 13:03:07 +01:00

17 KiB

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:

npm install -D @sveltejs/adapter-node

Architecture Patterns

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:

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

// 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:

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

# 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

// 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

# 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

# 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

# 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

#!/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

// 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)

Secondary (MEDIUM confidence)

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)