Add operational documentation

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

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

View File

@@ -0,0 +1,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 <db-container> --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: <network-id-from-step-1>
```
Update via Coolify MCP:
```python
mcp__coolify__service(action="update", uuid="joo4g4k0w08k8kcosgsgswc0", docker_compose_raw="<updated yaml>")
```
### 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": "<container-name>",
"port": "5432",
"database": "<db-name>",
"url": "jdbc:postgresql://<container-name>:5432/<db-name>",
"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 <container-name>"
```
2. **Check network connectivity:**
```bash
ssh nuc "docker exec cloudbeaver-joo4g4k0w08k8kcosgsgswc0 ping -c1 <container-name>"
```
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/

View File

@@ -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 <missing-package>"
```
### 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.

View File

@@ -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/<repo>.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', '<app-uuid>')->first();
\\\$app->fqdn = 'http://<name>.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', '<app-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/<repo>/settings/hooks`
2. Click **Add Webhook****Gitea**
3. Configure:
- **Target URL:** `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=<app-uuid>`
- **Secret:** The secret generated in Step 3
- **Trigger On:** Push Events
- **Active:** ✓
4. Click **Add Webhook**
**⚠️ IMPORTANT:** The webhook URL MUST include `?uuid=<app-uuid>` - without it, Coolify won't know which app to deploy!
### Step 5: Initial Deploy
```python
mcp__coolify__deploy(tag_or_uuid="<app-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>
```
| 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=<app-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', '<app-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)

View File

