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:
248
docs/cloudbeaver-database-manager.md
Normal file
248
docs/cloudbeaver-database-manager.md
Normal 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/
|
||||||
731
docs/ecija-intranet-deployment.md
Normal file
731
docs/ecija-intranet-deployment.md
Normal 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.
|
||||||
250
docs/gitea-coolify-auto-deploy.md
Normal file
250
docs/gitea-coolify-auto-deploy.md
Normal 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)
|
||||||
314
docs/gitea-coolify-integration.md
Normal file
314
docs/gitea-coolify-integration.md
Normal 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
|
||||||
665
docs/monitoring-presentation.html
Normal file
665
docs/monitoring-presentation.html
Normal 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
156
docs/monitoring.md
Normal 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
252
docs/remote-access.md
Normal 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
329
docs/security.md
Normal 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
|
||||||
872
docs/turbostarter-deployment.md
Normal file
872
docs/turbostarter-deployment.md
Normal 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.
|
||||||
Reference in New Issue
Block a user