# Turbostarter (Knosia) — Complete Deployment Guide > Full reference for deploying the Turbostarter Next.js monorepo on the NUC server via Coolify. > Generated from the complete session transcript (12,610 lines across 3+ session compactions). ## Table of Contents 1. [Project Overview](#project-overview) 2. [Architecture](#architecture) 3. [Source Code Modifications](#source-code-modifications) 4. [Dockerfile (Final Working Version)](#dockerfile) 5. [Docker Compose (Coolify Service)](#docker-compose) 6. [Gitea Container Registry Setup](#gitea-container-registry) 7. [Build & Deploy Workflow](#build--deploy-workflow) 8. [Database Setup](#database-setup) 9. [Seed Data](#seed-data) 10. [HTTPS via Tailscale Funnel](#https-via-tailscale-funnel) 11. [Environment Variables](#environment-variables) 12. [Credentials](#credentials) 13. [Error History & Fixes](#error-history--fixes) 14. [Gotchas & Lessons Learned](#gotchas--lessons-learned) 15. [Git Commits Made](#git-commits) 16. [Files Created/Modified](#files-createdmodified) --- ## Project Overview **Turbostarter** is a Next.js 16.0.10 monorepo with: - **pnpm workspaces** — 30 workspace projects, ~2953 npm packages - **Turbopack** for builds - **Drizzle ORM** for database schema (with `pgSchema()` for custom schemas) - **Better Auth** for authentication (CSRF origin validation, passkeys, 2FA, magic links) - **PostHog** for analytics/monitoring - **Zod** (via `envin`) for env var validation - **pgvector** (PostgreSQL with vector extension) - **MinIO** for S3 object storage | Property | Value | |----------|-------| | **Live URL** | `https://alezmad-nuc.tail58f5ad.ts.net` | | **Login URL** | `https://alezmad-nuc.tail58f5ad.ts.net/auth/login` | | **Coolify Service UUID** | `v4gogwwc8wkk4888ksscc4k4` | | **Web Sub-App UUID** | `tsw008g00w0coc8gkwgc8sg0` | | **Gitea Repo** | `alezmad/turbostarter` | | **Local Source** | `/Users/agutierrez/Desktop/turbostarter-export` | | **Old App UUID (deleted)** | `wo8ogs0g8gccc0gcgook8s80` | | **Old DB UUID (deleted)** | `ios4c0sg44g80w0k48kw800k` | **History:** The Gitea repo was originally `nedas/knosia`. It was renamed via direct SQLite DB manipulation: ```bash ssh nuc "docker run --rm --user root -v ho0cwgcwos88cwc48g84c0g8_gitea-data:/data keinos/sqlite3 sqlite3 /data/gitea/gitea.db ' UPDATE user SET name=\"alezmad\", lower_name=\"alezmad\" WHERE id=1; UPDATE repository SET owner_name=\"alezmad\" WHERE owner_id=1; UPDATE repository SET name=\"turbostarter\", lower_name=\"turbostarter\", owner_id=1, owner_name=\"alezmad\" WHERE id=6; '" ``` --- ## Architecture ``` Internet → Tailscale Funnel (valid HTTPS cert) → Traefik (port 80, HTTP) → web container (port 3000) ``` **Key points:** - Tailscale Funnel terminates TLS and forwards plain HTTP to Traefik - Traefik FQDN is set to `http://` (not `https://`) to avoid redirect loops - The web container runs behind Coolify's Traefik proxy **Containers (single Coolify service):** | Container | Image | Purpose | |-----------|-------|---------| | `web-v4gogwwc8wkk4888ksscc4k4` | `localhost:3030/alezmad/turbostarter:latest` | Next.js app | | `db-v4gogwwc8wkk4888ksscc4k4` | `pgvector/pgvector:pg17` | PostgreSQL + pgvector | | `minio-v4gogwwc8wkk4888ksscc4k4` | `minio/minio:latest` | Object storage (S3) | | `minio-init-v4gogwwc8wkk4888ksscc4k4` | `minio/mc:latest` | One-time bucket init | **Why a Coolify Service (not standalone Application):** The app requires pgvector (not plain postgres), MinIO for S3 storage, and an init container. Deploying as a Coolify service keeps all infrastructure in a single docker-compose definition. --- ## Source Code Modifications All modifications required to make Turbostarter build and deploy successfully: ### 1. `apps/web/next.config.ts` — Standalone output + skip TS errors ```typescript const config: NextConfig = { reactStrictMode: true, output: "standalone", // Required for Docker standalone builds typescript: { ignoreBuildErrors: true, // Prevents Coolify timeout during type-check phase }, // ... rest of config }; ``` ### 2. `apps/web/src/app/[locale]/(apps)/tts/page.tsx` — Force dynamic rendering ```typescript // Added after imports: export const dynamic = "force-dynamic"; ``` Without this, the build fails with `Error: ELEVENLABS_API_KEY is required for TTS` because Next.js tries to pre-render the TTS page at build time. ### 3. `packages/auth/src/server.ts` — Trusted origins from env var ```typescript trustedOrigins: [ "chrome-extension://", "turbostarter://", "https://appleid.apple.com", ...(env.NODE_ENV === NodeEnv.DEVELOPMENT ? ["http://localhost*", "https://localhost*"] : []), ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []), // NEW ], ``` ### 4. `packages/analytics/web/src/providers/posthog/env.ts` — Optional PostHog key ```typescript client: { NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), // Changed from required NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), }, ``` Same change in `packages/monitoring/web/src/providers/posthog/env.ts`. Also added non-null assertion `!` in the actual PostHog init calls: - `packages/analytics/web/src/providers/posthog/index.tsx` — `posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, {` - `packages/monitoring/web/src/providers/posthog/index.ts` — `posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, {` ### 5. `packages/auth/src/env.ts` — BETTER_AUTH_SECRET optional with default ```typescript BETTER_AUTH_SECRET: z.string().optional().default("dev-secret-change-in-production"), ``` ### 6. `packages/storage/src/providers/s3/env.ts` — S3 env vars optional with defaults ```typescript S3_ENDPOINT: z.string().optional().default("http://localhost:9000"), S3_ACCESS_KEY_ID: z.string().optional().default("minioadmin"), S3_SECRET_ACCESS_KEY: z.string().optional().default("minioadmin"), ``` ### 7. `packages/monitoring/web/src/providers/sentry/env.ts` — Sentry DSN optional ```typescript NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), ``` ### 8. `packages/billing/src/providers/stripe/env.ts` — Stripe env vars optional ```typescript STRIPE_SECRET_KEY: z.string().optional().default(""), STRIPE_WEBHOOK_SECRET: z.string().optional().default(""), ``` ### 9. `packages/db/src/env.ts` — DATABASE_URL optional with default ```typescript // Changed from z.url() to: DATABASE_URL: z.string().optional().default(""), ``` ### 10. `packages/email/src/utils/env.ts` — Email env vars optional ```typescript EMAIL_FROM: z.string().optional().default("noreply@example.com"), ``` ### 11. `packages/email/src/providers/resend/env.ts` — Resend API key optional ```typescript RESEND_API_KEY: z.string().optional().default(""), ``` ### 12. `apps/web/env.config.ts` — Web app env vars optional with defaults ```typescript CONTACT_EMAIL: z.email().optional().default("contact@example.com"), NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("TurboStarter"), NEXT_PUBLIC_URL: z.string().url().optional().default("http://localhost:3000"), ``` ### 13. `packages/cms/package.json` — Add missing zod dependency ```json "dependencies": { "@content-collections/core": "0.11.1", "@turbostarter/shared": "workspace:*", "reading-time": "1.5.0", "zod": "catalog:" // NEW - required for CMS build }, ``` ### 6. `nixpacks.toml` — Remove chromium, fix start command ```toml [phases.setup] nixPkgs = ["nodejs_22", "pnpm-10_x", "openssl"] aptPkgs = ["curl", "wget"] [phases.install] cmds = [ "npm install -g corepack@0.24.1 && corepack enable", "pnpm i --frozen-lockfile" ] [phases.build] cmds = ["npx turbo run build"] [start] cmd = "pnpm --filter web start" ``` **Note:** The package name is `web` (from `apps/web/package.json`), NOT `@turbostarter/web`. --- ## Dockerfile The final working Dockerfile uses a **single-stage builder** pattern because pnpm monorepo module resolution requires the full workspace structure. Multi-stage builds with separate deps/builder stages fail with `zod` module not found errors. ```dockerfile # Turbostarter Production Dockerfile # Single-stage build (mimics nixpacks) + slim production image # Build locally on Mac, push to Gitea registry, deploy via Coolify # Stage 1: Build everything in one layer (like nixpacks does) FROM node:22-slim AS builder WORKDIR /app # Install pnpm RUN corepack enable && corepack prepare pnpm@10.25.0 --activate # Copy everything (pnpm workspaces need full context for resolution) COPY . . # Install all dependencies (hoisted, same as nixpacks) RUN pnpm install --frozen-lockfile # Build ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_URL=http://localhost:3000 ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL RUN npx turbo run build # Stage 2: Minimal production image FROM node:22-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs # Copy standalone output from builder COPY --from=builder /app/apps/web/.next/standalone ./ COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder /app/apps/web/public ./apps/web/public USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "apps/web/server.js"] ``` **Key detail:** `NEXT_PUBLIC_URL` is passed as a Docker build arg (`--build-arg`) because `NEXT_PUBLIC_*` variables are baked into the Next.js client-side bundle at compile time. ### .dockerignore ``` .next .turbo dist out .expo .wxt node_modules **/node_modules .pnpm-store .git .gitignore *.md *.log npm-debug.log .vscode .idea coverage .nyc_output Dockerfile .dockerignore .DS_Store *.local .env*.local tmp/ ``` --- ## Docker Compose The final docker-compose deployed as a Coolify service: ```yaml services: web: image: localhost:3030/alezmad/turbostarter:latest restart: always environment: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - DATABASE_URL=postgres://turbostarter:turbostarter@db:5432/core - BETTER_AUTH_SECRET=WyfMfoRclem2Bc/Ek3/2nWsiIdHkjIOvAhJXevDAx/E= - BETTER_AUTH_TRUSTED_ORIGINS=https://alezmad-nuc.tail58f5ad.ts.net - S3_BUCKET=knosia - S3_REGION=us-east-1 - S3_ENDPOINT=http://minio:9000 - S3_ACCESS_KEY_ID=minioadmin - S3_SECRET_ACCESS_KEY=minioadmin ports: - "3000" depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 5 start_period: 10s db: image: pgvector/pgvector:pg17 restart: always environment: POSTGRES_USER: turbostarter POSTGRES_PASSWORD: turbostarter POSTGRES_DB: core volumes: - knosia-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "turbostarter"] interval: 10s timeout: 5s retries: 5 minio: image: minio/minio:latest restart: always command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - knosia-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 minioadmin minioadmin; mc mb myminio/knosia --ignore-existing; mc anonymous set download myminio/knosia; echo 'MinIO bucket created'; exit 0; " volumes: knosia-postgres: knosia-minio: ``` --- ## Gitea Container Registry ### Initial Setup 1. **Enable packages in Gitea** — add to `app.ini`: ```ini [packages] ENABLED = true ``` Then restart Gitea: `ssh nuc "docker restart gitea-ho0cwgcwos88cwc48g84c0g8"` 2. **Create access token** in Gitea: - Go to Settings → Applications → Manage Access Tokens - Name: `docker-registry` - Permissions: `package:read`, `package:write` 3. **Configure Docker on local Mac** — add to `~/.docker/daemon.json`: ```json { "insecure-registries": ["192.168.1.3:3030"] } ``` Then restart Docker Desktop. 4. **Login to registry:** ```bash echo "cdff70a9405954351addaa5af2a6ff163a15bf6b" | docker login 192.168.1.3:3030 -u alezmad --password-stdin ``` ### Key Details | Property | Value | |----------|-------| | **Registry URL (push from Mac)** | `192.168.1.3:3030` | | **Registry URL (pull on NUC)** | `localhost:3030` | | **Access Token** | `cdff70a9405954351addaa5af2a6ff163a15bf6b` | | **Username** | `alezmad` | **Why `localhost:3030` on NUC:** Docker requires insecure-registry config for non-HTTPS registries, but `localhost` is always allowed without it. Since the NUC pulls from its own Gitea instance, `localhost:3030` works. --- ## Build & Deploy Workflow ### Full Build & Deploy ```bash # 1. Build image locally (ARM Mac → AMD64 cross-compile) cd /Users/agutierrez/Desktop/turbostarter-export docker build --platform linux/amd64 \ --build-arg NEXT_PUBLIC_URL=https://alezmad-nuc.tail58f5ad.ts.net \ -t 192.168.1.3:3030/alezmad/turbostarter:latest . # 2. Push to Gitea registry docker push 192.168.1.3:3030/alezmad/turbostarter:latest # 3. Redeploy via Coolify (stop + start for full container recreation) # Via MCP: # mcp__coolify__control(resource="service", action="stop", uuid="v4gogwwc8wkk4888ksscc4k4") # mcp__coolify__control(resource="service", action="start", uuid="v4gogwwc8wkk4888ksscc4k4") # Via SSH: # ssh nuc "docker compose -p v4gogwwc8wkk4888ksscc4k4 down && docker compose -p v4gogwwc8wkk4888ksscc4k4 up -d" ``` ### Convenience Script (`scripts/build-and-push.sh`) ```bash #!/bin/bash set -e REGISTRY="192.168.1.3:3030" REPO="alezmad/turbostarter" TAG="${1:-latest}" IMAGE="${REGISTRY}/${REPO}:${TAG}" echo "Building Docker image: ${IMAGE}" docker build --platform linux/amd64 -t "${IMAGE}" . echo "Pushing to Gitea registry..." docker push "${IMAGE}" echo "Done! Image pushed: ${IMAGE}" ``` ### Why Local Builds (Not NUC Builds) The NUC has only **7.6GB RAM** with **30+ containers** running and **3.8GB/4GB swap** used. Next.js Turbopack builds consume significant memory: - NUC builds: 15-20 minutes, risk of OOM (Docker DNS fails, Redis disconnects) - Mac M-series builds: ~2 minutes, no resource contention --- ## Database Setup ### Create PostgreSQL Schemas Turbostarter uses Drizzle's `pgSchema()` for custom schemas that must exist before `drizzle-kit push`: ```bash ssh nuc "docker exec db-v4gogwwc8wkk4888ksscc4k4 psql -U turbostarter -d core -c \ 'CREATE SCHEMA IF NOT EXISTS chat; CREATE SCHEMA IF NOT EXISTS pdf; CREATE SCHEMA IF NOT EXISTS image;'" ``` ### Run Drizzle Schema Push **Must run from `packages/db` directory** (so it finds `drizzle.config.ts`): ```bash # 1. Get DB container IP ssh nuc "docker inspect db-v4gogwwc8wkk4888ksscc4k4 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" # Returns e.g. 10.0.12.3 # 2. Create SSH tunnel (use IP from step 1, NOT container name) ssh -f -N -L 5440:10.0.12.3:5432 nuc # 3. Push schema cd /Users/agutierrez/Desktop/turbostarter-export/packages/db DATABASE_URL="postgres://turbostarter:turbostarter@localhost:5440/core" npx drizzle-kit push --force # 4. Kill tunnel pkill -f "ssh -f -N -L 5440" ``` ### Tables Created (11 in `public` schema) | Schema | Table | |--------|-------| | public | organization | | public | member | | public | passkey | | public | session | | public | two_factor | | public | invitation | | public | verification | | public | user | | public | account | | public | customer | | public | credit_transaction | **Custom schemas** (`chat`, `pdf`, `image`) exist but may not have tables initially. ### Schema Files Located at `packages/db/src/schema/`: - `auth.ts` — user, session, account, organization, member, etc. - `chat.ts` — uses `pgSchema("chat")` - `pdf.ts` — uses `pgSchema("pdf")` - `image.ts` — uses `pgSchema("image")` - `credit-transaction.ts` - `customer.ts` - `index.ts` — re-exports all schemas ### Verify Tables ```bash ssh nuc "docker exec db-v4gogwwc8wkk4888ksscc4k4 psql -U turbostarter -d core -c \ \"SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema');\"" ``` --- ## Seed Data ### Auth Seed Script Located at `packages/auth/src/scripts/seed.ts` (NOT `packages/db/src/scripts/seed.ts` which is a placeholder). ```bash # With SSH tunnel active (see Database Setup): cd /Users/agutierrez/Desktop/turbostarter-export/packages/auth SKIP_ENV_VALIDATION=1 \ DATABASE_URL="postgres://turbostarter:turbostarter@localhost:5440/core" \ BETTER_AUTH_SECRET="WyfMfoRclem2Bc/Ek3/2nWsiIdHkjIOvAhJXevDAx/E=" \ npx tsx ./src/scripts/seed.ts ``` ### Seeded Users | Email | Role | Password | |-------|------|----------| | `me+admin@turbostarter.dev` | admin | `Pa$$w0rd` | | `me+user@turbostarter.dev` | user | `Pa$$w0rd` | | `me+org-owner@turbostarter.dev` | user | `Pa$$w0rd` | | `me+org-admin@turbostarter.dev` | user | `Pa$$w0rd` | | `me+org-member@turbostarter.dev` | user | `Pa$$w0rd` | Plus a `seed-organization` with members and invitations. Default credentials (from `packages/auth/src/env.ts`): `SEED_EMAIL=me@turbostarter.dev`, `SEED_PASSWORD=Pa$$w0rd` --- ## HTTPS via Tailscale Funnel ### Why Tailscale (Not Cloudflare) Spanish ISPs block Cloudflare shared IPs during LaLiga matches. Tailscale Funnel: - Uses different IP infrastructure (not blocked) - Handles dynamic ISP IP changes automatically - No ports exposed on router - Valid HTTPS certificates included ### Setup The Funnel was already configured: `https://alezmad-nuc.tail58f5ad.ts.net` → port 80 (Traefik). **Critical:** The Coolify FQDN must be set to `http://` (not `https://`): - If set to `https://`: Tailscale (HTTPS) → Traefik (HTTP:80) → redirect-to-https middleware → redirect loop - If set to `http://`: Tailscale (HTTPS) → Traefik (HTTP:80) → direct routing → container ```bash # Set FQDN via Coolify tinker ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\Models\ServiceApplication; \\\$app = ServiceApplication::where('uuid', 'tsw008g00w0coc8gkwgc8sg0')->first(); \\\$app->fqdn = 'http://alezmad-nuc.tail58f5ad.ts.net'; \\\$app->save(); echo 'FQDN set: ' . \\\$app->fqdn; \"" ``` ### Palmr Reassignment Palmr was previously using the Tailscale Funnel hostname. It was reassigned to only `drop.hublang.com`. --- ## Environment Variables ### Runtime (docker-compose) | Variable | Value | Notes | |----------|-------|-------| | `NODE_ENV` | `production` | | | `PORT` | `3000` | | | `HOSTNAME` | `0.0.0.0` | | | `DATABASE_URL` | `postgres://turbostarter:turbostarter@db:5432/core` | Internal Docker network | | `BETTER_AUTH_SECRET` | `WyfMfoRclem2Bc/Ek3/2nWsiIdHkjIOvAhJXevDAx/E=` | Generated random | | `BETTER_AUTH_TRUSTED_ORIGINS` | `https://alezmad-nuc.tail58f5ad.ts.net` | Comma-separated list | | `S3_BUCKET` | `knosia` | | | `S3_REGION` | `us-east-1` | | | `S3_ENDPOINT` | `http://minio:9000` | Internal Docker network | | `S3_ACCESS_KEY_ID` | `minioadmin` | | | `S3_SECRET_ACCESS_KEY` | `minioadmin` | | ### Build-time (Docker build arg) | Variable | Value | Notes | |----------|-------|-------| | `NEXT_PUBLIC_URL` | `https://alezmad-nuc.tail58f5ad.ts.net` | Baked into Next.js static output | | `NEXT_TELEMETRY_DISABLED` | `1` | | --- ## Credentials ### Gitea Registry | Property | Value | |----------|-------| | Registry URL | `192.168.1.3:3030` | | Username | `alezmad` | | Access Token | `cdff70a9405954351addaa5af2a6ff163a15bf6b` | ### Database | Property | Value | |----------|-------| | Host (internal) | `db` | | Host (container name) | `db-v4gogwwc8wkk4888ksscc4k4` | | Port | `5432` | | User | `turbostarter` | | Password | `turbostarter` | | Database | `core` | ### MinIO | Property | Value | |----------|-------| | Endpoint (internal) | `http://minio:9000` | | Console (internal) | `http://minio:9001` | | Root User | `minioadmin` | | Root Password | `minioadmin` | | Bucket | `knosia` | ### App Users (Seeded) | Email | Password | Role | |-------|----------|------| | `me+admin@turbostarter.dev` | `Pa$$w0rd` | admin | | `me+user@turbostarter.dev` | `Pa$$w0rd` | user | --- ## Error History & Fixes A complete chronology of every error encountered across 15+ deployment attempts: ### 1. NUC Out of Memory (Deployments 5, 8, 9) **Symptom:** Docker DNS failures, Redis disconnects, builds hanging **Root cause:** NUC has 7.6GB RAM + 3.8GB/4GB swap with 30+ containers. Next.js Turbopack builds pushed it over the edge. **Fix:** Switch to local Mac builds, push pre-built images to Gitea registry. ### 2. Build Timeout — Chromium Installation (~7 min) **Symptom:** `apt-get install chromium` taking 7+ minutes during nixpacks build **Fix:** Created `nixpacks.toml` to remove all browser dependencies. ### 3. Build Timeout — TypeScript Type-Checking **Symptom:** Build compiled successfully but Coolify timed out during the separate TypeScript type-checking phase **Fix:** `typescript: { ignoreBuildErrors: true }` in `next.config.ts` ### 4. ELEVENLABS_API_KEY Required at Build Time **Error:** `Error: ELEVENLABS_API_KEY is required for TTS` **Root cause:** TTS page statically generated at build time **Fix:** `export const dynamic = "force-dynamic"` on TTS page ### 5. Wrong Start Command Filter **Error:** `No projects matched the filters` **Root cause:** `nixpacks.toml` used `pnpm --filter @turbostarter/web start` but package name is `web` **Fix:** Changed to `pnpm --filter web start` ### 6. PostHog Key Required **Error:** TypeScript error — `NEXT_PUBLIC_POSTHOG_KEY` undefined not assignable to string **Fix:** Made PostHog key optional in both analytics and monitoring env schemas ### 7. Multi-Stage Dockerfile — Module Resolution Failure **Error:** `Cannot find module 'zod'` and similar cross-workspace resolution errors **Root cause:** Multi-stage Dockerfile with separate deps/builder stages breaks pnpm workspace resolution. Cross-workspace dependencies using `catalog:` references can't resolve when `node_modules` are copied between stages. **Fix:** Single-stage builder that copies everything first, then installs, then builds. ### 8. Docker Push to Gitea — HTTPS Error **Error:** `Get "https://192.168.1.3:3030/v2/": EOF` **Root cause:** Docker tries HTTPS by default for non-localhost registries **Fix:** Added `"insecure-registries": ["192.168.1.3:3030"]` to Docker Desktop daemon.json ### 9. NUC Docker Pull — HTTPS Error **Error:** Image pull fails with HTTPS error when using `192.168.1.3:3030` **Root cause:** NUC Docker also tries HTTPS **Fix:** Use `localhost:3030` as image name in docker-compose (Docker allows HTTP for localhost) ### 10. minio-init Restart Loop **Error:** Web container never starts because `minio-init` keeps restarting **Root cause:** Coolify adds `restart: unless-stopped` to all containers. minio-init exits with 0 but restarts, so `service_completed_successfully` never triggers. **Fix:** Set `restart: "no"` on minio-init, removed from web's `depends_on` ### 11. Healthcheck Failure (wget not found) **Error:** Container stuck in "health: starting" forever **Root cause:** `node:22-slim` has no `wget` or `curl` **Fix:** Node-based healthcheck: `["CMD", "node", "-e", "fetch('http://localhost:3000')..."]` ### 12. Blank Page — CSP upgrade-insecure-requests **Error:** HTML loaded but all sub-resources failed (HTTPS upgrade on HTTP page) **Root cause:** CSP header `upgrade-insecure-requests` tells browser to upgrade all requests to HTTPS, which fails without valid certs on `.nuc.lan` **User decision:** Keep the CSP header (production security), configure real HTTPS instead **Fix:** Tailscale Funnel for valid HTTPS certificates ### 13. HTTPS Redirect Loop **Error:** Infinite redirect between Tailscale and Traefik **Root cause:** FQDN set to `https://` causes: Tailscale(HTTPS) → Traefik(HTTP:80) → redirect-to-https → loop **Fix:** Set FQDN to `http://alezmad-nuc.tail58f5ad.ts.net` ### 14. drizzle-kit Push — Schema Not Found **Error:** `error: schema "chat" does not exist` **Root cause:** Drizzle uses `pgSchema()` but doesn't create the schemas automatically **Fix:** `CREATE SCHEMA IF NOT EXISTS chat; CREATE SCHEMA IF NOT EXISTS pdf; CREATE SCHEMA IF NOT EXISTS image;` ### 15. drizzle-kit Push — SSH Tunnel ECONNRESET **Error:** Connection reset when tunneling to container name **Root cause:** NUC host can't resolve Docker container names **Fix:** Get container IP via `docker inspect` and tunnel to IP ### 16. Better Auth 403 on Sign-In **Error:** `Invalid origin: https://alezmad-nuc.tail58f5ad.ts.net` **Root cause:** `trustedOrigins` in Better Auth config doesn't include production URL **Fix:** Added `BETTER_AUTH_TRUSTED_ORIGINS` env var support and set it in docker-compose ### 17. Docker Push — Intermittent EOF **Error:** `failed to do request: Head "https://192.168.1.3:3030/...": EOF` **Root cause:** Transient network issue **Fix:** Re-login to registry and retry (usually works on 2nd/3rd attempt) ### 18. MaxAttemptsExceededException — Deployment Stuck 24h **Error:** Deployment appeared stuck in Coolify for 24 hours **Root cause:** Horizon worker restart during silent `next build` phase **Fix:** Cancel and redeploy --- ## Gotchas & Lessons Learned 1. **pnpm monorepo Dockerfile must be single-stage builder** — multi-stage breaks module resolution for cross-workspace deps using `catalog:` references 2. **Package filter name is `web`**, not `@turbostarter/web` — check `apps/web/package.json` name field 3. **Coolify adds `restart: unless-stopped` to ALL containers** — must explicitly set `restart: "no"` for init containers 4. **`node:22-slim` has no `wget` or `curl`** — use `node -e "fetch(...)"` for healthchecks 5. **`NEXT_PUBLIC_*` vars are compile-time only** — must be passed as `--build-arg` during docker build 6. **Tailscale Funnel + Traefik:** FQDN must be HTTP internally to avoid redirect loop 7. **PostgreSQL schemas must be created before `drizzle-kit push`** — Drizzle's `pgSchema()` doesn't auto-create them 8. **The real seed script is at `packages/auth/src/scripts/seed.ts`** — `packages/db/src/scripts/seed.ts` is a placeholder 9. **`drizzle-kit push` must run from `packages/db/` directory** — running from repo root fails to find config 10. **SSH tunnel must use container IP, not container name** — NUC host can't resolve Docker DNS 11. **NUC Docker uses `localhost:3030`** for Gitea registry — avoids HTTPS insecure-registry issues 12. **Local Mac Docker needs `insecure-registries`** config for HTTP Gitea registry 13. **Gitea Container Registry requires `[packages] ENABLED = true`** in app.ini 14. **`drizzle-kit` is a dev dependency** — not in the production Docker image, must run via SSH tunnel from local machine 15. **Docker push sometimes fails with EOF** — retry usually works, likely transient network issue 16. **Coolify `restart` may only recreate some containers** — use `stop` + `start` (two calls) for full recreation --- ## Git Commits All commits made to the `alezmad/turbostarter` repo during deployment: 1. **`9b893ea` — `Make NEXT_PUBLIC_POSTHOG_KEY optional`** — PostHog analytics env var 2. **`f1f67dd` — `Make BETTER_AUTH_SECRET optional with default`** — Auth secret env var 3. **`709235c` — `Make Stripe env vars optional with defaults`** — Stripe billing env vars 4. **`a5b6284` — `Make web app env vars optional with defaults`** — CONTACT_EMAIL, PRODUCT_NAME, URL 5. **`1951f67` — `Make S3 and Sentry env vars optional with defaults`** — S3 + Sentry monitoring 6. **`a41ccd5` — `Fix PostHog TypeScript error with non-null assertion`** — analytics package 7. **`999e30f` — `Fix second PostHog TypeScript error in monitoring package`** — monitoring package 8. **`989aa37` — `Add nixpacks.toml to remove chromium from builds`** — Remove browser deps 9. **`b0ab5d5` — `Add Docker support for local builds`** — Dockerfile, .dockerignore, build-and-push.sh 10. **`9a3a011` — `Skip TypeScript checking in build to prevent Coolify timeout`** — `ignoreBuildErrors` 11. **`Make TTS page dynamic to avoid build-time API call`** — `force-dynamic` export 12. **`cf8b3e8` — `fix: correct start command filter to use 'web' package name`** — `web` not `@turbostarter/web` 13. **`b26f725` — `feat: production deployment with HTTPS and trusted origins`** — NEXT_PUBLIC_URL + BETTER_AUTH_TRUSTED_ORIGINS *(Plus additional commits for DATABASE_URL, EMAIL_FROM, RESEND_API_KEY env var defaults)* --- ## Files Created/Modified | File | Action | Purpose | |------|--------|---------| | `Dockerfile` | Created/Modified | Production Docker build (final: single-stage + standalone) | | `.dockerignore` | Created | Exclude unnecessary files from Docker context | | `scripts/build-and-push.sh` | Created | Convenience script for build + push | | `nixpacks.toml` | Created | Nixpacks config (remove chromium, fix start command) | | `apps/web/next.config.ts` | Modified | `output: "standalone"`, `ignoreBuildErrors`, security headers | | `apps/web/src/app/[locale]/(apps)/tts/page.tsx` | Modified | `force-dynamic` export | | `packages/auth/src/server.ts` | Modified | `BETTER_AUTH_TRUSTED_ORIGINS` env var support | | `packages/auth/src/env.ts` | Modified | `BETTER_AUTH_SECRET` made optional with default | | `packages/analytics/web/src/providers/posthog/env.ts` | Modified | `NEXT_PUBLIC_POSTHOG_KEY` optional | | `packages/monitoring/web/src/providers/posthog/env.ts` | Modified | `NEXT_PUBLIC_POSTHOG_KEY` optional | | `packages/cms/package.json` | Modified | Added missing `zod` dependency | | `packages/storage/src/providers/s3/env.ts` | Modified | S3 env vars optional with defaults | | `packages/monitoring/web/src/providers/sentry/env.ts` | Modified | Sentry DSN optional | | `packages/monitoring/web/src/providers/posthog/index.ts` | Modified | Non-null assertion for PostHog init | | `packages/analytics/web/src/providers/posthog/index.tsx` | Modified | Non-null assertion for PostHog init | | `apps/web/env.config.ts` | Modified | Web app env vars optional with defaults | | `packages/billing/src/providers/stripe/env.ts` | Modified | Stripe env vars optional | | `packages/db/src/env.ts` | Modified | DATABASE_URL optional with default | | `packages/email/src/utils/env.ts` | Modified | EMAIL_FROM optional with default | | `packages/email/src/providers/resend/env.ts` | Modified | RESEND_API_KEY optional | | `~/.docker/daemon.json` (local Mac) | Modified | Added insecure-registries for Gitea | ### Known Issue **OG image URLs still reference `localhost:3000`**: The `NEXT_PUBLIC_URL` defaults to `http://localhost:3000` in the app's env config. While it's set correctly at Docker build time via `--build-arg`, meta tags for OG images may still reference `localhost:3000` if runtime env detection falls back to the default. This is cosmetic but affects social sharing previews.