GHCR_TOKEN=ghp_xxx scripts/publish-images.sh 0.1.0 — logs into ghcr.io as alezmad and pushes all 3 claudemesh-* images (broker + web + migrate, multi-arch) via the existing build-multiarch.sh. Supports --dry-run that prints what would publish without logging in or pushing. When user drops their GHCR PAT, shipping the 0.1.0 image tag is one command. Also documents post-trim image sizes in DEPLOY.md Step 2 (broker 341MB, migrate 653MB, web 250MB). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
10 KiB
Markdown
298 lines
10 KiB
Markdown
# 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 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.
|
|
|
|
### Fast path (ghcr.io/alezmad)
|
|
|
|
```bash
|
|
GHCR_TOKEN=ghp_xxx ./scripts/publish-images.sh 0.1.0
|
|
./scripts/publish-images.sh 0.1.0 --dry-run # preview without pushing
|
|
```
|
|
|
|
One command logs in + builds + pushes all 3 images to
|
|
`ghcr.io/alezmad/claudemesh-{broker,web,migrate}` for both archs.
|
|
|
|
### Manual path (any registry)
|
|
|
|
```bash
|
|
# 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).
|
|
|
|
Image sizes (arm64, after the `pnpm deploy` trim — amd64 is similar):
|
|
|
|
| image | size | contains |
|
|
| ------------------- | ------- | -------------------------------------- |
|
|
| claudemesh-broker | ~341 MB | bun runtime, prod deps only |
|
|
| claudemesh-migrate | ~653 MB | bun runtime + drizzle-kit (devDep) |
|
|
| claudemesh-web | ~250 MB | node + next.js standalone output |
|
|
|
|
> **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)
|
|
|
|
```bash
|
|
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:
|
|
|
|
```yaml
|
|
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):
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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)
|
|
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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)
|