diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..70c0a078 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +.github +.env +.env.local +*.log +dist +crewform-docs +.DS_Store diff --git a/.env.example b/.env.example index 0f29a106..8c82d596 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,30 @@ -# CrewForm Environment Variables +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm — Environment Variables # Copy to .env and fill in your values +# ───────────────────────────────────────────────────────────────────────────── -# Supabase — required +# ── PostgreSQL (Docker Compose) ─────────────────────────────────────────── +POSTGRES_DB=crewform +POSTGRES_USER=crewform +POSTGRES_PASSWORD= # REQUIRED — choose a strong password +POSTGRES_PORT=5432 + +# ── Supabase ────────────────────────────────────────────────────────────── +# For hosted Supabase: use your project URL and keys +# For self-hosting without Supabase: leave blank (direct Postgres mode) VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# ── App URL ─────────────────────────────────────────────────────────────── +VITE_APP_URL=http://localhost:3000 +FRONTEND_PORT=3000 + +# ── Encryption ──────────────────────────────────────────────────────────── +# 32-byte hex string for AES-256-GCM API key encryption +ENCRYPTION_KEY= -# App URL -VITE_APP_URL=http://localhost:5173 +# ── AI Provider Keys (optional — BYOK pattern uses workspace API keys) ── +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_GENERATIVE_AI_API_KEY= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9cad18a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm Frontend — Multi-stage Dockerfile +# Stage 1: Build Vite app +# Stage 2: Serve static files via nginx +# ───────────────────────────────────────────────────────────────────────────── + +# ── Build stage ─────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci + +# Accept build-time env vars for Vite +ARG VITE_SUPABASE_URL +ARG VITE_SUPABASE_ANON_KEY +ARG VITE_APP_URL + +# Copy source and build +COPY . . +RUN npm run build + +# ── Serve stage ─────────────────────────────────────────────────────────── +FROM nginx:1.27-alpine + +# Copy custom nginx config +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b96f2e61 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm — Docker Compose (Self-Hosting) +# ───────────────────────────────────────────────────────────────────────────── +# +# Usage: +# cp .env.example .env # edit with your values +# docker compose up -d +# +# Services: +# postgres — PostgreSQL 15 with persistent volume +# migrate — Runs all SQL migrations, then exits +# frontend — Vite build served via nginx (port 3000) +# task-runner — Node.js polling service +# ───────────────────────────────────────────────────────────────────────────── + +services: + # ── PostgreSQL ────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-crewform} + POSTGRES_USER: ${POSTGRES_USER:-crewform} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crewform}" ] + interval: 5s + timeout: 5s + retries: 10 + + # ── Migrations ────────────────────────────────────────────────────────── + migrate: + image: postgres:15-alpine + depends_on: + postgres: + condition: service_healthy + environment: + PGHOST: postgres + PGPORT: "5432" + PGDATABASE: ${POSTGRES_DB:-crewform} + PGUSER: ${POSTGRES_USER:-crewform} + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - ./supabase/migrations:/migrations:ro + - ./docker/migrate.sh:/migrate.sh:ro + entrypoint: [ "sh", "/migrate.sh" ] + + # ── Frontend ──────────────────────────────────────────────────────────── + frontend: + build: + context: . + dockerfile: Dockerfile + args: + VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-http://localhost:8000} + VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-} + VITE_APP_URL: ${VITE_APP_URL:-http://localhost:3000} + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + migrate: + condition: service_completed_successfully + + # ── Task Runner ───────────────────────────────────────────────────────── + task-runner: + build: + context: ./task-runner + dockerfile: Dockerfile + restart: unless-stopped + environment: + VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-http://localhost:8000} + SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:-} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-} + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + pgdata: diff --git a/docker/migrate.sh b/docker/migrate.sh new file mode 100755 index 00000000..a2ede9c0 --- /dev/null +++ b/docker/migrate.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm — Auto-migration script +# Runs all SQL migrations in sorted order against PostgreSQL. +# Environment: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD +# ───────────────────────────────────────────────────────────────────────────── + +set -e + +echo "═══════════════════════════════════════════════════" +echo " CrewForm — Running database migrations" +echo "═══════════════════════════════════════════════════" + +# Create a tracking table to avoid re-running migrations +psql -c " +CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +" 2>/dev/null + +MIGRATION_DIR="/migrations" +APPLIED=0 +SKIPPED=0 + +for f in $(ls "$MIGRATION_DIR"/*.sql 2>/dev/null | sort); do + filename=$(basename "$f") + + # Check if already applied + already=$(psql -tAc "SELECT 1 FROM _migrations WHERE name = '$filename'" 2>/dev/null || echo "") + if [ "$already" = "1" ]; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + echo " ▸ Applying: $filename" + psql -f "$f" -v ON_ERROR_STOP=1 + + # Record migration + psql -c "INSERT INTO _migrations (name) VALUES ('$filename')" 2>/dev/null + APPLIED=$((APPLIED + 1)) +done + +echo "═══════════════════════════════════════════════════" +echo " Done! Applied: $APPLIED | Skipped: $SKIPPED" +echo "═══════════════════════════════════════════════════" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..7ba1dddf --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,31 @@ +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm — nginx config for SPA routing +# ───────────────────────────────────────────────────────────────────────────── + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + gzip_min_length 256; + + # Cache static assets aggressively + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback — serve index.html for all non-file routes + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 00000000..d318af0d --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,164 @@ +# Self-Hosting CrewForm + +Run CrewForm on your own infrastructure with Docker Compose. This guide covers a **single-server deployment** suitable for teams and small organizations. + +## Prerequisites + +- **Docker** ≥ 24.0 and **Docker Compose** ≥ 2.20 +- **2 GB RAM** minimum (4 GB recommended) +- **10 GB disk** for database + assets +- A **Supabase project** (hosted) or PostgreSQL 15+ (direct mode) + +## Quick Start + +```bash +# 1. Clone the repository +git clone https://github.com/vincentgrobler/crewform.git +cd crewform + +# 2. Configure environment +cp .env.example .env +# Edit .env — at minimum set POSTGRES_PASSWORD + +# 3. Start all services +docker compose up -d + +# 4. Check status +docker compose ps +``` + +The frontend will be available at **http://localhost:3000**. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌────────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ postgres │ │ migrate │ │ task-runner │ │ +│ │ (PG 15) │←─│ (17 SQL) │ │ (Node + tsx) │ │ +│ │ :5432 │ │ one-shot │ │ polling loop │ │ +│ └────────────┘ └──────────┘ └────────────────┘ │ +│ │ │ │ +│ └──────────┬───────────────────┘ │ +│ │ │ +│ ┌─────────────────▼───────────────────────────┐ │ +│ │ frontend (nginx) │ │ +│ │ Vite build → :3000 │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Services + +| Service | Image | Purpose | Port | +|---------|-------|---------|------| +| `postgres` | postgres:15-alpine | Database with persistent volume | 5432 | +| `migrate` | postgres:15-alpine | Runs SQL migrations, then exits | — | +| `frontend` | nginx:1.27-alpine | Serves Vite build (SPA routing) | 3000 | +| `task-runner` | node:20-alpine | AI task execution polling service | — | + +## Configuration + +### Required Variables + +| Variable | Description | +|----------|-------------| +| `POSTGRES_PASSWORD` | Database password (choose a strong one) | +| `VITE_SUPABASE_URL` | Your Supabase project URL | +| `VITE_SUPABASE_ANON_KEY` | Supabase anon/public key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (task-runner) | + +### Optional Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_DB` | crewform | Database name | +| `POSTGRES_USER` | crewform | Database user | +| `POSTGRES_PORT` | 5432 | PostgreSQL port | +| `FRONTEND_PORT` | 3000 | Frontend port | +| `VITE_APP_URL` | http://localhost:3000 | Public app URL | +| `ENCRYPTION_KEY` | — | 32-byte hex for AES-256-GCM | +| `OPENAI_API_KEY` | — | Fallback OpenAI key | +| `ANTHROPIC_API_KEY` | — | Fallback Anthropic key | +| `GOOGLE_GENERATIVE_AI_API_KEY` | — | Fallback Google AI key | + +## Database Migrations + +Migrations run automatically on startup via the `migrate` container. It: + +1. Creates a `_migrations` tracking table +2. Runs all `supabase/migrations/*.sql` files in sorted order +3. Skips already-applied migrations +4. Exits after completion + +To run migrations manually: + +```bash +docker compose run --rm migrate +``` + +## Managing the Stack + +```bash +# View logs +docker compose logs -f + +# View logs for a specific service +docker compose logs -f task-runner + +# Restart a service +docker compose restart task-runner + +# Stop all services +docker compose down + +# Stop and remove volumes (⚠️ deletes database!) +docker compose down -v + +# Rebuild after code changes +docker compose build --no-cache +docker compose up -d +``` + +## Updating + +```bash +git pull origin main +docker compose build --no-cache +docker compose up -d +# Migrations run automatically on startup +``` + +## Troubleshooting + +### Migrations fail +```bash +# Check migration logs +docker compose logs migrate + +# Run migrations manually with verbose output +docker compose run --rm migrate +``` + +### Frontend shows blank page +- Ensure `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are set correctly +- Check nginx logs: `docker compose logs frontend` + +### Task runner not processing tasks +- Check that `SUPABASE_SERVICE_ROLE_KEY` is set +- View logs: `docker compose logs -f task-runner` +- Ensure the task-runner can reach the Supabase URL + +### Database connection issues +- Verify `POSTGRES_PASSWORD` matches across services +- Check postgres health: `docker compose exec postgres pg_isready` + +## Production Considerations + +- **HTTPS**: Put a reverse proxy (Caddy, Traefik, or nginx) in front with TLS +- **Backups**: Schedule `pg_dump` via cron +- **Monitoring**: Add health check endpoints and uptime monitoring +- **Secrets**: Use Docker secrets or a vault for sensitive values +- **Memory**: Monitor task-runner memory usage with AI provider calls diff --git a/task-runner/Dockerfile b/task-runner/Dockerfile index 035ef4a3..d04b5ce0 100644 --- a/task-runner/Dockerfile +++ b/task-runner/Dockerfile @@ -1,18 +1,21 @@ +# ───────────────────────────────────────────────────────────────────────────── +# CrewForm Task Runner — Dockerfile +# ───────────────────────────────────────────────────────────────────────────── + FROM node:20-alpine WORKDIR /usr/src/app -# Copy package files +# Copy package files and install dependencies COPY package*.json ./ - -# Install dependencies (including devDependencies for tsx) -RUN npm install +RUN npm ci --include=dev # Copy source code COPY tsconfig.json . COPY src ./src -# The Task Runner does not require a build step because we run it directly with tsx -# (Alternative: compile using tsc and run node dist/index.js) +# Health check — verify the process is running +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD pgrep -f "tsx" > /dev/null || exit 1 CMD ["npx", "tsx", "src/index.ts"]