Add operational documentation

CloudBeaver database manager guide, Ecija intranet deployment,
Gitea-Coolify auto-deploy and integration docs, monitoring setup
with presentation, remote access guide, security architecture,
and Turbostarter deployment procedure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-18 15:17:18 +01:00
parent 1aa7ebcde3
commit 8b503a549c
9 changed files with 3817 additions and 0 deletions

View File

@@ -0,0 +1,872 @@
# 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.