diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 483dea2..4f8ea93 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -122,11 +122,11 @@ Plans: 3. Data persists across container restarts via named volumes 4. Single docker-compose.yml starts the entire application 5. Backup of data directory preserves all entries and images -**Plans**: TBD +**Plans**: 2 plans Plans: -- [ ] 06-01: TBD -- [ ] 06-02: TBD +- [ ] 06-01-PLAN.md — Docker configuration with adapter-node, Dockerfile, and docker-compose.yml +- [ ] 06-02-PLAN.md — Health endpoint, environment documentation, and backup script ## Progress diff --git a/.planning/phases/06-deployment/06-01-PLAN.md b/.planning/phases/06-deployment/06-01-PLAN.md new file mode 100644 index 0000000..68a8dab --- /dev/null +++ b/.planning/phases/06-deployment/06-01-PLAN.md @@ -0,0 +1,320 @@ +--- +phase: 06-deployment +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - svelte.config.js + - Dockerfile + - .dockerignore + - docker-compose.yml +autonomous: true + +must_haves: + truths: + - "Application builds with adapter-node for production Node.js server" + - "Docker image is multi-stage with small Alpine base (~150MB)" + - "Container runs as non-root 'node' user" + - "docker-compose up -d starts the application" + artifacts: + - path: "svelte.config.js" + provides: "adapter-node configuration with TASKPLANER_ prefix" + contains: "adapter-node" + - path: "Dockerfile" + provides: "Multi-stage build for production" + contains: "FROM node:22-alpine" + - path: ".dockerignore" + provides: "Build context exclusions" + contains: "node_modules" + - path: "docker-compose.yml" + provides: "Single-service compose with named volume" + contains: "taskplaner_data" + key_links: + - from: "svelte.config.js" + to: "adapter-node" + via: "adapter import and configuration" + pattern: "adapter-node" + - from: "Dockerfile" + to: "docker-compose.yml" + via: "build context" + pattern: "build: \\." +--- + + +Docker configuration for SvelteKit production deployment + +Purpose: Enable the application to run in a Docker container with proper production settings, multi-stage build for small image size, and non-root user for security. + +Output: Dockerfile, docker-compose.yml, .dockerignore, and updated svelte.config.js with adapter-node + + + +@/home/tho/.claude/get-shit-done/workflows/execute-plan.md +@/home/tho/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-deployment/06-RESEARCH.md + +Key existing patterns: +- Database at ./data/taskplaner.db with WAL mode +- Image storage at ./data/uploads/originals and ./data/uploads/thumbnails +- DATABASE_PATH env var already used in src/lib/server/db/index.ts + + + + + + Task 1: Switch to adapter-node with environment prefix + svelte.config.js, package.json + +1. Install adapter-node as dev dependency: + ```bash + npm install -D @sveltejs/adapter-node + ``` + +2. Update svelte.config.js: + - Import adapter from '@sveltejs/adapter-node' (not adapter-auto) + - Configure adapter with: + - out: 'build' + - precompress: true (gzip/brotli static assets) + - envPrefix: 'TASKPLANER_' (custom env var prefix) + +Example: +```javascript +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; +``` + +NOTE: Keep adapter-auto in devDependencies as fallback for non-Docker dev environments. adapter-node is what the Dockerfile will use. + + + - `npm run build` completes successfully + - `build/` directory is created with server files + - `ls build/` shows index.js, handler.js, etc. + + svelte.config.js uses adapter-node, npm run build produces build/ directory + + + + Task 2: Create Docker configuration files + Dockerfile, .dockerignore, docker-compose.yml + +1. Create .dockerignore with exclusions: + ``` + # Dependencies - rebuild inside container for correct architecture + node_modules + + # Build outputs + build + .svelte-kit + + # Development/local data + data + .git + .gitignore + .env + .env.* + + # Documentation + *.md + + # IDE/Editor + .vscode + .idea + + # Logs + *.log + + # Docker files (prevent recursion) + Dockerfile + .dockerignore + docker-compose*.yml + + # Planning docs + .planning + ``` + +2. Create Dockerfile with multi-stage build: + ```dockerfile + # Stage 1: Build + FROM node:22-alpine AS builder + WORKDIR /app + + # Copy package files for layer caching + COPY package*.json ./ + RUN npm ci + + # Copy source and build + COPY . . + RUN npm run build + + # Remove dev dependencies for smaller production image + RUN npm prune --production + + # Stage 2: Production + FROM node:22-alpine + 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 directories and set ownership + # App expects /data/db for database and /data/uploads for images + RUN mkdir -p /data/db /data/uploads/originals /data/uploads/thumbnails \ + && chown -R node:node /data /app + + # Switch to non-root user for security + 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, curl is not) + 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"] + ``` + +3. Create docker-compose.yml: + ```yaml + 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} + - BODY_SIZE_LIMIT=10M + # Uncomment when behind reverse proxy (nginx/traefik): + # - PROTOCOL_HEADER=x-forwarded-proto + # - HOST_HEADER=x-forwarded-host + # - ADDRESS_HEADER=x-forwarded-for + # - XFF_DEPTH=1 + + volumes: + taskplaner_data: + ``` + +IMPORTANT: The Dockerfile HEALTHCHECK references /health endpoint which will be created in Plan 02. The container will show "unhealthy" until Plan 02 adds that endpoint, but the app will still run. + + + - `docker build -t taskplaner .` completes without errors + - Image size is under 250MB: `docker images taskplaner` + - `docker-compose config` shows valid configuration + + Dockerfile builds successfully, docker-compose.yml is valid YAML, .dockerignore excludes node_modules and build outputs + + + + Task 3: Update data paths for environment variable configuration + src/lib/server/db/index.ts, src/lib/server/images/storage.ts + +1. Update src/lib/server/db/index.ts to use TASKPLANER_DATA_DIR: + ```typescript + import Database from 'better-sqlite3'; + import { drizzle } from 'drizzle-orm/better-sqlite3'; + import * as schema from './schema'; + import { existsSync, mkdirSync } from 'fs'; + import { dirname, join } from 'path'; + + // Data directory from env (Docker: /data, local: ./data) + const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data'; + const DB_PATH = process.env.DATABASE_PATH || join(DATA_DIR, 'db', 'taskplaner.db'); + + // Ensure data directory exists + const dbDir = dirname(DB_PATH); + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + const sqlite = new Database(DB_PATH); + + // Enable WAL mode for better concurrent read performance + sqlite.pragma('journal_mode = WAL'); + + export const db = drizzle(sqlite, { schema }); + + export { schema }; + ``` + +2. Update src/lib/server/images/storage.ts to use TASKPLANER_DATA_DIR: + ```typescript + import { mkdir, writeFile, unlink } from 'node:fs/promises'; + import { join } from 'node:path'; + + // Data directory from env (Docker: /data, local: ./data) + const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data'; + + export const UPLOAD_DIR = join(DATA_DIR, 'uploads'); + export const ORIGINALS_DIR = join(DATA_DIR, 'uploads', 'originals'); + export const THUMBNAILS_DIR = join(DATA_DIR, 'uploads', 'thumbnails'); + + // Rest of file unchanged... + ``` + +This makes paths configurable: +- Local development: Uses ./data (default) +- Docker: Uses /data (from TASKPLANER_DATA_DIR env var) +- DATABASE_PATH still works as override for backward compatibility + + + - `npm run dev` still works (uses ./data default) + - `npm run build` completes without type errors + - Database and uploads work in development mode + + Data paths read from TASKPLANER_DATA_DIR env var with ./data fallback for local development + + + + + +1. Build completes: `npm run build` produces build/ directory +2. Docker build works: `docker build -t taskplaner .` +3. Image is small: `docker images taskplaner` shows < 250MB +4. Local dev still works: `npm run dev` uses ./data directory +5. Compose is valid: `docker-compose config` shows no errors + + + +- svelte.config.js uses adapter-node with TASKPLANER_ prefix +- Dockerfile uses multi-stage build with node:22-alpine +- Container runs as non-root 'node' user +- docker-compose.yml starts app with named volume for /data +- Data paths are configurable via TASKPLANER_DATA_DIR +- Local development still works with default ./data paths + + + +After completion, create `.planning/phases/06-deployment/06-01-SUMMARY.md` + diff --git a/.planning/phases/06-deployment/06-02-PLAN.md b/.planning/phases/06-deployment/06-02-PLAN.md new file mode 100644 index 0000000..6c32656 --- /dev/null +++ b/.planning/phases/06-deployment/06-02-PLAN.md @@ -0,0 +1,384 @@ +--- +phase: 06-deployment +plan: 02 +type: execute +wave: 2 +depends_on: ["06-01"] +files_modified: + - src/routes/health/+server.ts + - .env.example + - backup.sh + - README.md +autonomous: true + +must_haves: + truths: + - "Health endpoint returns 200 when database is accessible" + - "Health endpoint returns 503 when database fails" + - "Environment variables are documented with examples" + - "Backup script creates timestamped archive of data volume" + artifacts: + - path: "src/routes/health/+server.ts" + provides: "Health check endpoint for Docker" + exports: ["GET"] + - path: ".env.example" + provides: "Environment variable documentation" + contains: "TASKPLANER_DATA_DIR" + - path: "backup.sh" + provides: "Volume backup script" + contains: "tar czf" + key_links: + - from: "Dockerfile" + to: "src/routes/health/+server.ts" + via: "HEALTHCHECK wget command" + pattern: "/health" + - from: "docker-compose.yml" + to: ".env.example" + via: "environment variable reference" + pattern: "ORIGIN" +--- + + +Runtime configuration with health checks, environment documentation, and backup tooling + +Purpose: Complete the production deployment setup with health monitoring for Docker, clear documentation of configuration options, and a backup script for data preservation. + +Output: /health endpoint, .env.example template, backup.sh script, updated README with Docker instructions + + + +@/home/tho/.claude/get-shit-done/workflows/execute-plan.md +@/home/tho/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-deployment/06-RESEARCH.md +@.planning/phases/06-deployment/06-01-SUMMARY.md + +Key from Plan 01: +- Dockerfile has HEALTHCHECK pointing to /health +- Data paths use TASKPLANER_DATA_DIR env var +- docker-compose.yml uses named volume taskplaner_data + + + + + + Task 1: Create health check endpoint + src/routes/health/+server.ts + +Create src/routes/health/+server.ts: + +```typescript +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 with a simple query + db.select().from(entries).limit(1).all(); + + return new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }); + } catch (error) { + console.error('Health check failed:', error); + + return new Response('unhealthy', { + status: 503, + headers: { 'Content-Type': 'text/plain' } + }); + } +}; +``` + +The endpoint: +- Returns 200 "ok" when database is accessible +- Returns 503 "unhealthy" when database query fails +- Logs errors for debugging but doesn't expose details +- Uses simple text response (Docker just needs status code) + + + - `npm run dev` and visit http://localhost:5173/health returns "ok" + - Health endpoint returns 200 status code + - Database query is executed (visible in dev server logs on first request) + + /health endpoint returns 200 with database connectivity check + + + + Task 2: Create environment documentation and backup script + .env.example, backup.sh + +1. Create .env.example: + ```bash + # TaskPlaner Environment Configuration + # Copy to .env and customize for your deployment + + # ============================================ + # Server Configuration + # ============================================ + + # Port the server listens on (inside container) + # Map to host port via docker-compose ports setting + PORT=3000 + + # ============================================ + # Data Storage + # ============================================ + + # Directory for database and uploads + # Docker: /data (must match volume mount) + # Local development: ./data + TASKPLANER_DATA_DIR=/data + + # Optional: Direct database path override + # DATABASE_PATH=/data/db/taskplaner.db + + # ============================================ + # Production URL (REQUIRED for production) + # ============================================ + + # The full URL where users access the app + # Used for CSRF validation and generating absolute URLs + # Example: https://tasks.example.com + ORIGIN=http://localhost:3000 + + # ============================================ + # Request Limits + # ============================================ + + # Maximum request body size + # Supports K, M, G suffixes + # Default: 512kb, recommended for images: 10M + BODY_SIZE_LIMIT=10M + + # ============================================ + # Reverse Proxy Configuration + # Uncomment when running behind nginx/traefik/etc + # ============================================ + + # Header containing original protocol (http/https) + # PROTOCOL_HEADER=x-forwarded-proto + + # Header containing original host + # HOST_HEADER=x-forwarded-host + + # Header containing original client IP + # ADDRESS_HEADER=x-forwarded-for + + # Number of trusted proxies in front of app + # XFF_DEPTH=1 + ``` + +2. Create backup.sh: + ```bash + #!/bin/bash + # TaskPlaner Data Backup Script + # Creates a timestamped backup of the Docker volume + + set -e + + # Configuration (override via environment) + BACKUP_DIR="${BACKUP_DIR:-./backups}" + VOLUME_NAME="${VOLUME_NAME:-taskplaner_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 "=========================================" + echo "TaskPlaner Backup" + echo "=========================================" + echo "Volume: $VOLUME_NAME" + echo "Output: $BACKUP_FILE" + echo "" + + # Check if volume exists + if ! docker volume inspect "$VOLUME_NAME" > /dev/null 2>&1; then + echo "Error: Volume '$VOLUME_NAME' not found" + echo "" + echo "Available volumes:" + docker volume ls --format ' - {{.Name}}' | grep -i taskplaner || echo " (none with 'taskplaner' in name)" + echo "" + echo "Tip: Set VOLUME_NAME environment variable to use a different volume" + exit 1 + fi + + # Create backup using temporary Alpine container + echo "Creating backup..." + docker run --rm \ + -v "${VOLUME_NAME}:/data:ro" \ + -v "$(cd "$BACKUP_DIR" && pwd):/backup" \ + alpine:latest \ + tar czf "/backup/taskplaner_backup_${TIMESTAMP}.tar.gz" -C /data . + + echo "" + echo "Backup complete!" + echo "File: $BACKUP_FILE" + echo "Size: $(du -h "$BACKUP_FILE" | cut -f1)" + echo "" + echo "To restore: docker run --rm -v ${VOLUME_NAME}:/data -v \$(pwd)/${BACKUP_DIR}:/backup alpine tar xzf /backup/taskplaner_backup_${TIMESTAMP}.tar.gz -C /data" + ``` + +3. Make backup.sh executable: + After creating the file, run: chmod +x backup.sh + + + - .env.example exists with all documented variables + - backup.sh exists and is executable: `ls -la backup.sh` + - backup.sh syntax is valid: `bash -n backup.sh` + + .env.example documents all configuration options, backup.sh creates timestamped archive + + + + Task 3: Add Docker deployment section to README + README.md + +Check if README.md exists. If it does, add a Docker Deployment section. If not, create a minimal README with the Docker section. + +Add this section (after any existing content, or as the main content): + +```markdown +## Docker Deployment + +### Quick Start + +```bash +# Build and start the container +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the container +docker-compose down +``` + +The application will be available at http://localhost:3000 + +### Configuration + +Copy `.env.example` to `.env` and customize: + +```bash +cp .env.example .env +``` + +Key settings: +- `ORIGIN` - Required for production. Set to your public URL (e.g., `https://tasks.example.com`) +- `BODY_SIZE_LIMIT` - Max upload size. Default: `512kb`, recommended: `10M` +- `PORT` - Server port inside container. Default: `3000` + +### Behind a Reverse Proxy + +When running behind nginx, traefik, or similar, uncomment these in `.env`: + +```bash +PROTOCOL_HEADER=x-forwarded-proto +HOST_HEADER=x-forwarded-host +ADDRESS_HEADER=x-forwarded-for +XFF_DEPTH=1 +``` + +### Data Persistence + +Data is stored in a Docker named volume (`taskplaner_data`). This includes: +- SQLite database (`/data/db/taskplaner.db`) +- Uploaded images (`/data/uploads/`) + +The volume persists across container restarts and updates. + +### Backup & Restore + +Create a backup: +```bash +./backup.sh +``` + +Backups are saved to `./backups/` with timestamps. + +Restore from backup: +```bash +# Stop the container first +docker-compose down + +# Restore (replace TIMESTAMP with actual backup filename) +docker run --rm \ + -v taskplaner_taskplaner_data:/data \ + -v $(pwd)/backups:/backup \ + alpine tar xzf /backup/taskplaner_backup_TIMESTAMP.tar.gz -C /data + +# Start the container +docker-compose up -d +``` + +### Health Check + +The container includes a health check at `/health`. View status: + +```bash +docker-compose ps +``` + +A healthy container shows `(healthy)` in the status column. +``` + +If README.md exists, preserve existing content and add this as a new section. +If README.md doesn't exist, create it with a title "# TaskPlaner" followed by this Docker section. + + + - README.md exists with Docker Deployment section + - Section includes Quick Start, Configuration, Backup instructions + - Commands are correct and copy-pasteable + + README.md documents Docker deployment with quick start, configuration, and backup instructions + + + + + +1. Health endpoint works: `curl http://localhost:5173/health` returns "ok" (dev mode) +2. Environment documented: `.env.example` has all configuration options +3. Backup script works: `./backup.sh` (requires running container with volume) +4. README complete: Docker section with quick start and backup instructions + +Full Docker verification (after both plans): +```bash +# Build and start +docker-compose up -d + +# Check health +docker-compose ps # Should show (healthy) +curl http://localhost:3000/health # Should return "ok" + +# Verify data persistence +# Add an entry via the UI, then: +docker-compose down +docker-compose up -d +# Entry should still exist + +# Test backup +./backup.sh +ls -la backups/ # Should show timestamped tar.gz +``` + + + +- /health endpoint returns 200 when database accessible, 503 on failure +- .env.example documents PORT, TASKPLANER_DATA_DIR, ORIGIN, BODY_SIZE_LIMIT, proxy headers +- backup.sh creates timestamped tar.gz of data volume +- README.md has Docker deployment section with quick start and backup instructions +- Container shows "healthy" status in docker-compose ps + + + +After completion, create `.planning/phases/06-deployment/06-02-SUMMARY.md` +