@@ -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/<user>/<repo>/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/<repo>/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('<public-key>');
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', '<app-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="<app-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="<deployment-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', '<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'
<private-key-content>
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', '<uuid>')->first();
\$app->custom_labels = null;
\$app->save();
"
```
Then force redeploy:
```python
mcp__coolify__deploy(tag_or_uuid="<uuid>", force=True)
```
### 404 After Deployment
**Cause:** Traefik not routing to the new domain.
**Verify labels:**
```bash
container=$(docker ps --format '{{.Names}}' | grep <app-uuid-prefix> | 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="<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="<uuid>", lines=50)
```
### Restart Application
```python
mcp__coolify__control(resource="application", action="restart", uuid="<uuid>")
```
### Force Redeploy
```python
mcp__coolify__deploy(tag_or_uuid="<uuid>", force=True)
```
### Check Container Status
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep <uuid-prefix>
```
## 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

View File

@@ -0,0 +1,665 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NUC Monitoring & Recovery System</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--background: #0C0A09;
--foreground: #FAFAF9;
--surface-card: #1C1917;
--surface-muted: #292524;
--accent-cyan: #06B6D4;
--accent-green: #22C55E;
--accent-orange: #F97316;
--accent-red: #EF4444;
--accent-purple: #A855F7;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--background);
color: var(--foreground);
min-height: 100vh;
overflow: hidden;
}
.slide {
min-height: 100vh;
padding: 3rem;
display: flex;
flex-direction: column;
justify-content: center;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
h1 { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; }
h2 { font-size: 2rem; font-weight: 600; margin-bottom: 1.5rem; color: var(--accent-cyan); }
h3 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.75rem; }
.subtitle { font-size: 1.25rem; color: #A8A29E; margin-bottom: 2rem; }
.card {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--surface-muted);
}
.grid { display: grid; gap: 1rem; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
gap: 0.375rem;
}
.badge-green { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.badge-cyan { background: rgba(6, 182, 212, 0.2); color: var(--accent-cyan); }
.badge-orange { background: rgba(249, 115, 22, 0.2); color: var(--accent-orange); }
.badge-red { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
.badge-purple { background: rgba(168, 85, 247, 0.2); color: var(--accent-purple); }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-green { background: var(--accent-green); }
.dot-cyan { background: var(--accent-cyan); }
.dot-orange { background: var(--accent-orange); }
.dot-red { background: var(--accent-red); }
.icon { font-size: 2rem; margin-bottom: 0.5rem; }
.flow-diagram {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
padding: 2rem 0;
}
.flow-box {
background: var(--surface-card);
border: 2px solid var(--surface-muted);
border-radius: 8px;
padding: 1rem 1.5rem;
text-align: center;
min-width: 140px;
}
.flow-arrow {
color: var(--accent-cyan);
font-size: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-muted);
}
th { color: #A8A29E; font-weight: 500; font-size: 0.875rem; }
code {
background: var(--surface-muted);
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
}
.nav {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
background: var(--surface-card);
padding: 0.5rem;
border-radius: 9999px;
border: 1px solid var(--surface-muted);
z-index: 100;
}
.nav-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
transition: background 0.2s;
}
.nav-btn:hover { background: var(--surface-muted); }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-dots {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.5rem;
}
.nav-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--surface-muted);
cursor: pointer;
transition: all 0.2s;
}
.nav-dot.active { background: var(--accent-cyan); width: 24px; border-radius: 4px; }
.highlight { color: var(--accent-cyan); }
.text-muted { color: #A8A29E; }
.text-sm { font-size: 0.875rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.text-center { text-align: center; }
.logo {
font-size: 1rem;
font-weight: 600;
color: var(--accent-cyan);
position: fixed;
top: 2rem;
left: 2rem;
}
.slide-number {
position: fixed;
top: 2rem;
right: 2rem;
color: #A8A29E;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect } = React;
const slides = [
// Slide 1: Title
() => (
<div className="slide" style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🖥</div>
<h1>NUC Monitoring & Recovery</h1>
<p className="subtitle">Production-grade infrastructure monitoring for home lab</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
<span className="badge badge-cyan"><span className="dot dot-cyan"></span>Prometheus</span>
<span className="badge badge-green"><span className="dot dot-green"></span>Grafana</span>
<span className="badge badge-orange"><span className="dot dot-orange"></span>Alertmanager</span>
<span className="badge badge-purple"><span className="dot dot-purple"></span>Auto-Recovery</span>
</div>
</div>
),
// Slide 2: Architecture Overview
() => (
<div className="slide">
<h2>Architecture Overview</h2>
<div className="flow-diagram">
<div className="flow-box">
<div>📡</div>
<div>OpenWrt</div>
<div className="text-sm text-muted">:9100</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-cyan)' }}>
<div>📊</div>
<div>Prometheus</div>
<div className="text-sm text-muted">:9091</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box">
<div>🔔</div>
<div>Alertmanager</div>
<div className="text-sm text-muted">:9093</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-orange)' }}>
<div>📱</div>
<div>ntfy</div>
<div className="text-sm text-muted">push alerts</div>
</div>
</div>
<div className="flow-diagram">
<div className="flow-box">
<div>🖥</div>
<div>NUC Node</div>
<div className="text-sm text-muted">:9100</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-green)' }}>
<div>📈</div>
<div>Grafana</div>
<div className="text-sm text-muted">:3333</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box">
<div>🌐</div>
<div>Tailscale</div>
<div className="text-sm text-muted">Funnel</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-purple)' }}>
<div>📲</div>
<div>Remote Access</div>
<div className="text-sm text-muted">anywhere</div>
</div>
</div>
</div>
),
// Slide 3: Metrics Collection
() => (
<div className="slide">
<h2>Metrics Collection</h2>
<div className="grid grid-3">
<div className="card">
<div className="icon">📡</div>
<h3>OpenWrt Router</h3>
<p className="text-muted text-sm mb-4">prometheus-node-exporter-lua</p>
<table>
<tbody>
<tr><td>CPU</td><td className="highlight"></td></tr>
<tr><td>Memory</td><td className="highlight"></td></tr>
<tr><td>Network</td><td className="highlight"></td></tr>
<tr><td>Thermal</td><td className="highlight"></td></tr>
<tr><td>Conntrack</td><td className="highlight"></td></tr>
</tbody>
</table>
</div>
<div className="card">
<div className="icon">🖥</div>
<h3>NUC Server</h3>
<p className="text-muted text-sm mb-4">prometheus-node-exporter</p>
<table>
<tbody>
<tr><td>CPU / Load</td><td className="highlight"></td></tr>
<tr><td>Memory</td><td className="highlight"></td></tr>
<tr><td>Disk I/O</td><td className="highlight"></td></tr>
<tr><td>Network</td><td className="highlight"></td></tr>
<tr><td>Filesystem</td><td className="highlight"></td></tr>
</tbody>
</table>
</div>
<div className="card">
<div className="icon">📊</div>
<h3>Prometheus</h3>
<p className="text-muted text-sm mb-4">Scrape & Store</p>
<table>
<tbody>
<tr><td>Interval</td><td><code>15s</code></td></tr>
<tr><td>Retention</td><td><code>30 days</code></td></tr>
<tr><td>Targets</td><td><code>3</code></td></tr>
<tr><td>Alert Rules</td><td><code>6</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
),
// Slide 4: Alert Rules
() => (
<div className="slide">
<h2>Alert Rules</h2>
<div className="grid grid-2">
<div className="card">
<h3>🖥 NUC Alerts</h3>
<table>
<thead>
<tr><th>Alert</th><th>Condition</th><th>Severity</th></tr>
</thead>
<tbody>
<tr>
<td>NUCDown</td>
<td><code>up == 0</code> for 1m</td>
<td><span className="badge badge-red">critical</span></td>
</tr>
<tr>
<td>HighCPULoad</td>
<td><code>CPU > 80%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
<tr>
<td>HighMemory</td>
<td><code>Memory > 85%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
<tr>
<td>DiskSpaceLow</td>
<td><code>Disk > 85%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
</tbody>
</table>
</div>
<div className="card">
<h3>📡 OpenWrt Alerts</h3>
<table>
<thead>
<tr><th>Alert</th><th>Condition</th><th>Severity</th></tr>
</thead>
<tbody>
<tr>
<td>OpenWrtDown</td>
<td><code>up == 0</code> for 1m</td>
<td><span className="badge badge-red">critical</span></td>
</tr>
<tr>
<td>HighLoad</td>
<td><code>load > 2</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
</tbody>
</table>
<div className="mt-4">
<h3>📱 Notification Flow</h3>
<p className="text-muted text-sm mt-2">
Alertmanager Bridge ntfy.sh/nuc-watchdog Phone
</p>
</div>
</div>
</div>
</div>
),
// Slide 5: Auto-Recovery System
() => (
<div className="slide">
<h2>Auto-Recovery System</h2>
<p className="subtitle">Multi-layer recovery ensures maximum uptime</p>
<div className="grid grid-4">
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>1</div>
<h3 className="highlight">Wake-on-LAN</h3>
<p className="text-sm text-muted mt-2">OpenWrt sends magic packet every 2 min while NUC is down</p>
<div className="mt-4">
<code>etherwake 94:c6:91:1f:c9:c5</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>2</div>
<h3 className="highlight">SSH Reboot</h3>
<p className="text-sm text-muted mt-2">Passwordless sudo configured for remote reboot</p>
<div className="mt-4">
<code>sudo reboot</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>3</div>
<h3 className="highlight">Hardware Watchdog</h3>
<p className="text-sm text-muted mt-2">Kernel watchdog auto-reboots on system freeze</p>
<div className="mt-4">
<code>systemd watchdog</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>4</div>
<h3 className="highlight">Panic Reboot</h3>
<p className="text-sm text-muted mt-2">Kernel auto-reboots 10s after panic</p>
<div className="mt-4">
<code>kernel.panic=10</code>
</div>
</div>
</div>
</div>
),
// Slide 6: OpenWrt Monitor
() => (
<div className="slide">
<h2>OpenWrt Health Monitor</h2>
<div className="grid grid-2">
<div className="card">
<h3>🔍 Check Logic</h3>
<div style={{ background: 'var(--surface-muted)', padding: '1rem', borderRadius: '8px', marginTop: '1rem' }}>
<pre style={{ fontFamily: 'monospace', fontSize: '0.8rem', lineHeight: '1.6' }}>
{`Every 30 seconds:
├─ HTTP check (port 3000)
├─ Ping check
├─ Both OK → Reset failures
├─ Ping OK, HTTP fail → Service degraded
└─ Both fail → NUC down
├─ Alert via ntfy
└─ Send WoL (every 2 min)`}
</pre>
</div>
</div>
<div className="card">
<h3> Configuration</h3>
<table>
<tbody>
<tr><td>Location</td><td><code>/opt/monitor-nuc.sh</code></td></tr>
<tr><td>Check Interval</td><td><code>30 seconds</code></td></tr>
<tr><td>Fail Threshold</td><td><code>3 checks</code></td></tr>
<tr><td>WoL Retry</td><td><code>2 minutes</code></td></tr>
<tr><td>NUC MAC</td><td><code>94:c6:91:1f:c9:c5</code></td></tr>
<tr><td>Alert Channel</td><td><code>ntfy.sh/nuc-watchdog</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
),
// Slide 7: Remote Access
() => (
<div className="slide">
<h2>Remote Access</h2>
<div className="grid grid-2">
<div className="card">
<div className="icon">🔷</div>
<h3>Tailscale (NUC)</h3>
<p className="text-muted text-sm mb-4">Zero-config mesh VPN with Funnel</p>
<table>
<tbody>
<tr><td>Grafana URL</td><td><code>alezmad-nuc.tail58f5ad.ts.net</code></td></tr>
<tr><td>Access</td><td>Anywhere, no VPN app needed</td></tr>
<tr><td>Protocol</td><td>HTTPS (auto-cert)</td></tr>
</tbody>
</table>
<div className="mt-4">
<span className="badge badge-cyan">Best for: Services</span>
</div>
</div>
<div className="card">
<div className="icon">🔐</div>
<h3>WireGuard (OpenWrt)</h3>
<p className="text-muted text-sm mb-4">Full LAN access, works if NUC is down</p>
<table>
<tbody>
<tr><td>Endpoint</td><td><code>5.224.196.245:51820</code></td></tr>
<tr><td>VPN Subnet</td><td><code>10.10.10.0/24</code></td></tr>
<tr><td>Config</td><td><code>~/wireguard/home-vpn.conf</code></td></tr>
</tbody>
</table>
<div className="mt-4">
<span className="badge badge-green">Best for: Full LAN / Recovery</span>
</div>
</div>
</div>
</div>
),
// Slide 8: Access URLs
() => (
<div className="slide">
<h2>Quick Reference</h2>
<div className="card">
<h3>🔗 Access URLs</h3>
<table>
<thead>
<tr><th>Service</th><th>Local</th><th>Remote</th><th>Credentials</th></tr>
</thead>
<tbody>
<tr>
<td><span className="badge badge-green">Grafana</span></td>
<td><code>192.168.1.3:3333</code></td>
<td><code>alezmad-nuc.tail58f5ad.ts.net</code></td>
<td>admin / nucmonitoring</td>
</tr>
<tr>
<td><span className="badge badge-cyan">Prometheus</span></td>
<td><code>192.168.1.3:9091</code></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td><span className="badge badge-orange">Alertmanager</span></td>
<td><code>192.168.1.3:9093</code></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td><span className="badge badge-purple">ntfy</span></td>
<td colspan="2"><code>ntfy.sh/nuc-watchdog</code></td>
<td>Subscribe in app</td>
</tr>
</tbody>
</table>
</div>
<div className="card mt-4">
<h3>🛠 Maintenance Commands</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginTop: '1rem' }}>
<code>curl localhost:9091/api/v1/targets</code>
<span className="text-muted text-sm">Check Prometheus targets</span>
<code>curl localhost:9093/api/v2/alerts</code>
<span className="text-muted text-sm">View active alerts</span>
<code>docker restart prometheus-*</code>
<span className="text-muted text-sm">Restart monitoring</span>
<code>curl -d "test" ntfy.sh/nuc-watchdog</code>
<span className="text-muted text-sm">Test notification</span>
</div>
</div>
</div>
),
// Slide 9: Summary
() => (
<div className="slide" style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<h1>System Status</h1>
<div className="grid grid-4 mt-4" style={{ maxWidth: '900px' }}>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Prometheus</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">3 targets scraped</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Grafana</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">Remote access on</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Alerts</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">6 rules active</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Recovery</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">4 layers ready</p>
</div>
</div>
<p className="subtitle mt-4">Production-ready monitoring for home infrastructure</p>
</div>
),
];
function Presentation() {
const [currentSlide, setCurrentSlide] = useState(0);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(s => Math.min(s + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft') {
setCurrentSlide(s => Math.max(s - 1, 0));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const SlideComponent = slides[currentSlide];
return (
<>
<div className="logo">NUC Portal</div>
<div className="slide-number">{currentSlide + 1} / {slides.length}</div>
<SlideComponent key={currentSlide} />
<nav className="nav">
<button
className="nav-btn"
onClick={() => setCurrentSlide(s => Math.max(s - 1, 0))}
disabled={currentSlide === 0}
>
</button>
<div className="nav-dots">
{slides.map((_, i) => (
<div
key={i}
className={`nav-dot ${i === currentSlide ? 'active' : ''}`}
onClick={() => setCurrentSlide(i)}
/>
))}
</div>
<button
className="nav-btn"
onClick={() => setCurrentSlide(s => Math.min(s + 1, slides.length - 1))}
disabled={currentSlide === slides.length - 1}
>
</button>
</nav>
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<Presentation />);
</script>
</body>
</html>

156
docs/monitoring.md Normal file
View File

@@ -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`

252
docs/remote-access.md Normal file
View File

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

329
docs/security.md Normal file
View File

@@ -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 <IP>
```
### 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 <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 { <IP> }"
```
### 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

View File

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