Files
nuc/docs/turbostarter-deployment.md
Alejandro Gutiérrez 8b503a549c 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>
2026-02-18 15:17:18 +01:00

30 KiB

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
  2. Architecture
  3. Source Code Modifications
  4. Dockerfile (Final Working Version)
  5. Docker Compose (Coolify Service)
  6. Gitea Container Registry Setup
  7. Build & Deploy Workflow
  8. Database Setup
  9. Seed Data
  10. HTTPS via Tailscale Funnel
  11. Environment Variables
  12. Credentials
  13. Error History & Fixes
  14. Gotchas & Lessons Learned
  15. Git Commits Made
  16. Files Created/Modified

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:

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

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

// 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

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

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.tsxposthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, {
  • packages/monitoring/web/src/providers/posthog/index.tsposthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, {

5. packages/auth/src/env.ts — BETTER_AUTH_SECRET optional with default

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

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

NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),

8. packages/billing/src/providers/stripe/env.ts — Stripe env vars optional

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

// Changed from z.url() to:
DATABASE_URL: z.string().optional().default(""),

10. packages/email/src/utils/env.ts — Email env vars optional

EMAIL_FROM: z.string().optional().default("noreply@example.com"),

11. packages/email/src/providers/resend/env.ts — Resend API key optional

RESEND_API_KEY: z.string().optional().default(""),

12. apps/web/env.config.ts — Web app env vars optional with defaults

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

"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

[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.

# 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:

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:

    [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:

    {
      "insecure-registries": ["192.168.1.3:3030"]
    }
    

    Then restart Docker Desktop.

  4. Login to registry:

    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

# 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)

#!/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:

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):

# 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

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).

# 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
# 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.tspackages/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. 9b893eaMake NEXT_PUBLIC_POSTHOG_KEY optional — PostHog analytics env var
  2. f1f67ddMake BETTER_AUTH_SECRET optional with default — Auth secret env var
  3. 709235cMake Stripe env vars optional with defaults — Stripe billing env vars
  4. a5b6284Make web app env vars optional with defaults — CONTACT_EMAIL, PRODUCT_NAME, URL
  5. 1951f67Make S3 and Sentry env vars optional with defaults — S3 + Sentry monitoring
  6. a41ccd5Fix PostHog TypeScript error with non-null assertion — analytics package
  7. 999e30fFix second PostHog TypeScript error in monitoring package — monitoring package
  8. 989aa37Add nixpacks.toml to remove chromium from builds — Remove browser deps
  9. b0ab5d5Add Docker support for local builds — Dockerfile, .dockerignore, build-and-push.sh
  10. 9a3a011Skip TypeScript checking in build to prevent Coolify timeoutignoreBuildErrors
  11. Make TTS page dynamic to avoid build-time API callforce-dynamic export
  12. cf8b3e8fix: correct start command filter to use 'web' package nameweb not @turbostarter/web
  13. b26f725feat: 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.