Files
claudemesh/DEPLOY.md
Alejandro Gutiérrez c4e1ff5f28
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
chore: replace TurboStarter brand references in env templates + docs
- PRODUCT_NAME default: TurboStarter → claudemesh (.env.example, .env.local)
- SEED_EMAIL default: me@turbostarter.devdev@example.com
- README dev accounts table: reflect new seed email format
- DEPLOY.md: fix stale SEED_EMAIL reference

Keeps DB user as turbostarter per docker-compose.yml default; retains
TurboStarter attribution link in README Contributing section (legit
credit for the template this repo is built on).

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

8.3 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 Image

# Login to your registry (adjust for your setup)
docker login <REGISTRY_HOST> -u <USERNAME>

# Build for AMD64 (required for most VPS)
docker build --platform linux/amd64 \
  --build-arg NEXT_PUBLIC_URL=https://your-app.example.com \
  -t <REGISTRY_HOST>/<ORG>/<APP>:latest .

# Push
docker push <REGISTRY_HOST>/<ORG>/<APP>:latest

Build takes ~2 min on Mac M-series. If push fails with EOF, retry.

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)