From 8b503a549ca08b531b8358e66e7dd8a8dc68d7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:17:18 +0100 Subject: [PATCH] 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 --- docs/cloudbeaver-database-manager.md | 248 ++++++++ docs/ecija-intranet-deployment.md | 731 ++++++++++++++++++++++ docs/gitea-coolify-auto-deploy.md | 250 ++++++++ docs/gitea-coolify-integration.md | 314 ++++++++++ docs/monitoring-presentation.html | 665 ++++++++++++++++++++ docs/monitoring.md | 156 +++++ docs/remote-access.md | 252 ++++++++ docs/security.md | 329 ++++++++++ docs/turbostarter-deployment.md | 872 +++++++++++++++++++++++++++ 9 files changed, 3817 insertions(+) create mode 100644 docs/cloudbeaver-database-manager.md create mode 100644 docs/ecija-intranet-deployment.md create mode 100644 docs/gitea-coolify-auto-deploy.md create mode 100644 docs/gitea-coolify-integration.md create mode 100644 docs/monitoring-presentation.html create mode 100644 docs/monitoring.md create mode 100644 docs/remote-access.md create mode 100644 docs/security.md create mode 100644 docs/turbostarter-deployment.md diff --git a/docs/cloudbeaver-database-manager.md b/docs/cloudbeaver-database-manager.md new file mode 100644 index 0000000..e01643a --- /dev/null +++ b/docs/cloudbeaver-database-manager.md @@ -0,0 +1,248 @@ +# CloudBeaver Database Manager + +**Date:** 2026-02-03 +**Context:** Centralized database management UI for all NUC databases + +## Summary + +CloudBeaver CE (v24) provides a web-based SQL client connected to all databases on the NUC server. Instead of auto-discovery (which CloudBeaver CE doesn't support), all 9 database connections are pre-configured via `data-sources.json` and the container is connected to every relevant Docker network for direct container-to-container access. + +## Access + +| Property | Value | +|----------|-------| +| **URL** | `http://192.168.1.3:8978` | +| **Admin User** | `cbadmin` | +| **Admin Password** | `CloudBeaver2026!` | +| **Coolify UUID** | `joo4g4k0w08k8kcosgsgswc0` | + +## Connected Databases + +### Coolify Standalone DBs + +| Connection | Type | Host (Container) | Database | User | +|------------|------|-------------------|----------|------| +| WhyRating Hub | PostgreSQL | `i8skkc8cwsgwgsg0g8kcw44k` | whyrating | whyrating | +| Turbostarter | PostgreSQL | `db-v4gogwwc8wkk4888ksscc4k4` | core | turbostarter | +| LiquidGym (MySQL) | MySQL 8 | `hgwcgs4oswwc8scg080scoo4` | liquidgym | liquidgym | + +### Service Embedded DBs + +| Connection | Type | Host (Container) | Database | User | +|------------|------|-------------------|----------|------| +| Outline | PostgreSQL | `postgres-pccg80wks4c084008owokkkg` | outline | HVubx2MKadO9V4JU | +| Google Scraper | PostgreSQL | `postgres-g4s8w4csk8s8ocswg48kkogo` | scraper | scraper | +| LiquidGym (Postgres) | PostgreSQL | `postgres-x4kk8g4k8w4g0cw480w84g4g` | postgres | postgres | +| Knosia | PostgreSQL | `postgres-ik80skko0008w4000c4w40os` | knosia | knosia | +| Authentik | PostgreSQL | `postgresql-e8owcw0s4wcswc4w4css0sws` | authentik | yth9ADhCXAsYytvI | + +### Infrastructure + +| Connection | Type | Host (Container) | Database | User | Access | +|------------|------|-------------------|----------|------|--------| +| Coolify DB | PostgreSQL | `coolify-db` | coolify | coolify | Read-only | + +## Architecture + +### How It Works + +``` +CloudBeaver Container +├── Network: coolify → coolify-db +├── Network: pccg80... → postgres-pccg80... (Outline) +├── Network: e8owcw... → postgresql-e8owcw... (Authentik) +├── Network: g4s8w4... → postgres-g4s8w4... (Google Scraper) +├── Network: x4kk8g... → postgres-x4kk8g... (LiquidGym PG + MySQL) +├── Network: ik80sk... → postgres-ik80sk... (Knosia) +├── Network: v4gogw... → db-v4gogw... (Turbostarter) +└── Volume: cloudbeaver-data → /opt/cloudbeaver/workspace (persistent) +``` + +CloudBeaver connects directly to database containers via Docker network DNS. No port forwarding or host networking needed — container names resolve within shared networks. + +### Why Not Auto-Discovery? + +CloudBeaver CE has no native auto-discovery. A Docker API-based script was considered but rejected due to: +- **Credential mismatch:** No reliable way to get DB passwords from Docker +- **Container name churn:** Coolify uses random UUIDs for container names +- **False positives:** Not all containers with port 5432 are accessible DBs + +Pre-configured `data-sources.json` is more reliable and predictable. + +## Coolify Compose + +```yaml +services: + cloudbeaver: + image: 'dbeaver/cloudbeaver:24' + volumes: + - 'cloudbeaver-data:/opt/cloudbeaver/workspace' + ports: + - '8978:8978' + environment: + - SERVICE_URL_CLOUDBEAVER_8978 + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8978"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - default + - coolify + - outline-net + - authentik-net + - scraper-net + - liquidgym-pg-net + - knosia-net + - turbostarter-net +networks: + coolify: + external: true + name: coolify + outline-net: + external: true + name: pccg80wks4c084008owokkkg + authentik-net: + external: true + name: e8owcw0s4wcswc4w4css0sws + scraper-net: + external: true + name: g4s8w4csk8s8ocswg48kkogo + liquidgym-pg-net: + external: true + name: x4kk8g4k8w4g0cw480w84g4g + knosia-net: + external: true + name: ik80skko0008w4000c4w40os + turbostarter-net: + external: true + name: v4gogwwc8wkk4888ksscc4k4 +``` + +## Configuration Files + +| File | Location | Purpose | +|------|----------|---------| +| **data-sources.json** | `/opt/cloudbeaver/workspace/GlobalConfiguration/.dbeaver/data-sources.json` | Connection definitions (host, port, db, driver) | +| **initial-data-sources.conf** | `/opt/cloudbeaver/conf/initial-data-sources.conf` | Backup copy for fresh container init | +| **Credentials** | Internal H2 database at `/opt/cloudbeaver/workspace/.data/cb.h2v2.dat` | Encrypted password storage | + +## Adding a New Database + +### 1. Find the database's Docker network + +```bash +ssh nuc "docker inspect --format '{{json .NetworkSettings.Networks}}' | jq -r 'keys[]'" +``` + +### 2. Add network to Coolify compose + +Add a new entry under both `services.cloudbeaver.networks` and `networks`: + +```yaml +services: + cloudbeaver: + networks: + - new-db-net # Add this + +networks: + new-db-net: # Add this + external: true + name: +``` + +Update via Coolify MCP: +```python +mcp__coolify__service(action="update", uuid="joo4g4k0w08k8kcosgsgswc0", docker_compose_raw="") +``` + +### 3. Add connection to data-sources.json + +```bash +ssh nuc "docker exec cloudbeaver-joo4g4k0w08k8kcosgsgswc0 cat /opt/cloudbeaver/workspace/GlobalConfiguration/.dbeaver/data-sources.json" +# Edit and write back the updated JSON with the new connection entry +``` + +Connection entry format: +```json +{ + "new-db-id": { + "provider": "postgresql", + "driver": "postgres-jdbc", + "name": "Display Name", + "save-password": true, + "folder": "coolify-standalone", + "configuration": { + "host": "", + "port": "5432", + "database": "", + "url": "jdbc:postgresql://:5432/", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native" + } + } +} +``` + +### 4. Redeploy and set credentials + +```python +# Redeploy to pick up network changes +mcp__coolify__deploy(tag_or_uuid="joo4g4k0w08k8kcosgsgswc0") +``` + +Then set credentials via the CloudBeaver UI (log in, click the connection, enter username/password, check "Save credentials"). + +Or via GraphQL API: +```bash +# Authenticate first +curl -s http://192.168.1.3:8978/api/gql -H 'Content-Type: application/json' \ + -c /tmp/cb-cookies \ + -d '{"query":"mutation { authLogin(provider:\"local\", credentials:{user:\"cbadmin\",password:\"CloudBeaver2026!\"}) { authId } }"}' + +# Initialize connection with credentials +curl -s http://192.168.1.3:8978/api/gql -H 'Content-Type: application/json' \ + -b /tmp/cb-cookies \ + -d '{"query":"mutation { initConnection(id:\"new-db-id\", credentials:{userName:\"user\",userPassword:\"pass\"}, saveCredentials:true) { id name connected } }"}' +``` + +## Troubleshooting + +### Connection shows "Not connected" + +1. **Check container exists:** + ```bash + ssh nuc "docker ps | grep " + ``` + +2. **Check network connectivity:** + ```bash + ssh nuc "docker exec cloudbeaver-joo4g4k0w08k8kcosgsgswc0 ping -c1 " + ``` + +3. **Check credentials are saved:** Log into CloudBeaver UI, click the connection, verify username/password fields are filled. + +### MySQL "Public Key Retrieval not allowed" + +MySQL 8 requires `allowPublicKeyRetrieval: true` in JDBC properties. Set via CloudBeaver UI: Connection → Driver Properties → Add `allowPublicKeyRetrieval` = `true`. + +### "admin" username reserved + +CloudBeaver CE reserves `admin` as a team name. Use a different admin username (we use `cbadmin`). + +### Server shows "configuration expired" + +This happens when CloudBeaver's initial setup wizard hasn't been completed. Access the UI at `http://192.168.1.3:8978` and complete the 3-step wizard (Welcome → Server Config → Confirm). + +### Connections lost after redeploy + +Connection definitions persist in `data-sources.json` on the volume. However, **credentials are stored in the H2 database** (`cb.h2v2.dat`). Both are on the `cloudbeaver-data` volume and survive normal redeploys. If the volume is deleted, connections will reload from `data-sources.json` but credentials will need to be re-entered. + +## Related + +- **Coolify Dashboard:** `http://192.168.1.3:8000` +- **Adminer (lightweight alternative):** `http://192.168.1.3:8088` +- **NocoDB (spreadsheet-style):** `http://192.168.1.3:8084` +- **CloudBeaver Docs:** https://dbeaver.com/docs/cloudbeaver/ diff --git a/docs/ecija-intranet-deployment.md b/docs/ecija-intranet-deployment.md new file mode 100644 index 0000000..5013cf7 --- /dev/null +++ b/docs/ecija-intranet-deployment.md @@ -0,0 +1,731 @@ +# ECIJA Intranet - NUC Deployment Guide + +> **Purpose:** Step-by-step deployment of the ECIJA Intranet Django backend on the NUC home server. +> **Source project:** `/Users/agutierrez/Desktop/ECIJA-Intranet/` +> **Database dump:** `/Users/agutierrez/Desktop/flexicar-intranet-backend/dump-flexicar-202602071346-backup/` +> **Credentials file:** `/Users/agutierrez/Desktop/flexicar-intranet-backend/windy-shoreline-225910-efd33901e56c.json` +> **Installation reference:** `/Users/agutierrez/Desktop/flexicar-intranet-backend/instalacion_intra.docx` + +--- + +## Architecture Overview + +``` + ┌─────────────────────────────────────────────┐ + │ NUC Server (192.168.1.3) │ + │ Tailscale: 100.113.153.45 │ + │ │ + intranet.nuc.lan │ ┌──────────┐ ┌────────────────────┐ │ + ──────────────────►│ │ Traefik │───►│ Django/Gunicorn │ │ + │ │ :80/:443 │ │ :8010 │ │ + │ └──────────┘ └────────┬───────────┘ │ + │ │ │ + │ ┌────────┼───────┐ │ + │ │ │ │ │ + │ ┌──────▼──┐ ┌───▼───┐ │ │ + │ │Postgres │ │ Redis │ │ │ + │ │ :5434 │ │ :6379 │ │ │ + │ └─────────┘ └───────┘ │ │ + │ │ │ + │ ┌──────────────────────┐│ │ + │ │ Background Worker ││ │ + │ │ (process_tasks) ││ │ + │ └──────────────────────┘│ │ + └───────────────────────────────────┼────────┘ + │ + ▼ + ┌──────────────────┐ + │ Google Cloud │ + │ Storage (GCS) │ + │ Bucket: │ + │ ecija-intranet │ + └──────────────────┘ +``` + +### Design principle: change nothing, only deploy + +The NUC is the **compute host only**. All external integrations (GCS, HubSpot, Azure AD, 3G, reCAPTCHA) stay exactly as the original app expects them. This means: +- Zero code changes to `settings.py` +- Same env vars as the official installation doc +- Same GCS bucket and credentials +- If it works locally on a Mac, it works on the NUC + +### What the NUC already provides (no setup needed) + +| Service | Port | Notes | +|---------|------|-------| +| Redis | 6379 | UUID: `vkg44cgcss4ococgk0cs000o` - reuse existing | +| Traefik | 80/443 | Reverse proxy for `intranet.nuc.lan` | +| n8n | 5678 | Workflow automation (intranet has n8n webhooks) | +| Adminer | 8088 | DB admin UI | +| CloudBeaver | 8978 | DB admin UI (alternative) | +| Dozzle | 9999 | Container log viewer | +| Uptime Kuma | 3001 | Service monitoring | +| Kopia | 51515 | Backup management | + +### What needs to be deployed + +| Service | Port | Method | +|---------|------|--------| +| PostgreSQL (dedicated) | 5434 | Coolify service | +| Django + Gunicorn | 8010 | Coolify docker-compose | +| Background Worker | - | Same image, different command | + +--- + +## Phase 1: PostgreSQL Database + +### 1.1 Deploy a dedicated PostgreSQL instance via Coolify + +Use port **5434** to avoid conflicts with existing Postgres instances (5432, 5433, 5442 already in use). + +```bash +# Via Coolify MCP: +mcp__coolify__service( + action="create", + type="postgresql", + name="ecija-intranet-db", + server_uuid="qk84w0goo4w48g4ggsoo0oss", + project_uuid="a8484ggc88c40w4g4k004ow0", + environment_name="production", + instant_deploy=True +) +``` + +If native type fails, deploy via docker-compose: + +```yaml +services: + ecija-postgres: + image: postgres:17 + container_name: ecija-intranet-db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: flexicar-prod + ports: + - "5434:5432" + volumes: + - ecija_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d flexicar-prod"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + ecija_pgdata: +``` + +### 1.2 Enable required PostgreSQL extensions + +```bash +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'" +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'" +``` + +### 1.3 Transfer and restore the database dump + +The dump is in pg_dump **directory format** (4 GB, 453 tables). + +**Important:** The dump was exported from a database called `flexicar` with owner `flexicar`, but the Django app expects `DB_NAME=flexicar-prod` with `DATABASE_USER=postgres` (per the official install doc). We restore into `flexicar-prod` using `--no-owner` so all objects are owned by `postgres`. + +```bash +# Step 1: Transfer dump directory to NUC +scp -r /Users/agutierrez/Desktop/flexicar-intranet-backend/dump-flexicar-202602071346-backup/ nuc:/tmp/ecija-dump/ + +# Step 2: Copy dump into the Postgres container +ssh nuc "docker cp /tmp/ecija-dump/ ecija-intranet-db:/tmp/ecija-dump/" + +# Step 3: Restore the dump into flexicar-prod (ignore original ownership) +ssh nuc "docker exec ecija-intranet-db pg_restore \ + -U postgres \ + -d flexicar-prod \ + --no-owner \ + --no-privileges \ + --jobs=4 \ + /tmp/ecija-dump/" + +# Step 4: Verify table count (should be ~453) +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c \"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';\"" + +# Step 5: Clean up dump from container +ssh nuc "docker exec ecija-intranet-db rm -rf /tmp/ecija-dump/" + +# Step 6: Clean up dump from NUC host +ssh nuc "rm -rf /tmp/ecija-dump/" +``` + +### 1.4 Verify database is accessible + +```bash +# From NUC +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'SELECT current_database(), current_user;'" + +# From Adminer (browser) +# URL: http://192.168.1.3:8088 +# System: PostgreSQL +# Server: ecija-intranet-db:5432 (or 192.168.1.3:5434 if external) +# Username: postgres +# Password: postgres +# Database: flexicar-prod +``` + +--- + +## Phase 2: Transfer Project Files + +### 2.1 Transfer the project to NUC + +```bash +# Exclude unnecessary files (venv, node_modules, caches) +rsync -avz --progress \ + --exclude '.git' \ + --exclude 'env/' \ + --exclude 'node_modules/' \ + --exclude '.DS_Store' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + /Users/agutierrez/Desktop/ECIJA-Intranet/ \ + nuc:/opt/ecija-intranet/ +``` + +### 2.2 Transfer the Google Cloud Storage credentials + +The GCS service account key is required for static/media file storage. This is the same file used in the original production environment. + +**Important:** The official install doc sets `GS_CREDENTIALS="/secrets/bucket/credentials.json"` (generic name), while `GS_CREDENTIALS_LOCAL` uses the full filename. We mount the credentials dir to `/secrets/bucket/` and copy the file as `credentials.json` to match the production `GS_CREDENTIALS` path. + +```bash +# Create the credentials directory on NUC +ssh nuc "mkdir -p /opt/ecija-intranet/config/credenciales" + +# Transfer the service account JSON (keep original name for GS_CREDENTIALS_LOCAL) +scp /Users/agutierrez/Desktop/flexicar-intranet-backend/windy-shoreline-225910-efd33901e56c.json \ + nuc:/opt/ecija-intranet/config/credenciales/windy-shoreline-225910-efd33901e56c.json + +# Also copy as credentials.json (for GS_CREDENTIALS=/secrets/bucket/credentials.json) +ssh nuc "cp /opt/ecija-intranet/config/credenciales/windy-shoreline-225910-efd33901e56c.json \ + /opt/ecija-intranet/config/credenciales/credentials.json" +``` + +### 2.3 Optionally push to NUC's Gitea for version control + +```bash +# From local machine +cd /Users/agutierrez/Desktop/ECIJA-Intranet + +# Create the repo in Gitea first (via browser at http://192.168.1.3:3030 or API) +# Then add remote and push +git remote add nuc http://192.168.1.3:3030/alezmad/ecija-intranet.git +git push nuc main +``` + +--- + +## Phase 3: Django Application Container + +### 3.1 Create the production Dockerfile + +The existing Dockerfile uses `gcr.io/google_appengine/python` (Google App Engine base image) which is unnecessary on the NUC. + +Create file on NUC at `/opt/ecija-intranet/Dockerfile.nuc`: + +```dockerfile +FROM python:3.9-slim + +# System dependencies for psycopg2, Pillow, lxml, pdf libs +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + libjpeg-dev \ + libfreetype6-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ + libffi-dev \ + libmupdf-dev \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js 18 for frontend build +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python dependencies (root level - includes all packages) +COPY requirements.txt /app/requirements.txt +RUN pip install --upgrade pip && \ + pip install --use-pep517 -r /app/requirements.txt + +# Copy application code +COPY app/ /app/ + +# Frontend dependencies and build +RUN npm install && npm run build + +EXPOSE 8010 + +# Entrypoint script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["gunicorn", "ecija.wsgi:application", \ + "--workers", "4", \ + "--threads", "4", \ + "--timeout", "120", \ + "--graceful-timeout", "30", \ + "--keep-alive", "5", \ + "--max-requests", "1000", \ + "--max-requests-jitter", "100", \ + "--bind", "0.0.0.0:8010", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "--log-level", "info"] +``` + +### 3.2 Create the entrypoint script + +Create file on NUC at `/opt/ecija-intranet/docker-entrypoint.sh`: + +```bash +#!/bin/bash +set -e + +echo "Running database migrations..." +python manage.py migrate --noinput + +echo "Collecting static files..." +python manage.py collectstatic --noinput || true + +echo "Starting application..." +exec "$@" +``` + +### 3.3 Deploy via Coolify docker-compose + +Deploy through Coolify MCP using `docker_compose_raw`: + +```yaml +services: + ecija-web: + build: + context: /opt/ecija-intranet + dockerfile: Dockerfile.nuc + container_name: ecija-intranet-web + restart: unless-stopped + ports: + - "8010:8010" + env_file: + - /opt/ecija-intranet/.env.production + volumes: + - ecija_media:/app/media + - /opt/ecija-intranet/config/credenciales:/secrets/bucket:ro + networks: + - ecija-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8010/"] + interval: 30s + timeout: 10s + retries: 3 + + ecija-worker: + build: + context: /opt/ecija-intranet + dockerfile: Dockerfile.nuc + container_name: ecija-intranet-worker + restart: unless-stopped + command: ["python", "manage.py", "process_tasks"] + env_file: + - /opt/ecija-intranet/.env.production + volumes: + - ecija_media:/app/media + - /opt/ecija-intranet/config/credenciales:/secrets/bucket:ro + networks: + - ecija-network + +volumes: + ecija_media: + +networks: + ecija-network: + driver: bridge +``` + +--- + +## Phase 4: Environment Configuration + +### 4.1 Create the production env file + +This uses the **same values from the official installation doc** (`instalacion_intra.docx`), with only the DB host changed to point to the NUC container. + +Create on NUC at `/opt/ecija-intranet/.env.production`: + +```bash +# ============================================================ +# ECIJA Intranet - NUC Production Environment +# Source: /Users/agutierrez/Desktop/flexicar-intranet-backend/instalacion_intra.docx +# Principle: IDENTICAL to official install doc, only DB_HOST changed +# ============================================================ +# Lines below are a 1:1 copy from the docx. Only DB_HOST differs +# (container name instead of 127.0.0.1). +# Do NOT add extra vars that aren't in the original doc. + +DATABASE_USER=postgres +DATABASE_PASSWORD=postgres +DJANGO_ENVIRONMENT=local +MS_SECRET_KEY=H6CLoLV?kO.uaZOI3lkiHv=L:jWqk12t +G_RECAPTCHA_SECRET_KEY= +G_RECAPTCHA_SITE_KEY= +DEBUG=True +DEMO=False +GKE_ENABLED=True +AG=False +GS=True +RECAPTCHA=True +DB_NAME_DEMO=ecija_demo +DB_NAME=flexicar-prod +DB_HOST=ecija-intranet-db +DB_PORT=5432 +STATIC_URL=/static/ +MEDIA_URL=/media/ +GS_BUCKET_NAME=ecija-intranet +GS_PROJECT_ID=ecija-intranet +GS_CREDENTIALS=/secrets/bucket/credentials.json +TENANT_ID=8e39b277-105b-4b2e-b21c-e06cd806a070 +CLIENT_ID=6858acbb-098f-4261-987d-6a9892b06d86 +RELYING_PARTY_ID=api://18d841fc-2054-4b76-992a-2a11fc9ade78 +AUDIENCE=api://18d841fc-2054-4b76-992a-2a11fc9ade78 +CODIGO_EMPRESA_3G=ecijades +LOGIN_3G=ws64304 +PASSWORD_3G=z4JcpcTqjd +IDIOMA_3G=ES +API_KEY_3G=m23o2MHszn128snAkwkaAKnzLK29s91klalzl19Jjkzj19zlxlnwZNmMnN1812nznNMjsjz9MnznWwqsjzlSAK2znqh0z9134 +APPS_INSTALADAS=django_auth_adfs,ecija,meta_aepd_app,agile,gestor_licencias,telefonia,administracion,certificados3g,ticketing,analytics,demo,gestion_documental,sp_widgets,configuracion_interfaz,widgets_dashboard,historico,comentarios,avisos,procesal,canal_denuncias,referidos,notificacion,busqueda_google,tareas,bank_movements,notas,oportunidades,reclamaciones,nux,eventos,fleximanage,comunicaciones +HUBSPOT_API_URL_COMPANIES=https://api.hubapi.com/companies/v2/companies +HUBSPOT_API_URL_CONTACTS=https://api.hubapi.com/contacts/v1/contact +HUBSPOT_API_KEY=pat-eu1-5b7e6860-e273-4a92-b6f8-b6eead476505 +GS_CREDENTIALS_LOCAL=/secrets/bucket/windy-shoreline-225910-efd33901e56c.json +GOOGLE_APPLICATION_CREDENTIALS=/secrets/bucket/windy-shoreline-225910-efd33901e56c.json +``` + +### 4.2 Alignment with official install doc + +The `.env.production` file is a **1:1 copy** of the env vars from `instalacion_intra.docx` with one change: + +| Variable | Official doc | NUC deployment | Why | +|----------|-------------|----------------|-----| +| `DB_HOST` | `127.0.0.1` | `ecija-intranet-db` | Container networking (Docker DNS resolves container names) | + +Everything else is identical. GCS stays because: +- The app is built on `django-storages[google]`, not S3 +- The credentials already work +- Zero code changes required +- Same config carries over to real production + +--- + +## Phase 5: Networking & DNS + +### 5.1 Add DNS entry for `intranet.nuc.lan` + +```bash +ssh nuc "ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 ' +uci add dhcp domain +uci set dhcp.@domain[-1].name=\"intranet.nuc.lan\" +uci set dhcp.@domain[-1].ip=\"100.113.153.45\" +uci commit dhcp +/etc/init.d/dnsmasq restart +'" +``` + +### 5.2 Add Traefik route + +Edit on NUC: `/data/coolify/proxy/dynamic/nuc-services.yaml` + +Add the following router and service (merge with existing entries): + +```yaml +http: + routers: + ecija-intranet: + rule: Host(`intranet.nuc.lan`) + service: ecija-intranet + entryPoints: + - http + services: + ecija-intranet: + loadBalancer: + servers: + - url: http://host.docker.internal:8010 +``` + +### 5.3 Update Homepage dashboard + +Edit on NUC: `/opt/homepage/config/services.yaml` + +Add under appropriate section: + +```yaml +- ECIJA Intranet: + - Intranet: + href: http://intranet.nuc.lan + description: ECIJA Law Firm Intranet + icon: django + server: nuc + container: ecija-intranet-web +``` + +--- + +## Phase 6: Monitoring & Backup + +### 6.1 Add to Uptime Kuma + +Via browser at `http://192.168.1.3:3001`: +1. Add new monitor +2. Type: HTTP(s) +3. URL: `http://192.168.1.3:8010` +4. Name: ECIJA Intranet +5. Heartbeat interval: 60s + +### 6.2 Add database to Kopia backup + +The PostgreSQL volume `ecija_pgdata` should be included in Kopia's backup schedule. + +```bash +# Verify the volume exists +ssh nuc "docker volume inspect ecija_pgdata" + +# Kopia should auto-discover Docker volumes +# Verify in Kopia UI at http://192.168.1.3:51515 +``` + +--- + +## Phase 7: Verification Checklist + +Run these commands after deployment to verify everything works: + +```bash +# 1. Database is running and accessible +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'SELECT count(*) FROM information_schema.tables WHERE table_schema = '\''public'\'';'" +# Expected: ~453 tables + +# 2. Extensions are enabled +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'SELECT extname FROM pg_extension;'" +# Expected: pg_trgm, unaccent, plpgsql + +# 3. Django container is running +ssh nuc "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep ecija" +# Expected: ecija-intranet-web (Up), ecija-intranet-worker (Up) + +# 4. Django migrations applied +ssh nuc "docker logs ecija-intranet-web 2>&1 | grep -i 'migrat'" + +# 5. Web server responds +ssh nuc "curl -s -o /dev/null -w '%{http_code}' http://localhost:8010/" +# Expected: 200 or 302 (redirect to login) + +# 6. GraphQL API responds +ssh nuc "curl -s -o /dev/null -w '%{http_code}' http://localhost:8010/api/graphql/" +# Expected: 200 or 400 (no query provided = normal) + +# 7. Background worker is processing +ssh nuc "docker logs ecija-intranet-worker 2>&1 | tail -5" + +# 8. DNS resolves +nslookup intranet.nuc.lan +# Expected: 100.113.153.45 + +# 9. Traefik routes correctly +curl -s -o /dev/null -w '%{http_code}' http://intranet.nuc.lan/ +# Expected: 200 or 302 + +# 10. GCS connectivity (from web container) +ssh nuc "docker exec ecija-intranet-web python -c \" +from google.cloud import storage +client = storage.Client() +bucket = client.bucket('ecija-intranet') +print('GCS connection OK, bucket:', bucket.name) +\"" +``` + +--- + +## Quick Reference + +### Service URLs (after deployment) + +| Service | URL | Direct Port | +|---------|-----|-------------| +| Intranet Web | `http://intranet.nuc.lan` | `http://192.168.1.3:8010` | +| Intranet DB | - | `192.168.1.3:5434` | +| DB Admin (Adminer) | - | `http://192.168.1.3:8088` | +| Container Logs | - | `http://192.168.1.3:9999` (Dozzle) | + +### Container Names + +| Container | Purpose | +|-----------|---------| +| `ecija-intranet-db` | PostgreSQL 17 database | +| `ecija-intranet-web` | Django + Gunicorn web server | +| `ecija-intranet-worker` | Background task processor | + +### Database Credentials + +| Field | Value | +|-------|-------| +| Host (from container) | `ecija-intranet-db` | +| Host (from NUC host) | `192.168.1.3` | +| Port (internal) | `5432` | +| Port (external) | `5434` | +| User | `postgres` | +| Password | `postgres` | +| Database | `flexicar-prod` | + +### Key File Locations (on NUC) + +| File | Path | +|------|------| +| Project root | `/opt/ecija-intranet/` | +| Django app | `/opt/ecija-intranet/app/` | +| Environment file | `/opt/ecija-intranet/.env.production` | +| GCP credentials | `/opt/ecija-intranet/config/credenciales/windy-shoreline-225910-efd33901e56c.json` | +| Dockerfile | `/opt/ecija-intranet/Dockerfile.nuc` | +| Entrypoint | `/opt/ecija-intranet/docker-entrypoint.sh` | +| Media volume | Docker volume `ecija_media` | +| DB volume | Docker volume `ecija_pgdata` | +| Traefik config | `/data/coolify/proxy/dynamic/nuc-services.yaml` | +| Homepage config | `/opt/homepage/config/services.yaml` | + +### Key File Locations (on Mac - source files) + +| File | Path | +|------|------| +| Project source | `/Users/agutierrez/Desktop/ECIJA-Intranet/` | +| Database dump (directory) | `/Users/agutierrez/Desktop/flexicar-intranet-backend/dump-flexicar-202602071346-backup/` | +| Database dump (tar) | `/Users/agutierrez/Desktop/flexicar-intranet-backend/dump-flexicar-202602071346-backup.tar` | +| GCP service account key | `/Users/agutierrez/Desktop/flexicar-intranet-backend/windy-shoreline-225910-efd33901e56c.json` | +| Installation doc | `/Users/agutierrez/Desktop/flexicar-intranet-backend/instalacion_intra.docx` | +| Original docker-compose | `/Users/agutierrez/Desktop/flexicar-intranet-backend/docker-compose.yaml` | + +### Coolify Identifiers + +| Field | Value | +|-------|-------| +| Server UUID | `qk84w0goo4w48g4ggsoo0oss` | +| Project UUID | `a8484ggc88c40w4g4k004ow0` | +| Environment | `production` | + +--- + +## Troubleshooting + +### Django won't start - missing module + +```bash +# Check logs +ssh nuc "docker logs ecija-intranet-web 2>&1 | tail -30" + +# If pip dependency issue, exec into container +ssh nuc "docker exec -it ecija-intranet-web pip install " +``` + +### Database connection refused + +```bash +# Verify Postgres is running +ssh nuc "docker ps | grep ecija-intranet-db" + +# Check if containers share a network +ssh nuc "docker network inspect ecija-network" + +# Test connectivity from web container +ssh nuc "docker exec ecija-intranet-web python -c \" +import psycopg2 +conn = psycopg2.connect(host='ecija-intranet-db', port=5432, user='postgres', password='postgres', dbname='flexicar-prod') +print('Connection OK') +conn.close() +\"" +``` + +### Static files not loading (GCS issues) + +```bash +# Test GCS credentials from container +ssh nuc "docker exec ecija-intranet-web python -c \" +from google.cloud import storage +client = storage.Client() +buckets = list(client.list_buckets()) +print('Accessible buckets:', [b.name for b in buckets]) +\"" + +# Verify credentials file is mounted +ssh nuc "docker exec ecija-intranet-web ls -la /secrets/bucket/" + +# Run collectstatic manually +ssh nuc "docker exec ecija-intranet-web python manage.py collectstatic --noinput" +``` + +### pg_restore fails with "role flexicar does not exist" + +The dump has `OWNER TO flexicar` statements. Using `--no-owner` should handle this, but if you see warnings about role `flexicar` not existing, you can safely ignore them (objects will be owned by `postgres`). Alternatively, create the role: + +```bash +ssh nuc "docker exec ecija-intranet-db psql -U postgres -d flexicar-prod -c 'CREATE ROLE flexicar WITH LOGIN;'" 2>/dev/null || true +``` + +### Frontend assets not built + +```bash +# Exec into container and build frontend +ssh nuc "docker exec -it ecija-intranet-web bash -c 'cd /app && npm install && npm run build'" +``` + +### Memory issues during restore + +The dump is 4 GB. If the NUC runs low on memory: + +```bash +# Use single-job restore (slower but less memory) +ssh nuc "docker exec ecija-intranet-db pg_restore \ + -U postgres \ + -d flexicar-prod \ + --no-owner \ + --no-privileges \ + --jobs=1 \ + /tmp/ecija-dump/" +``` + +### Container can't reach GCS (network issue) + +```bash +# Test internet connectivity from container +ssh nuc "docker exec ecija-intranet-web curl -s -o /dev/null -w '%{http_code}' https://storage.googleapis.com/" +# Expected: 200 or 400 + +# If blocked, check Docker DNS +ssh nuc "docker exec ecija-intranet-web cat /etc/resolv.conf" +``` + +--- + +## Notes + +- The `-e git+https://github.com/Seykotron/django-cruds-adminlte.git` dependency in requirements.txt requires git to be installed in the Docker image (included in the Dockerfile.nuc) +- The `jinjacompiler` npm package in `app/package.json` uses a GitHub PAT token in its URL - this may expire and need updating +- Azure AD auth (`AG=False`) is disabled - the app falls back to Django's built-in auth with username/password +- `GS_CREDENTIALS=/secrets/bucket/credentials.json` (generic name) and `GS_CREDENTIALS_LOCAL` (full filename) both point to the mounted volume at `/secrets/bucket/`. Both files exist because we copy the JSON as both names in Phase 2.2 +- Background worker (`process_tasks`) should always be running - it handles async operations like email sending, report generation, etc. +- The database dump was created from PostgreSQL 17.7 - the container uses PostgreSQL 17 for compatibility +- `DEBUG=True` matches the official install doc. Switch to `False` once everything is confirmed working. +- The NUC's existing Redis (port 6379) can be used for Django caching if needed - add `REDIS_URL` to the env file and ensure the containers share a Docker network or use the host IP +- The database name difference: the dump creates DB `flexicar` but Django expects `flexicar-prod` (per install doc). We restore into `flexicar-prod` using `--no-owner` which handles this correctly. +- `DATABASE_USER=postgres` and `DATABASE_PASSWORD=postgres` match the official install doc exactly. These are development credentials. diff --git a/docs/gitea-coolify-auto-deploy.md b/docs/gitea-coolify-auto-deploy.md new file mode 100644 index 0000000..9ba2d9a --- /dev/null +++ b/docs/gitea-coolify-auto-deploy.md @@ -0,0 +1,250 @@ +# Gitea-Coolify Auto-Deploy Guide + +Automatic deployment on git push using Coolify's manual webhook integration with self-hosted Gitea. + +## Architecture + +``` +Developer → git push → Gitea → Webhook → Coolify → Build & Deploy +``` + +## Prerequisites + +### 1. Deploy Key for Git Access + +Apps use SSH deploy keys to pull code from Gitea: + +| Resource | Value | +|----------|-------| +| **Deploy Key UUID** | `akssgwowsccgwgoggs4ks8ck` | +| **Gitea Container** | `gitea-ho0cwgcwos88cwc48g84c0g8` | +| **SSH Port** | 22222 (external) → 22 (internal) | + +### 2. Network Connectivity + +Gitea container must be on the `coolify` network: + +```bash +docker network connect coolify gitea-ho0cwgcwos88cwc48g84c0g8 +``` + +### 3. ⚠️ CRITICAL: Gitea Webhook Allowed Hosts + +**Gitea blocks webhooks to internal hosts by default.** You MUST configure `ALLOWED_HOST_LIST` in Gitea's app.ini. + +```bash +# Add [webhook] section to Gitea's app.ini +ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 sh -c 'echo \"\" >> /data/gitea/conf/app.ini && echo \"[webhook]\" >> /data/gitea/conf/app.ini && echo \"ALLOWED_HOST_LIST = coolify,10.0.1.5,192.168.1.3,localhost,host.docker.internal,external\" >> /data/gitea/conf/app.ini'" + +# Restart Gitea +ssh nuc "docker restart gitea-ho0cwgcwos88cwc48g84c0g8" + +# Verify +ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 cat /data/gitea/conf/app.ini | grep -A2 '\[webhook\]'" +``` + +**Without this, webhooks will fail with:** +``` +dial tcp 10.0.1.5:8080: webhook can only call allowed HTTP servers +(check your webhook.ALLOWED_HOST_LIST setting), deny 'coolify(10.0.1.5:8080)' +``` + +### 4. ⚠️ CRITICAL: Use Internal Port 8080 + +**Coolify listens on port 8080 internally**, not 8000. Port 8000 is only the external Docker port mapping. + +| Context | Port | URL Example | +|---------|------|-------------| +| From Docker network (Gitea webhook) | **8080** | `http://coolify:8080/webhooks/...` | +| From external/browser | 8000 | `http://192.168.1.3:8000` | + +## Creating an App with Auto-Deploy + +### Step 1: Create Application with Deploy Key + +```python +mcp__coolify__application( + action="create_key", + name="my-app", + project_uuid="a8484ggc88c40w4g4k004ow0", + environment_name="production", + server_uuid="qk84w0goo4w48g4ggsoo0oss", + git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:nuc/.git", + git_branch="main", + build_pack="nixpacks", + ports_exposes="3000", + private_key_uuid="akssgwowsccgwgoggs4ks8ck" +) +``` + +### Step 2: Configure FQDN + +```bash +ssh nuc "docker exec coolify php artisan tinker --execute=\" +use App\Models\Application; +\\\$app = Application::where('uuid', '')->first(); +\\\$app->fqdn = 'http://.nuc.lan'; +\\\$app->custom_labels = null; +\\\$app->base_directory = '/'; +\\\$app->save(); +\"" +``` + +### Step 3: Generate and Set Webhook Secret + +```bash +# Generate a secret +SECRET=$(openssl rand -hex 32) +echo "Webhook Secret: $SECRET" + +# Set in Coolify +ssh nuc "docker exec coolify php artisan tinker --execute=\" +use App\Models\Application; +\\\$app = Application::where('uuid', '')->first(); +\\\$app->manual_webhook_secret_gitea = '$SECRET'; +\\\$app->save(); +echo 'Set webhook secret for ' . \\\$app->name; +\"" +``` + +### Step 4: Create Webhook in Gitea + +1. Go to `http://192.168.1.3:3030/nuc//settings/hooks` +2. Click **Add Webhook** → **Gitea** +3. Configure: + - **Target URL:** `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=` + - **Secret:** The secret generated in Step 3 + - **Trigger On:** Push Events + - **Active:** ✓ +4. Click **Add Webhook** + +**⚠️ IMPORTANT:** The webhook URL MUST include `?uuid=` - without it, Coolify won't know which app to deploy! + +### Step 5: Initial Deploy + +```python +mcp__coolify__deploy(tag_or_uuid="") +``` + +### Step 6: Test Webhook + +In Gitea webhook settings, click **Test Delivery**. Check: +- Response should be `200 OK` +- Coolify should show a new deployment queued + +## Webhook URL Format + +**Correct format (use port 8080 for internal Docker network):** +``` +http://coolify:8080/webhooks/source/gitea/events/manual?uuid= +``` + +| App | UUID | Webhook URL | +|-----|------|-------------| +| nuc-portal | `t80w0cw0oooc4g0soswos4so` | `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=t80w0cw0oooc4g0soswos4so` | +| whyrating-hub | `vw4ggc40socwkgwg4osc8wg8` | `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=vw4ggc40socwkgwg4osc8wg8` | +| whyrating-brand | `r80gk0ccgg0okos8cw848kkk` | `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=r80gk0ccgg0okos8cw848kkk` | +| whyrating-templates | `qw80g4sog0kk8cc4wkcs8sgc` | `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=qw80g4sog0kk8cc4wkcs8sgc` | + +## Troubleshooting + +### Webhook Returns "dial tcp ... webhook can only call allowed HTTP servers" + +**Cause:** Gitea's webhook security blocks internal hosts by default. + +**Fix:** Add Coolify to Gitea's allowed host list: + +```bash +# Check current app.ini +ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 cat /data/gitea/conf/app.ini | grep -A5 '\[webhook\]'" + +# Edit app.ini to add: +[webhook] +ALLOWED_HOST_LIST = coolify,10.0.1.5,192.168.1.3,localhost,host.docker.internal,external + +# Or allow all private IPs (less secure): +ALLOWED_HOST_LIST = private + +# Restart Gitea +ssh nuc "docker restart gitea-ho0cwgcwos88cwc48g84c0g8" +``` + +### Webhook Returns 404 or No Deployment + +**Cause:** Missing `?uuid=` parameter in webhook URL. + +**Fix:** Ensure URL includes the app UUID: +``` +http://coolify:8080/webhooks/source/gitea/events/manual?uuid= +``` + +### Webhook Returns "Connection Refused" (dial tcp ... connection refused) + +**Cause:** Using external port 8000 instead of internal port 8080. + +**Fix:** Coolify's nginx listens on port **8080** inside the container, not 8000. Change: +``` +# Wrong (external port) +http://coolify:8000/webhooks/... + +# Correct (internal port) +http://coolify:8080/webhooks/... +``` + +### Webhook Returns 401 Unauthorized + +**Cause:** Webhook secret mismatch. + +**Fix:** Verify the secret matches in both Gitea and Coolify: +```bash +# Check Coolify +ssh nuc "docker exec coolify php artisan tinker --execute=\" +use App\Models\Application; +\\\$app = Application::where('uuid', '')->first(); +echo 'Secret: ' . \\\$app->manual_webhook_secret_gitea; +\"" +``` + +### Webhook Delivers but Deployment Fails + +Check Coolify logs: +```bash +ssh nuc "docker logs coolify 2>&1 | grep -i 'deploy\|webhook' | tail -30" +``` + +Common issues: +- Git pull fails: Check deploy key is added to repo +- Build fails: Check application logs in Coolify UI + +## Current Configuration + +### Shared Webhook Secret + +All apps use the same webhook secret for simplicity: +``` +9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718 +``` + +### Deploy Key (add to each new repo) + +``` +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGHtsL3jicJTsBekYuwbKjO0EcRadYKhvLSUw/36XF7h coolify-gitea +``` + +Add via Gitea: Repository → Settings → Deploy Keys → **Enable Write Access** ✓ + +## Why Not "Gitea Source"? + +Coolify has a "Gitea Source" feature that attempts to use GitHub App-style OAuth. This **does not work well** with self-hosted Gitea because: + +1. Gitea's OAuth2 is simpler than GitHub Apps (no JWT signing with private keys) +2. The credentials stored in Coolify are invalid/fake +3. Deployments fail with JWT parsing errors + +**Use deploy keys + manual webhooks instead** - it's simpler and more reliable. + +## References + +- [Coolify CI/CD Gitea Integration](https://coolify.io/docs/applications/ci-cd/gitea/integration) +- [Gitea Webhooks Documentation](https://docs.gitea.com/usage/webhooks) +- [Gitea app.ini Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#webhook-webhook) diff --git a/docs/gitea-coolify-integration.md b/docs/gitea-coolify-integration.md new file mode 100644 index 0000000..eb774f8 --- /dev/null +++ b/docs/gitea-coolify-integration.md @@ -0,0 +1,314 @@ +# Gitea-Coolify Integration for Git Auto-Deploy + +Deploy Next.js applications from self-hosted Gitea with automatic deployments via Coolify. + +## Overview + +This guide covers deploying applications from Gitea (self-hosted Git) to Coolify with: +- SSH key authentication +- Automatic builds via Nixpacks +- Traefik routing with custom domains +- Optional webhooks for auto-deploy on push + +## Architecture + +``` +Gitea (git@gitea-...:user/repo.git) + ↓ SSH clone via deploy key +Coolify Helper Container + ↓ Nixpacks build +Docker Image + ↓ Deploy to coolify network +Running Container ← Traefik (*.nuc.lan routing) +``` + +## Prerequisites + +- Gitea running as Coolify service +- Gitea container connected to `coolify` network +- SSH deploy key configured in both Coolify and Gitea + +## Key References + +| Resource | UUID/Value | +|----------|------------| +| **Server UUID** | `qk84w0goo4w48g4ggsoo0oss` | +| **Project UUID** | `a8484ggc88c40w4g4k004ow0` | +| **Environment** | `production` | +| **Deploy Key UUID** | `akssgwowsccgwgoggs4ks8ck` | +| **Gitea Container** | `gitea-ho0cwgcwos88cwc48g84c0g8` | +| **Gitea Service UUID** | `ho0cwgcwos88cwc48g84c0g8` | + +### Gitea Ports + +| Type | External | Internal | +|------|----------|----------| +| HTTP | 3030 | 3000 | +| SSH | 22222 | 22 | + +## Network Configuration + +### Critical: Connect Gitea to Coolify Network + +Gitea runs on its own Docker network. The Coolify helper container clones repositories from the `coolify` network and cannot reach Gitea unless connected: + +```bash +docker network connect coolify gitea-ho0cwgcwos88cwc48g84c0g8 +``` + +Verify connection: +```bash +docker network inspect coolify | grep gitea +``` + +### Repository URL Format + +**Correct (use container name):** +``` +git@gitea-ho0cwgcwos88cwc48g84c0g8:alezmad/repo-name.git +``` + +**Incorrect (will fail):** +``` +git@192.168.1.3:alezmad/repo.git # Port 22 goes to NUC SSH, not Gitea +ssh://git@192.168.1.3:22222/user/repo.git # Coolify mangles ssh:// URLs +``` + +## Deploy Key Setup + +### 1. Generate SSH Key (if needed) + +```bash +ssh-keygen -t ed25519 -C "coolify-gitea" -f /tmp/coolify-gitea-key -N "" +``` + +### 2. Current Deploy Key + +**Public Key (add to Gitea repos):** +``` +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGHtsL3jicJTsBekYuwbKjO0EcRadYKhvLSUw/36XF7h coolify-gitea +``` + +**Coolify Private Key UUID:** `akssgwowsccgwgoggs4ks8ck` + +### 3. Add Deploy Key to Gitea Repository + +1. Navigate to: `http://192.168.1.3:3030///settings/keys` +2. Click "Add Deploy Key" +3. Title: `Coolify Deploy Key` +4. Content: Paste the public key +5. **Enable Write Access** ✓ (required for pushing fixes) +6. Click "Add Deploy Key" + +Or automate via Playwriter: +```javascript +await page.goto('http://192.168.1.3:3030/alezmad//settings/keys'); +await page.locator('button:has-text("Add Deploy Key")').click(); +await page.locator('input[name="title"]').fill('Coolify Deploy Key'); +await page.locator('textarea[name="content"]').fill(''); +await page.locator('input[name="is_writable"]').check(); +await page.locator('#add-deploy-key-panel button.ui.primary.button').click(); +``` + +## Deploying a New Application + +### Step 1: Create Application in Coolify + +```python +result = mcp__coolify__application( + action="create_key", + name="my-app-name", + project_uuid="a8484ggc88c40w4g4k004ow0", + environment_name="production", + server_uuid="qk84w0goo4w48g4ggsoo0oss", + git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:alezmad/repo-name.git", + git_branch="main", + build_pack="nixpacks", + ports_exposes="3000", + private_key_uuid="akssgwowsccgwgoggs4ks8ck" +) +app_uuid = result['uuid'] +``` + +### Step 2: Configure FQDN and Base Directory + +The API doesn't allow setting FQDN directly. Use Laravel tinker: + +```bash +docker exec coolify php artisan tinker --execute=" +use App\Models\Application; +\$app = Application::where('uuid', '')->first(); +\$app->fqdn = 'http://myapp.nuc.lan'; +\$app->custom_labels = null; # Forces label regeneration +\$app->base_directory = '/'; # Or '/subdir' for monorepos +\$app->save(); +echo 'FQDN: ' . \$app->fqdn; +" +``` + +### Step 3: Deploy + +```python +mcp__coolify__deploy(tag_or_uuid="") +``` + +### Step 4: Monitor Deployment + +```python +# Check deployment status +mcp__coolify__list_deployments(per_page=5) + +# Get detailed logs +mcp__coolify__deployment(action="get", uuid="", lines=50) +``` + +## Troubleshooting + +### "Permission denied (publickey)" + +**Cause:** Deploy key not authorized for the repository. + +**Fix:** +1. Verify key is added to Gitea repository settings +2. Ensure "Enable Write Access" is checked +3. Verify Gitea is connected to coolify network + +### "Could not resolve hostname" + +**Cause:** Gitea container not on coolify network. + +**Fix:** +```bash +docker network connect coolify gitea-ho0cwgcwos88cwc48g84c0g8 +``` + +### "Nixpacks failed to detect application type" + +**Cause:** Wrong `base_directory` setting. + +**Fix:** Update via tinker: +```bash +docker exec coolify php artisan tinker --execute=" +use App\Models\Application; +\$app = Application::where('uuid', '')->first(); +\$app->base_directory = '/'; # Adjust as needed +\$app->save(); +" +``` + +### TypeScript Build Errors + +**Common issue:** Missing function arguments (e.g., `Expected 6 arguments, but got 5`) + +**Fix:** +1. Clone repo locally or on NUC +2. Fix the code +3. Commit and push to Gitea +4. Redeploy + +Example fix workflow: +```bash +# On NUC +cd /tmp && git clone http://192.168.1.3:3030/alezmad/repo.git repo-fix +cd repo-fix +# Make fixes... +git add -A && git commit -m "Fix: description" + +# Push using deploy key +cat > /tmp/gitea_key << 'EOF' + +EOF +chmod 600 /tmp/gitea_key +git remote set-url origin ssh://git@localhost:22222/alezmad/repo.git +GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -i /tmp/gitea_key" git push origin main +``` + +### Traefik Labels Not Updated + +**Cause:** FQDN changed but container labels still have old domain. + +**Fix:** Clear custom_labels and redeploy: +```bash +docker exec coolify php artisan tinker --execute=" +use App\Models\Application; +\$app = Application::where('uuid', '')->first(); +\$app->custom_labels = null; +\$app->save(); +" +``` + +Then force redeploy: +```python +mcp__coolify__deploy(tag_or_uuid="", force=True) +``` + +### 404 After Deployment + +**Cause:** Traefik not routing to the new domain. + +**Verify labels:** +```bash +container=$(docker ps --format '{{.Names}}' | grep | head -1) +docker inspect $container --format '{{json .Config.Labels}}' | jq -r 'to_entries[] | select(.key | startswith("traefik")) | "\(.key)=\(.value)"' | grep rule +``` + +Should show: `Host(\`myapp.nuc.lan\`)` + +## Webhooks (Auto-Deploy on Push) + +### Setup Gitea Webhook + +1. Get webhook URL from Coolify application settings +2. In Gitea: Repository → Settings → Webhooks → Add Webhook +3. Payload URL: Coolify webhook URL +4. Content type: `application/json` +5. Secret: From Coolify +6. Events: Push events + +### Via MCP + +```python +# Get application details (includes webhook info) +app = mcp__coolify__get_application(uuid="") +# Webhook URL is in manual_webhook_secret_gitea field +``` + +## Current Deployed Applications + +| Application | UUID | FQDN | Repository | +|-------------|------|------|------------| +| whyrating-brand | `r80gk0ccgg0okos8cw848kkk` | http://brand.nuc.lan | `alezmad/whyrating-brand` | +| whyrating-templates | `qw80g4sog0kk8cc4wkcs8sgc` | http://templates.nuc.lan | `alezmad/whyrating-templates` | + +## Quick Reference Commands + +### Check Application Status +```python +mcp__coolify__list_applications() +``` + +### View Logs +```python +mcp__coolify__application_logs(uuid="", lines=50) +``` + +### Restart Application +```python +mcp__coolify__control(resource="application", action="restart", uuid="") +``` + +### Force Redeploy +```python +mcp__coolify__deploy(tag_or_uuid="", force=True) +``` + +### Check Container Status +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}' | grep +``` + +## Related Documentation + +- `.artifacts/2026-02-01_21-06_gitea-coolify-integration.md` - Original setup notes +- `CLAUDE.md` - Quick reference section +- Coolify docs: https://coolify.io/docs diff --git a/docs/monitoring-presentation.html b/docs/monitoring-presentation.html new file mode 100644 index 0000000..b49754a --- /dev/null +++ b/docs/monitoring-presentation.html @@ -0,0 +1,665 @@ + + + + + + NUC Monitoring & Recovery System + + + + + + + +
+ + + + diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 0000000..0d6cfd7 --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,156 @@ +# NUC Monitoring & Recovery Setup + +**Date:** 2026-02-02 22:20 +**Context:** Complete monitoring stack deployment with auto-recovery and remote access + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MONITORING STACK │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ scrape ┌─────────────┐ │ +│ │ OpenWrt │◄─────────────│ Prometheus │ │ +│ │ :9100 │ │ :9091 │ │ +│ └─────────────┘ └──────┬──────┘ │ +│ │ │ +│ ┌─────────────┐ scrape │ ┌─────────────┐ │ +│ │ NUC Node │◄───────────────────┤ │ Alertmanager│ │ +│ │ Exporter │ │ │ :9093 │ │ +│ │ :9100 │ ┌─────┴────┐ └──────┬──────┘ │ +│ └─────────────┘ │ Grafana │ │ │ +│ │ :3333 │ ▼ │ +│ └──────────┘ ┌───────────┐ │ +│ │ntfy Bridge│ │ +│ │ :9095 │ │ +│ └─────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ntfy.sh/nuc-watchdog │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Access URLs + +| Service | Local URL | Remote URL | Credentials | +|---------|-----------|------------|-------------| +| **Grafana** | http://192.168.1.3:3333 | https://alezmad-nuc.tail58f5ad.ts.net | admin / nucmonitoring | +| **Prometheus** | http://192.168.1.3:9091 | - | - | +| **Alertmanager** | http://192.168.1.3:9093 | - | - | +| **OpenWrt Metrics** | http://192.168.1.1:9100 | - | - | + +## Prometheus Targets + +| Job | Target | Scrape Interval | +|-----|--------|-----------------| +| `prometheus` | localhost:9090 | 15s | +| `nuc-node` | 192.168.1.3:9100 | 15s | +| `openwrt` | 192.168.1.1:9100 | 30s | + +## Alert Rules + +### NUC Alerts (`/opt/monitoring/alert_rules.yml`) + +| Alert | Condition | Severity | +|-------|-----------|----------| +| NUCDown | `up{job="nuc-node"} == 0` for 1m | critical | +| HighCPULoad | CPU > 80% for 5m | warning | +| HighMemoryUsage | Memory > 85% for 5m | warning | +| DiskSpaceLow | Disk > 85% for 5m | warning | + +### OpenWrt Alerts + +| Alert | Condition | Severity | +|-------|-----------|----------| +| OpenWrtDown | `up{job="openwrt"} == 0` for 1m | critical | +| OpenWrtHighLoad | Load > 2 for 5m | warning | + +## Auto-Recovery Layers + +| Layer | Component | Action | Trigger | +|-------|-----------|--------|---------| +| 1 | OpenWrt Monitor | WoL packet | HTTP+Ping fail (3x) | +| 2 | Hardware Watchdog | Auto-reboot | System freeze | +| 3 | Kernel Panic | Auto-reboot (10s) | Kernel panic | +| 4 | WoL | Wake from power off | Manual or script | + +## Configuration Files + +### NUC (`/opt/monitoring/`) +- `prometheus.yml` - Prometheus configuration +- `alert_rules.yml` - Alert rules +- `alertmanager.yml` - Alertmanager → ntfy bridge +- `alertmanager-ntfy-bridge.py` - Webhook translator + +### OpenWrt (`/opt/`) +- `monitor-nuc.sh` - Health check daemon (0.5Hz) + +### Systemd Services +- `prometheus-node-exporter.service` - NUC metrics +- `alertmanager-ntfy-bridge.service` - Alert translator + +## Notifications + +**ntfy Topic:** `nuc-watchdog` + +Subscribe via: +- App: ntfy (iOS/Android) +- Web: https://ntfy.sh/nuc-watchdog + +**Alert Sources:** +1. OpenWrt watchdog (direct to ntfy.sh) +2. Alertmanager → bridge → ntfy.sh + +## Remote Access + +### Tailscale (NUC) +- Funnel URL: https://alezmad-nuc.tail58f5ad.ts.net +- Exposes: Grafana (:3333) + +### WireGuard (OpenWrt) +- Endpoint: 5.224.196.245:51820 +- VPN Subnet: 10.10.10.0/24 +- Config: `~/wireguard/home-vpn.conf` + +## Grafana Setup + +**Data Source:** Prometheus (http://prometheus:9090) + +**Imported Dashboards:** +- Node Exporter Full (ID: 1860) + +## Maintenance Commands + +```bash +# Check Prometheus targets +curl -s http://192.168.1.3:9091/api/v1/targets | jq '.data.activeTargets[].health' + +# Check active alerts +curl -s http://192.168.1.3:9093/api/v2/alerts | jq '.[].labels.alertname' + +# Restart monitoring stack +ssh nuc "docker restart prometheus-r0wg4gwoow44kkkc8skc4kwg alertmanager-r0wg4gwoow44kkkc8skc4kwg grafana-r0wg4gwoow44kkkc8skc4kwg" + +# Check OpenWrt monitor +ssh root@192.168.1.1 "ps | grep monitor" + +# Test alert +curl -X POST http://192.168.1.3:9093/api/v2/alerts -H "Content-Type: application/json" \ + -d '[{"labels":{"alertname":"TestAlert","severity":"warning"},"annotations":{"summary":"Test"}}]' + +# Manual ntfy test +curl -d "Test message" https://ntfy.sh/nuc-watchdog +``` + +## Container UUIDs (Coolify) + +| Service | UUID | +|---------|------| +| Monitoring Stack | r0wg4gwoow44kkkc8skc4kwg | + +## Related + +- OpenWrt NUC Monitor: `/opt/monitor-nuc.sh` +- Kernel panic config: `/etc/sysctl.d/99-auto-reboot.conf` +- WireGuard config: `~/wireguard/home-vpn.conf` diff --git a/docs/remote-access.md b/docs/remote-access.md new file mode 100644 index 0000000..0afc562 --- /dev/null +++ b/docs/remote-access.md @@ -0,0 +1,252 @@ +# Remote Access Guide + +Two methods for accessing home network remotely: **Tailscale** (recommended) and **WireGuard** (backup). + +## Quick Reference + +| Method | Use Case | Connection | +|--------|----------|------------| +| **Tailscale** | Daily use, zero config | Automatic via mesh | +| **WireGuard** | Backup, full LAN | `~/wireguard/home-vpn.conf` | + +## Tailscale (Recommended) + +### Why Tailscale +- Zero configuration after setup +- Works through any NAT/firewall +- Auto-reconnects on network changes +- No ports exposed on router + +### Setup (Already Configured) + +**NUC as Subnet Router:** +```bash +# On NUC - advertise home LAN +sudo tailscale up --advertise-routes=192.168.1.0/24 --accept-routes +``` + +**Mac - Accept Routes:** +```bash +/Applications/Tailscale.app/Contents/MacOS/Tailscale up --accept-routes +``` + +### Usage + +Once connected to Tailscale, access home LAN directly: + +```bash +# SSH to NUC +ssh 192.168.1.3 + +# Access router admin +open http://192.168.1.1 + +# Access any LAN device +ping 192.168.1.x +``` + +### Status & Troubleshooting + +```bash +# Check status +/Applications/Tailscale.app/Contents/MacOS/Tailscale status + +# Restart connection +/Applications/Tailscale.app/Contents/MacOS/Tailscale down +/Applications/Tailscale.app/Contents/MacOS/Tailscale up --accept-routes + +# If logged out +/Applications/Tailscale.app/Contents/MacOS/Tailscale up +# Click auth link +``` + +### Tailscale Devices + +| Device | Tailscale IP | Purpose | +|--------|--------------|---------| +| alejandros-macbook-pro | 100.97.192.56 | This Mac | +| alezmad-nuc | 100.113.153.45 | NUC (subnet router) | +| nuc-tailscale | 100.110.198.76 | NUC Funnel endpoint | + +--- + +## WireGuard (Backup) + +### Why WireGuard Backup +- Works if Tailscale is down +- Direct connection (no relay) +- Full LAN access via OpenWrt + +### Architecture + +``` +Mac (10.10.10.2) + ↓ WireGuard tunnel +alezmad.duckdns.org:51820 (dynamic DNS) + ↓ +OpenWrt Router (10.10.10.1 / 192.168.1.1) + ↓ +Home LAN (192.168.1.0/24) +``` + +### Server (OpenWrt Router) + +| Property | Value | +|----------|-------| +| Interface | wg0 | +| Listen Port | 51820 | +| Server IP | 10.10.10.1/24 | +| Public Key | `LWajYq1vGnhnn5vC465nsXFWcbgflDxEHXDtUgTcwQs=` | + +### Client Config (Mac) + +**File:** `~/wireguard/home-vpn.conf` + +```ini +[Interface] +PrivateKey = aFklbF6A5dIWmV6gN0NI9A3pv/RmioEsBLWaaXupIns= +Address = 10.10.10.2/24 +DNS = 192.168.1.1 + +[Peer] +PublicKey = LWajYq1vGnhnn5vC465nsXFWcbgflDxEHXDtUgTcwQs= +Endpoint = alezmad.duckdns.org:51820 +AllowedIPs = 192.168.1.0/24, 10.10.10.0/24 +PersistentKeepalive = 25 +``` + +### Usage + +**WireGuard App (GUI):** +1. Open WireGuard app +2. Import `~/wireguard/home-vpn.conf` (already imported) +3. Toggle "home-vpn" to connect + +**CLI:** +```bash +# Connect +sudo wg-quick up ~/wireguard/home-vpn.conf + +# Disconnect +sudo wg-quick down ~/wireguard/home-vpn.conf + +# Status +sudo wg show +``` + +--- + +## DuckDNS (Dynamic IP) + +### Why DuckDNS +- ISP can change public IP anytime +- DuckDNS tracks current IP +- WireGuard uses hostname instead of IP + +### Configuration + +| Property | Value | +|----------|-------| +| Subdomain | alezmad.duckdns.org | +| Token | `8dd8e041-2fa3-4b3d-9317-f62b912214da` | +| Update Source | OpenWrt router | +| Check Interval | 10 minutes | + +### OpenWrt DDNS Service + +```bash +# Check status +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "cat /var/run/ddns/duckdns.*" + +# Manual update +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/ddns restart" + +# View config +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show ddns" +``` + +### Verify DNS Resolution + +```bash +dig +short alezmad.duckdns.org +# Should return current public IP +``` + +--- + +## Comparison + +| Feature | Tailscale | WireGuard | +|---------|-----------|-----------| +| Setup complexity | Minimal | Moderate | +| Port forwarding needed | No | Yes (51820) | +| NAT traversal | Automatic | Manual | +| Dynamic IP handling | Automatic | Via DuckDNS | +| Speed | Good (may relay) | Excellent (direct) | +| Dependencies | Tailscale service | OpenWrt only | + +--- + +## Troubleshooting + +### Tailscale Won't Connect +```bash +# Check if running +ps aux | grep -i tailscale + +# Restart app +killall Tailscale +open -a Tailscale + +# Re-authenticate +/Applications/Tailscale.app/Contents/MacOS/Tailscale up +``` + +### WireGuard Won't Connect + +1. **Check DuckDNS resolves:** + ```bash + dig +short alezmad.duckdns.org + ``` + +2. **Check port 51820 is open:** + ```bash + nc -zv alezmad.duckdns.org 51820 + ``` + +3. **Check WireGuard on router:** + ```bash + ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "wg show" + ``` + +4. **IP changed but DuckDNS stale:** + ```bash + ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/ddns restart" + ``` + +### Can't Access LAN via Tailscale + +1. **Check routes accepted on Mac:** + ```bash + /Applications/Tailscale.app/Contents/MacOS/Tailscale status + # Should show alezmad-nuc as "active" + ``` + +2. **Re-enable route acceptance:** + ```bash + /Applications/Tailscale.app/Contents/MacOS/Tailscale up --accept-routes + ``` + +3. **Check subnet router is advertising:** + ```bash + ssh nuc "tailscale status" + ``` + +--- + +## Security Notes + +- **Tailscale:** Traffic encrypted end-to-end, keys managed by Tailscale +- **WireGuard:** Traffic encrypted, keys stored locally +- **DuckDNS:** Only exposes that a hostname points to your IP (no credentials) +- **Port 51820:** Only WireGuard handshakes accepted, cryptographically verified diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..943e24a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,329 @@ +# Security Configuration + +Comprehensive security hardening for NUC server and OpenWrt router. + +## Security Architecture + +``` +Internet + │ + ▼ +┌─────────────────────────────────────┐ +│ OpenWrt Router (192.168.1.1) │ +│ ├─ CrowdSec Bouncer (threat intel) │ +│ ├─ Firewall (WAN: REJECT default) │ +│ └─ SSH: key-only authentication │ +└─────────────────────────────────────┘ + │ + │ Only allowed: WireGuard (51820/udp) + │ + ▼ +┌─────────────────────────────────────┐ +│ NUC Server (192.168.1.3) │ +│ ├─ CrowdSec (threat intelligence) │ +│ ├─ fail2ban (brute force protect) │ +│ ├─ SSH: key-only + 24h ban │ +│ ├─ Unattended upgrades (auto) │ +│ └─ Tailscale Funnel (HTTPS only) │ +└─────────────────────────────────────┘ +``` + +## External Attack Surface + +| Port | Service | Protection | +|------|---------|------------| +| 51820/udp | WireGuard VPN | Cryptographic auth only | +| Tailscale Funnel | HTTPS services | Tailscale auth + TLS | +| Everything else | Blocked | Router firewall DROP | + +**Not exposed to internet:** +- SSH (22) +- Router admin (80/443) +- Coolify (8000) +- All Docker services + +--- + +## Router Security (OpenWrt) + +### SSH Configuration + +| Setting | Value | +|---------|-------| +| Password auth | Disabled | +| Root password auth | Disabled | +| Auth method | SSH key only | +| Port | 22 (LAN only) | + +**Config:** `/etc/config/dropbear` + +```bash +# Verify settings +uci get dropbear.@dropbear[0].PasswordAuth # off +uci get dropbear.@dropbear[0].RootPasswordAuth # off +``` + +### Firewall (fw4/nftables) + +**WAN Zone Policy:** +- Input: REJECT +- Forward: REJECT +- Output: ACCEPT + +**Allowed WAN Input:** +- DHCP (port 68) +- ICMPv6 (limited) +- WireGuard (51820/udp) + +```bash +# View WAN input rules +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "nft list chain inet fw4 input_wan" +``` + +### CrowdSec Firewall Bouncer + +Blocks malicious IPs using threat intelligence from NUC CrowdSec. + +| Setting | Value | +|---------|-------| +| API URL | http://192.168.1.3:8083/ | +| Update frequency | 10s | +| Action | DROP + log | +| Interfaces | wan, wan6 | + +**Config:** `/etc/config/crowdsec` + +```bash +# Check bouncer status +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/crowdsec-firewall-bouncer status" + +# View blocked IPs +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "nft list set ip crowdsec crowdsec_blocklist" + +# Check logs +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "logread | grep crowdsec" +``` + +--- + +## NUC Security + +### SSH Configuration + +| Setting | Value | +|---------|-------| +| Password auth | Disabled | +| Auth method | SSH key only | +| Port | 22 | + +**Config:** `/etc/ssh/sshd_config` + +```bash +# Verify +grep "PasswordAuthentication" /etc/ssh/sshd_config +# PasswordAuthentication no +``` + +### fail2ban + +Protects SSH from brute force attacks. + +| Setting | Value | +|---------|-------| +| Max retries | 3 | +| Ban time | 24 hours | +| Find time | 10 minutes | +| Ignored IPs | LAN (192.168.1.0/24), Tailscale (100.0.0.0/8) | + +**Config:** `/etc/fail2ban/jail.local` + +```bash +# Check status +sudo fail2ban-client status sshd + +# View banned IPs +sudo fail2ban-client status sshd | grep "Banned IP" + +# Unban an IP +sudo fail2ban-client set sshd unbanip +``` + +### CrowdSec (Docker) + +Central threat intelligence hub. + +| Property | Value | +|----------|-------| +| Container | crowdsec-mwc4ocock400goww8s4k44o8 | +| API Port | 8083 | +| Dashboard | http://192.168.1.3:8083 | + +```bash +# List registered bouncers +docker exec crowdsec-mwc4ocock400goww8s4k44o8 cscli bouncers list + +# View decisions (blocked IPs) +docker exec crowdsec-mwc4ocock400goww8s4k44o8 cscli decisions list + +# View alerts +docker exec crowdsec-mwc4ocock400goww8s4k44o8 cscli alerts list +``` + +### Unattended Upgrades + +Automatic security and system updates. + +| Setting | Value | +|---------|-------| +| Security updates | Enabled | +| Regular updates | Enabled | +| Auto-reboot | 4:00 AM if needed | +| Cleanup unused | Enabled | + +**Config:** `/etc/apt/apt.conf.d/50unattended-upgrades-local` + +```bash +# Check status +sudo systemctl status unattended-upgrades + +# View logs +cat /var/log/unattended-upgrades/unattended-upgrades.log + +# Manual dry-run +sudo unattended-upgrade --dry-run --debug +``` + +--- + +## Access Methods + +### From LAN (Home Network) + +| Service | Access | +|---------|--------| +| NUC SSH | `ssh nuc` or `ssh 192.168.1.3` | +| Router SSH | `ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1` | +| Router Admin | http://192.168.1.1 | +| Coolify | http://192.168.1.3:8000 | + +### From Remote (Via Tailscale) + +| Service | Access | +|---------|--------| +| NUC SSH | `ssh nuc-tailscale` | +| Full LAN | Via subnet router (192.168.1.0/24) | +| Grafana | https://nuc-tailscale.tail58f5ad.ts.net:3002 | + +### From Remote (Via WireGuard) + +```bash +# Connect +sudo wg-quick up ~/wireguard/home-vpn.conf + +# Then access LAN normally +ssh 192.168.1.3 +``` + +--- + +## SSH Keys + +| Key | Location | Used For | +|-----|----------|----------| +| NUC/Router key | `~/.ssh/id_ed25519_nuc` | SSH to NUC and OpenWrt | + +```bash +# Test NUC key auth +ssh -i ~/.ssh/id_ed25519_nuc -o BatchMode=yes alezmad@192.168.1.3 "echo OK" + +# Test router key auth +ssh -i ~/.ssh/id_ed25519_nuc -o BatchMode=yes root@192.168.1.1 "echo OK" +``` + +--- + +## Security Checklist + +### Router +- [x] SSH password auth disabled +- [x] LuCI not exposed on WAN +- [x] WAN input policy: REJECT +- [x] CrowdSec bouncer active +- [x] Only WireGuard port open (51820) +- [x] UPnP disabled + +### NUC +- [x] SSH password auth disabled +- [x] fail2ban protecting SSH +- [x] CrowdSec running (Docker) +- [x] Unattended upgrades enabled +- [x] No services exposed to WAN directly + +### Network +- [x] Tailscale for remote access +- [x] WireGuard as backup VPN +- [x] DuckDNS for dynamic IP + +--- + +## Incident Response + +### If Brute Force Detected + +```bash +# Check fail2ban bans +sudo fail2ban-client status sshd + +# Check CrowdSec alerts +docker exec crowdsec-mwc4ocock400goww8s4k44o8 cscli alerts list + +# View auth logs +sudo tail -100 /var/log/auth.log | grep -i fail +``` + +### If Compromised IP Needs Blocking + +```bash +# Add manual ban in CrowdSec (blocks on router too) +docker exec crowdsec-mwc4ocock400goww8s4k44o8 cscli decisions add --ip --duration 24h --reason "manual block" + +# Or block directly on router +ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "nft add element ip crowdsec crowdsec_blocklist { }" +``` + +### If Locked Out + +1. **Physical access to NUC:** Connect monitor/keyboard +2. **Router:** Reset button (hold 10s) restores defaults +3. **Tailscale:** Still works if NUC is running + +--- + +## Monitoring + +| What | How | +|------|-----| +| SSH attempts | `sudo tail -f /var/log/auth.log` | +| fail2ban activity | `sudo fail2ban-client status sshd` | +| CrowdSec decisions | `docker exec crowdsec-... cscli decisions list` | +| Router firewall logs | `ssh root@192.168.1.1 "logread \| grep crowdsec"` | +| Blocked connections | `ssh root@192.168.1.1 "nft list set ip crowdsec crowdsec_blocklist"` | + +--- + +## Regular Maintenance + +### Weekly +- Check fail2ban status: `sudo fail2ban-client status` +- Review CrowdSec alerts: `docker exec crowdsec-... cscli alerts list` + +### Monthly +- Verify unattended-upgrades working: `cat /var/log/unattended-upgrades/*.log` +- Check for OpenWrt updates: `opkg update && opkg list-upgradable` +- Review SSH auth logs for anomalies + +### After Security Incident +1. Check all logs (auth, CrowdSec, fail2ban) +2. Rotate SSH keys if needed +3. Update CrowdSec scenarios +4. Review firewall rules diff --git a/docs/turbostarter-deployment.md b/docs/turbostarter-deployment.md new file mode 100644 index 0000000..c10a46f --- /dev/null +++ b/docs/turbostarter-deployment.md @@ -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.