fix(deploy): resolve Docker startup and CSRF issues

- Rename TASKPLANER_DATA_DIR to DATA_DIR (avoid adapter-node envPrefix conflict)
- Add TASKPLANER_ORIGIN for CSRF protection in docker-compose.yml
- Add automatic database schema initialization on startup
- Add Playwright E2E tests for Docker deployment verification
- Update .env.example with correct variable names

Fixes container restart loop and 403 errors on form submission.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Richter
2026-02-01 15:54:44 +01:00
parent 89e703daa5
commit 84ad332737
11 changed files with 304 additions and 7 deletions

View File

@@ -16,7 +16,7 @@ PORT=3000
# Directory for database and uploads
# Docker: /app/data (must match volume mount)
# Local development: ./data
TASKPLANER_DATA_DIR=/app/data
DATA_DIR=/app/data
# ============================================
# Production URL (REQUIRED for production)
@@ -24,8 +24,9 @@ TASKPLANER_DATA_DIR=/app/data
# The full URL where users access the app
# Used for CSRF validation and generating absolute URLs
# Must use TASKPLANER_ prefix due to adapter-node envPrefix config
# Example: https://tasks.example.com
ORIGIN=http://localhost:3000
TASKPLANER_ORIGIN=http://localhost:3000
# ============================================
# Request Limits

7
.gitignore vendored
View File

@@ -29,3 +29,10 @@ data/*.db-shm
data/attachments/*
!data/.gitkeep
!data/attachments/.gitkeep
# Backups
backups/
# Playwright
playwright-report/
test-results/

View File

@@ -0,0 +1,49 @@
---
status: complete
phase: 06-deployment
source: 06-01-SUMMARY.md, 06-02-SUMMARY.md
started: 2026-02-01T12:30:00Z
updated: 2026-02-01T12:30:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Docker Build Succeeds
expected: Run `docker compose build` — build completes without errors
result: pass
### 2. Container Starts and Runs
expected: Run `docker compose up -d` — container starts, `docker compose ps` shows healthy status
result: pass
### 3. Application Accessible
expected: Open http://localhost:3000 in browser — TaskPlanner UI loads, shows entry list
result: pass
### 4. Health Endpoint Returns 200
expected: Run `curl http://localhost:3000/health` — returns 200 OK with JSON status
result: pass
### 5. Data Persists Across Restart
expected: Create a test entry, run `docker compose restart`, refresh browser — entry still exists
result: pass
### 6. Backup Script Creates Archive
expected: Run `./backup.sh` — creates timestamped .tar.gz file in backups/ directory
result: pass
## Summary
total: 6
passed: 6
issues: 0
pending: 0
skipped: 0
## Gaps
[none yet]

View File

@@ -40,7 +40,7 @@ USER nodejs
# Set environment variables
ENV NODE_ENV=production
ENV TASKPLANER_DATA_DIR=/app/data
ENV DATA_DIR=/app/data
ENV PORT=3000
# Expose port

View File

@@ -8,7 +8,8 @@ services:
- taskplaner_data:/app/data
environment:
- NODE_ENV=production
- TASKPLANER_DATA_DIR=/app/data
- DATA_DIR=/app/data
- TASKPLANER_ORIGIN=http://localhost:3000
restart: unless-stopped
volumes:

64
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
@@ -1625,6 +1626,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -4789,6 +4806,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -13,9 +13,12 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:e2e": "playwright test",
"test:e2e:docker": "BASE_URL=http://localhost:3000 playwright test tests/docker-deployment.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",

20
playwright.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' }
}
]
});

View File

@@ -4,7 +4,7 @@ import * as schema from './schema';
import { existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data';
const DATA_DIR = process.env.DATA_DIR || './data';
const DB_PATH = join(DATA_DIR, 'taskplaner.db');
// Ensure data directory exists
@@ -18,6 +18,49 @@ const sqlite = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
// Initialize schema if tables don't exist
const tableExists = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries'")
.get();
if (!tableExists) {
sqlite.exec(`
CREATE TABLE entries (
id TEXT PRIMARY KEY,
title TEXT,
content TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'thought' CHECK(type IN ('task', 'thought')),
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'done', 'archived')),
pinned INTEGER DEFAULT 0,
due_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE images (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
ext TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX tagNameUniqueIndex ON tags(lower(name));
CREATE TABLE entry_tags (
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, tag_id)
);
`);
console.log('Database schema initialized');
}
export const db = drizzle(sqlite, { schema });
export { schema };

View File

@@ -1,7 +1,7 @@
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data';
const DATA_DIR = process.env.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');

View File

@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Docker deployment tests
*
* These tests verify the application works correctly when deployed in Docker.
* They specifically test CSRF protection which requires TASKPLANER_ORIGIN to be set.
*
* Run against Docker: BASE_URL=http://localhost:3000 npx playwright test
*/
test.describe('Docker Deployment', () => {
test('application loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/TaskPlaner/i);
});
test('health endpoint returns ok', async ({ request }) => {
const response = await request.get('/health');
expect(response.status()).toBe(200);
expect(await response.text()).toBe('ok');
});
test('can create entry via form submission', async ({ page }) => {
// This test verifies CSRF protection is properly configured
// If TASKPLANER_ORIGIN is not set, form submissions return 403
await page.goto('/');
// Fill in quick capture form (textarea for content)
const contentInput = page.locator('textarea[name="content"]');
await contentInput.fill('Test entry from Playwright');
// Submit the form - button text is "Add"
const addButton = page.locator('button[type="submit"]:has-text("Add")');
await addButton.click();
// Wait for the entry to appear in the list
await expect(page.locator('text=Test entry from Playwright')).toBeVisible({ timeout: 5000 });
});
test('can toggle entry completion', async ({ page }) => {
await page.goto('/');
// Create a task entry first
const contentInput = page.locator('textarea[name="content"]');
await contentInput.fill('Test task for completion');
// Select task type
const typeSelect = page.locator('select[name="type"]');
await typeSelect.selectOption('task');
const addButton = page.locator('button[type="submit"]:has-text("Add")');
await addButton.click();
// Wait for entry to appear
await expect(page.locator('text=Test task for completion')).toBeVisible({ timeout: 5000 });
// Find and click the checkbox to mark complete
const checkbox = page.locator('input[type="checkbox"]').first();
await checkbox.click();
// Verify the action completed (no 403 error)
// The checkbox should now be checked
await expect(checkbox).toBeChecked({ timeout: 5000 });
});
test('data persists across container restart', async ({ page }) => {
// This test verifies Docker volume persistence
const uniqueContent = `Persistence test ${Date.now()}`;
// Create an entry
await page.goto('/');
const contentInput = page.locator('textarea[name="content"]');
await contentInput.fill(uniqueContent);
const addButton = page.locator('button[type="submit"]:has-text("Add")');
await addButton.click();
// Wait for entry to appear
await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 });
// Restart the container
await execAsync('docker compose restart');
// Wait for container to be healthy again
let healthy = false;
for (let i = 0; i < 30; i++) {
try {
const response = await fetch('http://localhost:3000/health');
if (response.ok) {
healthy = true;
break;
}
} catch {
// Container not ready yet
}
await new Promise((r) => setTimeout(r, 1000));
}
expect(healthy).toBe(true);
// Verify entry still exists after restart
await page.goto('/');
await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 });
});
});