Files
claudemesh/DEPLOY.md
Alejandro Gutiérrez 30bc24f20d
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
docs(deploy): swap image path to ghcr.io/alezmad/claudemesh-broker
User owns the alezmad github scope, not a claudemesh org — point README
+ build script + DEPLOY.md at the real namespace so the docker pull
snippets actually work on launch day. Image names are now
claudemesh-broker / claudemesh-web / claudemesh-migrate (prefixed since
they live under a personal scope).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:31:34 +01:00

9.4 KiB

Deploy Guide

Step-by-step guide for deploying this boilerplate to any VPS via Docker + Coolify. Designed for AI agents to follow without errors.

Architecture

Build Machine (Mac/CI) → Docker Image → Registry → Coolify Service
                                                        ├── web (Next.js)
                                                        ├── db (PostgreSQL + pgvector)
                                                        └── minio (S3 storage)
  • Build: Cross-compile locally (ARM Mac → AMD64 server)
  • Registry: Gitea container registry (or any Docker registry)
  • Runtime: Coolify manages docker-compose service
  • TLS: Handled externally (Tailscale Funnel, Cloudflare, etc.)

Prerequisites

  • Docker Desktop with linux/amd64 platform support
  • SSH access to the target server
  • Coolify running on the server
  • A Docker registry (Gitea, Docker Hub, GHCR, etc.)

Step 1: Configure Environment

# Copy the production template
cp .env.production.example .env.production

# Generate auth secret
openssl rand -base64 32
# Paste into BETTER_AUTH_SECRET=

# Fill in required values:
# - DATABASE_URL (will be set in docker-compose, but needed for schema push)
# - BETTER_AUTH_SECRET (generated above)
# - NEXT_PUBLIC_URL (your public URL)
# - BETTER_AUTH_TRUSTED_ORIGINS (same as public URL)

See .env.production.example for full list with [REQUIRED] / [FEATURE] / [OPTIONAL] tags.

Step 2: Build & Push Images

Three images ship: broker, web, migrate. Use the multi-arch build script — it produces both linux/amd64 (VPS) and linux/arm64 (Apple Silicon devs) manifests so nobody hits QEMU emulation at runtime.

# Login to your registry
docker login <REGISTRY_HOST> -u <USERNAME>

# Multi-arch build + push (all 3 images: broker, web, migrate)
scripts/build-multiarch.sh <REGISTRY_HOST>/<ORG> <TAG>

# Examples:
scripts/build-multiarch.sh                              # → ghcr.io/alezmad/claudemesh-*:<git-sha>
scripts/build-multiarch.sh ghcr.io/alezmad 0.1.0        # → ghcr.io/alezmad/claudemesh-*:0.1.0
scripts/build-multiarch.sh ghcr.io/myorg latest         # → ghcr.io/myorg/claudemesh-*:latest

The script tags each image with both <TAG> and :latest. Builds in ~5-8 min on Mac M-series (arm64 native is fast, amd64 via emulation is the slow leg).

Mac Docker Desktop note: if amd64 builds fail with Input/output error during apt-get install, enable Settings → General → Use Rosetta for x86/amd64 emulation (not QEMU). QEMU has known I/O stability issues on macOS; Rosetta is rock-solid. Linux CI runners don't hit this.

Single-arch fallback (if you really only need amd64)

docker build --platform linux/amd64 \
  --build-arg NEXT_PUBLIC_URL=https://your-app.example.com \
  -f apps/web/Dockerfile \
  -t <REGISTRY_HOST>/<ORG>/web:latest .
docker push <REGISTRY_HOST>/<ORG>/web:latest

Repeat for apps/broker/Dockerfile and packages/db/Dockerfile.

Step 3: Create Coolify Service

Create a Coolify service (not application) with this docker-compose template. Replace all <PLACEHOLDERS> with your values:

services:
  web:
    image: <REGISTRY_HOST>/<ORG>/<APP>:latest
    restart: always
    environment:
      - NODE_ENV=production
      - PORT=3000
      - HOSTNAME=0.0.0.0
      - DATABASE_URL=postgres://<DB_USER>:<DB_PASS>@db:5432/<DB_NAME>
      - BETTER_AUTH_SECRET=<YOUR_SECRET>
      - BETTER_AUTH_TRUSTED_ORIGINS=https://your-app.example.com
      # Optional features — remove if not using:
      - S3_BUCKET=<BUCKET_NAME>
      - S3_REGION=us-east-1
      - S3_ENDPOINT=http://minio:9000
      - S3_ACCESS_KEY_ID=<MINIO_USER>
      - S3_SECRET_ACCESS_KEY=<MINIO_PASS>
    ports:
      - "3000"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: pgvector/pgvector:pg17
    restart: always
    environment:
      POSTGRES_USER: <DB_USER>
      POSTGRES_PASSWORD: <DB_PASS>
      POSTGRES_DB: <DB_NAME>
    volumes:
      - app-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "<DB_USER>"]
      interval: 10s
      timeout: 5s
      retries: 5

  minio:
    image: minio/minio:latest
    restart: always
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: <MINIO_USER>
      MINIO_ROOT_PASSWORD: <MINIO_PASS>
    volumes:
      - app-minio:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 10s
      timeout: 5s
      retries: 5

  minio-init:
    image: minio/mc:latest
    restart: "no"
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set myminio http://minio:9000 <MINIO_USER> <MINIO_PASS>;
      mc mb myminio/<BUCKET_NAME> --ignore-existing;
      mc anonymous set download myminio/<BUCKET_NAME>;
      exit 0;
      "

