# 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 ```bash # 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 ```bash # Login to your registry (adjust for your setup) docker login -u # Build for AMD64 (required for most VPS) docker build --platform linux/amd64 \ --build-arg NEXT_PUBLIC_URL=https://your-app.example.com \ -t //:latest . # Push docker push //: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 `` with your values: ```yaml services: web: image: //:latest restart: always environment: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - DATABASE_URL=postgres://:@db:5432/ - BETTER_AUTH_SECRET= - BETTER_AUTH_TRUSTED_ORIGINS=https://your-app.example.com # Optional features — remove if not using: - S3_BUCKET= - S3_REGION=us-east-1 - S3_ENDPOINT=http://minio:9000 - S3_ACCESS_KEY_ID= - S3_SECRET_ACCESS_KEY= ports: - "3000" depends_on: db: condition: service_healthy db: image: pgvector/pgvector:pg17 restart: always environment: POSTGRES_USER: POSTGRES_PASSWORD: POSTGRES_DB: volumes: - app-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", ""] interval: 10s timeout: 5s retries: 5 minio: image: minio/minio:latest restart: always command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: MINIO_ROOT_PASSWORD: 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 ; mc mb myminio/ --ignore-existing; mc anonymous set download myminio/; 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): ```bash ssh "docker exec coolify php artisan tinker --execute=\" use App\Models\ServiceApplication; \\\$app = ServiceApplication::where('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 ```bash # 1. Create schemas (needed for AI features — skip if not using them) ssh "docker exec psql -U -d -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 "docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" # 3. SSH tunnel ssh -f -N -L 5440::5432 # 4. Push schema (MUST run from packages/db directory) cd packages/db DATABASE_URL="postgres://:@localhost:5440/" npx drizzle-kit push --force # 5. Seed users (run from packages/auth directory) cd ../auth SKIP_ENV_VALIDATION=1 \ DATABASE_URL="postgres://:@localhost:5440/" \ BETTER_AUTH_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: `me@turbostarter.dev`) - Password: value of `SEED_PASSWORD` (default: `Pa$$w0rd`) --- ## Redeploy (After Code Changes) ```bash # 1. Build & push new image docker build --platform linux/amd64 \ --build-arg NEXT_PUBLIC_URL=https://your-app.example.com \ -t //:latest . docker push //:latest # 2. Pull new image on the server (required — Coolify won't pull automatically) ssh "docker pull localhost://:latest" # 3. Restart via Coolify (stop + start, or MCP restart) ``` **If containers get stuck in "Created" state after restart:** ```bash # Coolify sometimes leaves containers in "Created" state after stop+start. # Fix: manually start them in dependency order: ssh "docker start " # Wait ~15s for healthchecks, then: ssh "docker start " # 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:/...` — 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)