volumes:
  app-postgres:
  app-minio:

If you don't need S3/MinIO: Remove minio, minio-init services and all S3_* env vars.

Step 4: Set FQDN in Coolify

Set the web sub-application's FQDN. Use HTTP if TLS is handled externally (Tailscale, Cloudflare):

ssh <SERVER> "docker exec coolify php artisan tinker --execute=\"
use App\Models\ServiceApplication;
\\\$app = ServiceApplication::where('service_id', <SERVICE_ID>)->where('name', 'web')->first();
\\\$app->fqdn = 'http://your-app.example.com';
\\\$app->save();
echo 'FQDN: ' . \\\$app->fqdn;
\""

Step 5: Start the Service

Via Coolify MCP or UI. Wait ~30 seconds for all containers to become healthy.

Step 6: Initialize Database

# 1. Create schemas (needed for AI features — skip if not using them)
ssh <SERVER> "docker exec <DB_CONTAINER> psql -U <DB_USER> -d <DB_NAME> -c \
  'CREATE SCHEMA IF NOT EXISTS chat; CREATE SCHEMA IF NOT EXISTS pdf; CREATE SCHEMA IF NOT EXISTS image;'"

# 2. Get DB container IP (host can't resolve Docker DNS)
ssh <SERVER> "docker inspect <DB_CONTAINER> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"

# 3. SSH tunnel
ssh -f -N -L 5440:<CONTAINER_IP>:5432 <SERVER>

# 4. Push schema (MUST run from packages/db directory)
cd packages/db
DATABASE_URL="postgres://<DB_USER>:<DB_PASS>@localhost:5440/<DB_NAME>" npx drizzle-kit push --force

# 5. Seed users (run from packages/auth directory)
cd ../auth
SKIP_ENV_VALIDATION=1 \
DATABASE_URL="postgres://<DB_USER>:<DB_PASS>@localhost:5440/<DB_NAME>" \
BETTER_AUTH_SECRET="<YOUR_SECRET>" \
npx tsx ./src/scripts/seed.ts

# 6. Kill tunnel
pkill -f "ssh -f -N -L 5440"

Step 7: Verify

Open your app URL. Sign in with:

  • Email: value of SEED_EMAIL (default: dev@example.com)
  • Password: value of SEED_PASSWORD (default: Pa$$w0rd)

Redeploy (After Code Changes)

# 1. Build & push new image
docker build --platform linux/amd64 \
  --build-arg NEXT_PUBLIC_URL=https://your-app.example.com \
  -t <REGISTRY_HOST>/<ORG>/<APP>:latest .
docker push <REGISTRY_HOST>/<ORG>/<APP>:latest

# 2. Pull new image on the server (required — Coolify won't pull automatically)
ssh <SERVER> "docker pull localhost:<REGISTRY_PORT>/<ORG>/<APP>:latest"

# 3. Restart via Coolify (stop + start, or MCP restart)

If containers get stuck in "Created" state after restart:

# Coolify sometimes leaves containers in "Created" state after stop+start.
# Fix: manually start them in dependency order:
ssh <SERVER> "docker start <DB_CONTAINER> <MINIO_CONTAINER>"
# Wait ~15s for healthchecks, then:
ssh <SERVER> "docker start <WEB_CONTAINER>"

# Or nuclear option: Coolify stop, then start again (creates fresh containers)

Runtime Env Validation

The app validates environment variables at startup (not build time):

Category Behavior
DATABASE_URL, BETTER_AUTH_SECRET Required — app exits with clear error if missing
S3 vars (when S3_BUCKET is set) Feature-gated — required only when feature is enabled
Stripe vars (when STRIPE_SECRET_KEY is set) Feature-gated — required only when feature is enabled
Email vars (when provider key is set) Feature-gated — required only when feature is enabled
BETTER_AUTH_TRUSTED_ORIGINS Warning — app starts but logs a warning
Monitoring, analytics Optional — silently disabled if not set

This means: if the app starts, it's fully configured. No silent failures.


Critical Rules

  1. Image name in compose: Use localhost:<PORT>/... — not the external IP (avoids HTTPS errors)
  2. FQDN must be http:// when TLS is handled externally (Tailscale, Cloudflare)
  3. minio-init must have restart: "no" — Coolify adds unless-stopped by default
  4. Healthcheck uses node -e "fetch(...)"node:22-slim has no wget/curl
  5. NEXT_PUBLIC_URL is a build arg — baked at compile time, must rebuild to change
  6. BETTER_AUTH_TRUSTED_ORIGINS is runtime — comma-separated allowed origins
  7. drizzle-kit runs from packages/db/ — not from repo root
  8. SSH tunnel uses container IP — not container name (host can't resolve Docker DNS)
  9. Seed script is at packages/auth/packages/db/ seed is a placeholder
  10. Don't build on small VPS — cross-compile locally to avoid OOM
  11. Pull image on server before restarting — Coolify won't auto-pull from local registries
  12. Containers stuck in "Created" — Coolify bug; manually docker start in dependency order (db → minio → web)