Actualiza toolkit operativo y documentación
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# Evolution Go (API WhatsApp en Go) — stack para el host casero.
|
||||
#
|
||||
# Fuente: https://github.com/evolution-foundation/evolution-go (clon local en
|
||||
# projects/evolution-go, tag 0.7.2). Imagen publicada: evoapicloud/evolution-go.
|
||||
#
|
||||
# Por qué este compose y no el de upstream (docker/examples/docker-compose.yml):
|
||||
# - publica `ports:` 4000 y 5432 al host; aquí el 80/443 y el resto de puertos
|
||||
# los gestiona Traefik/Coolify y no hay que publicar nada (§2.3 del contrato).
|
||||
# - monta ./init-db.sql por ruta local: imposible en Coolify, el compose se
|
||||
# guarda como docker_compose_raw en la DB, no hay árbol de archivos. No hace
|
||||
# falta: ensureDBExists() (pkg/config/config.go) crea la DB del DSN al arrancar.
|
||||
# - usa vars que el código de 0.7.2 ya no lee (WADEBUG/LOGTYPE); las reales son
|
||||
# DEBUG_ENABLED/LOG_TYPE (pkg/config/env/env.go).
|
||||
#
|
||||
# Detalles verificados contra el código 0.7.2:
|
||||
# - GateMiddleware (pkg/core/c0.go) devuelve 503 en TODO hasta activar licencia,
|
||||
# EXCEPTO /server/ok, /manager, /assets, /license/*, /swagger, /ws. El
|
||||
# healthcheck usa /server/ok (200 siempre) para no dejar al contenedor sin
|
||||
# ruta en Traefik antes de activar la licencia desde el Manager.
|
||||
# - whatsmeow guarda las sesiones SQLite en /app/dbdata (exPath = /app):
|
||||
# SIEMPRE volumen con nombre o cada redeploy desvincula los teléfonos.
|
||||
# - POSTGRES_AUTH_DB (URI completa) es OBLIGATORIA aunque parezca opcional:
|
||||
# con string vacío initPostgresAuthDB() devuelve (nil, nil) —sin error— y
|
||||
# NewPollService() hace panic por nil deref sobre el *sql.DB (bug upstream
|
||||
# 0.7.2, cmd/evolution-go/main.go:300 + pkg/poll/service/poll_service.go:39).
|
||||
# La URI interpola ${POSTGRES_PASSWORD}: Coolify sustituye al deployear,
|
||||
# el secreto vive solo en la env del servicio.
|
||||
# - POSTGRES_USERS_DB en URI para seguir el contrato upstream (con las vars
|
||||
# discretas también funciona; la DB evogo_users se auto-crea igual).
|
||||
# - DATABASE_SAVE_MESSAGES y GLOBAL_API_KEY son obligatorias (panicIfEmpty).
|
||||
# - SERVER_PORT no tiene default en el código: fijarlo siempre.
|
||||
#
|
||||
# Contrato: docs/AGENTS-coolify-apps.md
|
||||
# - sin publicar 80/443: solo `expose`, Traefik enruta (§2.3)
|
||||
# - DB hermana por nombre de servicio, nunca localhost (§2.1)
|
||||
# - secretos (${VAR} sin default) llegan como env de Coolify, jamás aquí (§2.5)
|
||||
# - healthchecks con start_period holgado: el primer arranque aquí tarda
|
||||
# minutos (docs/casos/coolify-servicio-nuevo-503-no-available-server.md)
|
||||
|
||||
services:
|
||||
evolution-go:
|
||||
image: 'evoapicloud/evolution-go:0.7.2'
|
||||
environment:
|
||||
SERVER_PORT: '8080'
|
||||
CLIENT_NAME: '${CLIENT_NAME:-evolution}'
|
||||
# Obligatoria (panicIfEmpty). Secreto: inyectada por Coolify.
|
||||
GLOBAL_API_KEY: '${GLOBAL_API_KEY}'
|
||||
# Obligatoria y no vacía (panicIfEmpty).
|
||||
DATABASE_SAVE_MESSAGES: '${DATABASE_SAVE_MESSAGES:-false}'
|
||||
# OBLIGATORIA en URI (ver cabecera: vacía = panic nil deref en 0.7.2).
|
||||
# Coolify interpola ${POSTGRES_PASSWORD} del env del servicio al deploy.
|
||||
POSTGRES_AUTH_DB: 'postgresql://${POSTGRES_USER:-evolution}:${POSTGRES_PASSWORD}@evolution-postgres:5432/evogo_auth?sslmode=disable'
|
||||
POSTGRES_USERS_DB: 'postgresql://${POSTGRES_USER:-evolution}:${POSTGRES_PASSWORD}@evolution-postgres:5432/evogo_users?sslmode=disable'
|
||||
POSTGRES_HOST: evolution-postgres
|
||||
POSTGRES_PORT: '5432'
|
||||
POSTGRES_USER: '${POSTGRES_USER:-evolution}'
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
|
||||
POSTGRES_DB: '${POSTGRES_DB:-evogo_users}'
|
||||
# Nombres reales en 0.7.2 (env.go): DEBUG_ENABLED / LOG_TYPE.
|
||||
DEBUG_ENABLED: '${DEBUG_ENABLED:-INFO}'
|
||||
LOG_TYPE: '${LOG_TYPE:-console}'
|
||||
WEBHOOK_FILES: 'true'
|
||||
CONNECT_ON_STARTUP: 'false'
|
||||
OS_NAME: 'Linux'
|
||||
EVENT_IGNORE_GROUP: 'false'
|
||||
EVENT_IGNORE_STATUS: 'true'
|
||||
QRCODE_MAX_COUNT: '5'
|
||||
# AMQP/NATS/MinIO/WEBHOOK_URL apagados a propósito: nada de colas ni
|
||||
# objeto-storage extra en este host; el Manager funciona igual.
|
||||
AMQP_GLOBAL_ENABLED: 'false'
|
||||
NATS_GLOBAL_ENABLED: 'false'
|
||||
MINIO_ENABLED: 'false'
|
||||
# Sin bloque `ports:` — Traefik llega al 8080 interno (§2.3). FQDN sin
|
||||
# puerto + único `expose`: el mismo patrón verificado del stack firecrawl.
|
||||
expose:
|
||||
- '8080'
|
||||
volumes:
|
||||
# Sesiones whatsmeow (SQLite): perder esto = re-escanear QR de todos los
|
||||
# teléfonos en cada redeploy (§5).
|
||||
- 'evolution-data:/app/dbdata'
|
||||
- 'evolution-logs:/app/logs'
|
||||
depends_on:
|
||||
evolution-postgres:
|
||||
condition: service_healthy
|
||||
# Imagen alpine: wget es el de busybox (curl no viene instalado en 0.7.2).
|
||||
# /server/ok responde 200 sin licencia activa — ver comentario de cabecera.
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:8080/server/ok']
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
start_period: 300s
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
cpus: 1.5
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
evolution-postgres:
|
||||
image: 'postgres:16-alpine'
|
||||
environment:
|
||||
POSTGRES_USER: '${POSTGRES_USER:-evolution}'
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
|
||||
POSTGRES_DB: '${POSTGRES_DB:-evogo_users}'
|
||||
expose:
|
||||
- '5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-evolution} -d ${POSTGRES_DB:-evogo_users}']
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
start_period: 180s
|
||||
volumes:
|
||||
- 'evolution-postgres:/var/lib/postgresql/data'
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 5m
|
||||
max-file: '2'
|
||||
|
||||
volumes:
|
||||
evolution-data: {}
|
||||
evolution-logs: {}
|
||||
evolution-postgres: {}
|
||||
@@ -0,0 +1,189 @@
|
||||
# Firecrawl - stack mínimo con imágenes precompiladas, para el host casero.
|
||||
#
|
||||
# Por qué no se usa el compose de upstream (firecrawl/firecrawl, rama main):
|
||||
# - construye 3 servicios desde fuente (apps/api, apps/playwright-service-ts,
|
||||
# apps/nuq-postgres). Compilar Chromium en un disco a ~26 ms/escritura es el
|
||||
# peor caso posible en este host.
|
||||
# - añade FoundationDB (+ un init) que solo se usan si NUQ_BACKEND está puesto.
|
||||
# - pide mem_limit 8G en api y 4G en playwright. El LXC tiene 4 cores.
|
||||
#
|
||||
# Aquí todo son imágenes ya publicadas: cero builds. FoundationDB queda fuera y
|
||||
# NUQ_BACKEND se deja vacío, que es su modo por defecto.
|
||||
#
|
||||
# Contrato: docs/AGENTS-coolify-apps.md
|
||||
# - sin publicar 80/443: solo `expose`, Traefik enruta (§2.3)
|
||||
# - hermanos por nombre de servicio, nunca localhost (§2.1)
|
||||
# - volumen con nombre para lo que debe sobrevivir a un redeploy (§5)
|
||||
# - healthchecks con start_period holgado: el primer arranque aquí tarda
|
||||
# minutos (ver docs/casos/coolify-servicio-nuevo-503-no-available-server.md)
|
||||
# - sin secretos en el archivo: llegan como variables de entorno (§2.5)
|
||||
|
||||
services:
|
||||
api:
|
||||
image: 'ghcr.io/firecrawl/firecrawl:2.10.19'
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: '3002'
|
||||
INTERNAL_PORT: '3002'
|
||||
WORKER_PORT: '3005'
|
||||
EXTRACT_WORKER_PORT: '3004'
|
||||
ENV: local
|
||||
# Hermanos por nombre de servicio (§2.1)
|
||||
REDIS_URL: 'redis://redis:6379'
|
||||
REDIS_RATE_LIMIT_URL: 'redis://redis:6379'
|
||||
PLAYWRIGHT_MICROSERVICE_URL: 'http://playwright-service:3000/scrape'
|
||||
NUQ_RABBITMQ_URL: 'amqp://rabbitmq:5672'
|
||||
POSTGRES_HOST: nuq-postgres
|
||||
POSTGRES_PORT: '5432'
|
||||
POSTGRES_USER: '${POSTGRES_USER:-postgres}'
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-postgres}'
|
||||
POSTGRES_DB: '${POSTGRES_DB:-postgres}'
|
||||
USE_DB_AUTHENTICATION: 'false'
|
||||
# Vacío a propósito: con NUQ_BACKEND sin definir, FoundationDB no se usa.
|
||||
NUQ_BACKEND: ''
|
||||
# Concurrencia recortada para 4 cores (upstream trae 8/10/5/5).
|
||||
NUM_WORKERS_PER_QUEUE: '${NUM_WORKERS_PER_QUEUE:-2}'
|
||||
CRAWL_CONCURRENT_REQUESTS: '${CRAWL_CONCURRENT_REQUESTS:-3}'
|
||||
MAX_CONCURRENT_JOBS: '${MAX_CONCURRENT_JOBS:-2}'
|
||||
BROWSER_POOL_SIZE: '${BROWSER_POOL_SIZE:-2}'
|
||||
HARNESS_STARTUP_TIMEOUT_MS: '${HARNESS_STARTUP_TIMEOUT_MS:-180000}'
|
||||
LOGGING_LEVEL: '${LOGGING_LEVEL:-info}'
|
||||
# Sin esto el worker responde "Can't accept connection due to RAM/CPU
|
||||
# load" y rechaza todo: el umbral por defecto (0.8) se supera constantemente
|
||||
# en un host compartido como este.
|
||||
MAX_RAM: '${MAX_RAM:-0.95}'
|
||||
MAX_CPU: '${MAX_CPU:-0.95}'
|
||||
# Secretos: inyectados por Coolify, nunca literales aquí (§2.5)
|
||||
BULL_AUTH_KEY: '${BULL_AUTH_KEY}'
|
||||
TEST_API_KEY: '${TEST_API_KEY}'
|
||||
OPENAI_API_KEY: '${OPENAI_API_KEY}'
|
||||
OPENAI_BASE_URL: '${OPENAI_BASE_URL}'
|
||||
MODEL_NAME: '${MODEL_NAME}'
|
||||
MODEL_EMBEDDING_NAME: '${MODEL_EMBEDDING_NAME}'
|
||||
SEARXNG_ENDPOINT: '${SEARXNG_ENDPOINT}'
|
||||
# Sin bloque `ports:` — Traefik llega al puerto interno (§2.3)
|
||||
expose:
|
||||
- '3002'
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
playwright-service:
|
||||
condition: service_started
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
nuq-postgres:
|
||||
condition: service_healthy
|
||||
# Verificado dentro de la imagen: NO trae wget ni nc, solo curl. Y /test,
|
||||
# /health y /v1/health dan 404; la raiz da 200. Un healthcheck con wget
|
||||
# falla siempre y deja el contenedor sin ruta en Traefik -> 503.
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', '-o', '/dev/null', 'http://127.0.0.1:3002/']
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
start_period: 300s
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
cpus: 2.0
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
playwright-service:
|
||||
image: 'ghcr.io/firecrawl/playwright-service:latest'
|
||||
environment:
|
||||
PORT: '3000'
|
||||
MAX_CONCURRENT_PAGES: '${CRAWL_CONCURRENT_REQUESTS:-3}'
|
||||
BLOCK_MEDIA: '${BLOCK_MEDIA:-true}'
|
||||
ALLOW_LOCAL_WEBHOOKS: '${ALLOW_LOCAL_WEBHOOKS:-false}'
|
||||
expose:
|
||||
- '3000'
|
||||
# Chromium escribe mucho en /tmp; en tmpfs no toca el disco lento.
|
||||
tmpfs:
|
||||
- '/tmp/.cache:noexec,nosuid,size=512m'
|
||||
mem_limit: 2g
|
||||
memswap_limit: 2g
|
||||
cpus: 1.5
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
command: 'redis-server --bind 0.0.0.0 --save "" --appendonly no'
|
||||
expose:
|
||||
- '6379'
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 5m
|
||||
max-file: '2'
|
||||
|
||||
rabbitmq:
|
||||
image: 'rabbitmq:3-management'
|
||||
expose:
|
||||
- '5672'
|
||||
healthcheck:
|
||||
test: ['CMD', 'rabbitmq-diagnostics', '-q', 'check_running']
|
||||
interval: 15s
|
||||
timeout: 15s
|
||||
retries: 20
|
||||
start_period: 180s
|
||||
volumes:
|
||||
- 'firecrawl-rabbitmq:/var/lib/rabbitmq'
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 5m
|
||||
max-file: '2'
|
||||
|
||||
nuq-postgres:
|
||||
image: 'ghcr.io/firecrawl/nuq-postgres:latest'
|
||||
environment:
|
||||
POSTGRES_USER: '${POSTGRES_USER:-postgres}'
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-postgres}'
|
||||
POSTGRES_DB: '${POSTGRES_DB:-postgres}'
|
||||
expose:
|
||||
- '5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}']
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
start_period: 180s
|
||||
volumes:
|
||||
- 'firecrawl-postgres:/var/lib/postgresql/data'
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
volumes:
|
||||
firecrawl-postgres: {}
|
||||
firecrawl-rabbitmq: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
node_modules
|
||||
.next
|
||||
*.md
|
||||
.gg
|
||||
tests
|
||||
@@ -0,0 +1,34 @@
|
||||
# oh-daddy (Next.js 16) - production image, built on the Coolify server.
|
||||
# No upstream Dockerfile exists (repo targets Railway/nixpacks), so this
|
||||
# replicates railway.json's startCommand contract: bash scripts/start.sh
|
||||
# (backgrounds the Inngest post-deploy re-sync, then execs `npm run start`).
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS proddeps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache bash
|
||||
ENV NODE_ENV=production PORT=3000 NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=proddeps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/scripts ./scripts
|
||||
EXPOSE 3000
|
||||
CMD ["bash", "scripts/start.sh"]
|
||||
@@ -0,0 +1,108 @@
|
||||
# oh-daddy stack for Coolify (service type: docker compose, prebuilt/local images)
|
||||
# Upstream: https://github.com/KenKaiii/oh-daddy
|
||||
# App image `oh-daddy-app:local` is built on the server (Deploy-OhDaddy flow,
|
||||
# same pattern as Deploy-SoloLeveling.ps1) - Coolify's own deploy cannot pull it.
|
||||
# All secrets come from Coolify service env vars (is_literal) / .env in the
|
||||
# service dir; nothing is hardcoded here.
|
||||
#
|
||||
# Hard rules honored (docs/AGENTS-coolify-apps.md):
|
||||
# - no published 80/443; Traefik routes via SERVICE_FQDN_APP_3000 -> port 3000
|
||||
# - siblings reached by Docker service name (db, inngest, inngest-db, inngest-redis)
|
||||
# - named volumes for everything that must survive redeploys
|
||||
|
||||
services:
|
||||
app:
|
||||
image: oh-daddy-app:local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SERVICE_FQDN_APP_3000
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- INNGEST_BASE_URL=${INNGEST_BASE_URL}
|
||||
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
|
||||
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
expose:
|
||||
- "3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/login"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=ohdaddy
|
||||
- POSTGRES_USER=ohdaddy
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- oh-daddy-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ohdaddy -d ohdaddy"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
# Self-hosted Inngest engine (NOT Inngest Cloud) - internal only, no FQDN.
|
||||
inngest:
|
||||
image: inngest/inngest:v1.44.0
|
||||
restart: unless-stopped
|
||||
command: ["inngest", "start"]
|
||||
environment:
|
||||
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
|
||||
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
|
||||
- INNGEST_POSTGRES_URI=${INNGEST_POSTGRES_URI}
|
||||
- INNGEST_REDIS_URI=redis://inngest-redis:6379
|
||||
expose:
|
||||
- "8288"
|
||||
healthcheck:
|
||||
test: ["CMD", "inngest", "alpha", "doctor", "healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
depends_on:
|
||||
inngest-db:
|
||||
condition: service_healthy
|
||||
inngest-redis:
|
||||
condition: service_healthy
|
||||
|
||||
inngest-db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=inngest
|
||||
- POSTGRES_USER=inngest
|
||||
- POSTGRES_PASSWORD=${INNGEST_DB_PASSWORD}
|
||||
volumes:
|
||||
- oh-daddy-inngest-pg-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U inngest -d inngest"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
inngest-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- oh-daddy-inngest-redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
oh-daddy-db-data: {}
|
||||
oh-daddy-inngest-pg-data: {}
|
||||
oh-daddy-inngest-redis-data: {}
|
||||
@@ -0,0 +1,22 @@
|
||||
# OpenSEO — env vars para Coolify (template, sin secretos reales).
|
||||
#
|
||||
# Copia este archivo a `stacks/open-seo/.env.coolify`, rellena lo que aplique,
|
||||
# y pásalo a `New-CoolifyService.ps1 -EnvFile`. El script empuja cada línea
|
||||
# KEY=VALUE como env var del servicio; las referencias `${VAR}` dentro del
|
||||
# compose se resuelven en runtime desde esas vars.
|
||||
#
|
||||
# Nunca commitees el archivo `.env.coolify` real — está en .gitignore.
|
||||
|
||||
# SEO data (opcional). Base64 de `email:password` de dataforseo.com — NO la
|
||||
# dashboard API key. Vacío = la app arranca, los workflows SEO muestran "no
|
||||
# data". Ver https://openseo.so/docs/DATAFORSEO_API_KEY.md
|
||||
DATAFORSEO_API_KEY=
|
||||
|
||||
# Telemetry opt-out. "1" para desactivar el heartbeat anónimo y el beacon de
|
||||
# fallo de preflight. Por defecto encendido.
|
||||
OPENSEO_TELEMETRY_DISABLED=1
|
||||
DO_NOT_TRACK=1
|
||||
|
||||
# SAM, el agente SEO dentro de la app (opcional). Oculto si vacío.
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=
|
||||
@@ -0,0 +1,154 @@
|
||||
# OpenSEO — stack self-host en Coolify
|
||||
|
||||
> Resuelto el **2026-08-27** contra el host real.
|
||||
> Target: **LXC 102** (`coolify`), servicio compose, FQDN
|
||||
> **`https://openseo.urieljareth.org`**.
|
||||
> Imagen: **`ghcr.io/every-app/open-seo:sha-c469a48`** (mismo SHA que el deploy
|
||||
> fallido anterior — esta vez la build de Vite sí corre, en el entrypoint).
|
||||
|
||||
---
|
||||
|
||||
## Por qué existe este stack
|
||||
|
||||
El deploy previo (`uuid kj0kccsb4d46tm0d6qe6docy`, `name=open-seo:main-...`,
|
||||
deployment `fkojyfkqzp69hcba6miy8oer`) terminó con Coolify marcando verde y
|
||||
**Caddy respondiendo 404 a todo**. Diagnóstico:
|
||||
|
||||
- `build_pack=railpack` clonó `every-app/open-seo@main`, construyó imagen local
|
||||
con el mismo SHA `c469a48ae90ab58413b198fe3d1ac1aa90a9b070` y la cacheó.
|
||||
- En el redeploy: `No build configuration changed & image found (...) Build
|
||||
step skipped` → la imagen cacheada **no tenía `/app/dist`** (los artefactos
|
||||
del build de Vite) y Coolify la reusó.
|
||||
- Caddy (`/Caddyfile` con `root * /app/dist` + SPA fallback a `/index.html`)
|
||||
no encontró nada y devolvió 404 a `/`, `/robots.txt`, `/health`.
|
||||
- El README upstream lo dice textual: *"We recommend self-hosting with
|
||||
Cloudflare as opposed to Railway, Coolify or Dokploy. We plan to make it
|
||||
simpler to host on those platforms in the next few months."*
|
||||
|
||||
**Solución:** dejar de seguir upstream y consumir la **imagen prebuilt** que el
|
||||
propio equipo publica en GHCR. Esa imagen tiene la cadena correcta:
|
||||
`docker-entrypoint.sh` corre preflight → migrations → `pnpm run build` (que sí
|
||||
genera `/app/dist`) → `vite preview` en el puerto `3001`. Y usa un fingerprint
|
||||
para no reconstruir cuando los env vars relevantes no cambiaron.
|
||||
|
||||
Stack: **un solo servicio** (OpenSEO es self-contained: SQLite vía workerd en
|
||||
`/app/.wrangler`, volumen `openseo-data`). Sin DB externa.
|
||||
|
||||
---
|
||||
|
||||
## Archivos
|
||||
|
||||
| Archivo | Para qué |
|
||||
|---|---|
|
||||
| `docker-compose.coolify.yml` | Compose que consume Coolify vía `POST /services` |
|
||||
| `.env.example` | Template de env vars (sin secretos) |
|
||||
| `.env.coolify` | **No committed.** Lo crea el operador con `cp .env.example .env.coolify` y rellena |
|
||||
|
||||
---
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
Hardcoded en el compose (porque son decisión de arquitectura, no secretos):
|
||||
|
||||
| Var | Valor | Por qué |
|
||||
|---|---|---|
|
||||
| `PORT` | `3001` | Es donde escucha `vite preview` (per `Dockerfile.selfhost`) |
|
||||
| `AUTH_MODE` | `local_noauth` | Single admin, sin pantalla de login. Aquí no tenemos `TEAM_DOMAIN`/`POLICY_AUD` de Cloudflare Access |
|
||||
| `ALLOWED_HOST` | `openseo.urieljareth.org` | Sin esto, Vite bloquea toda petición externa con "Blocked request" |
|
||||
| `CLOUDFLARE_INCLUDE_PROCESS_ENV` | `true` | Lo exige el runtime workerd para que process.env llegue a los bindings |
|
||||
|
||||
Suministradas vía `.env.coolify` (env vars del servicio en Coolify):
|
||||
|
||||
| Var | Default | Efecto |
|
||||
|---|---|---|
|
||||
| `DATAFORSEO_API_KEY` | vacío | **WARN** del preflight (no FAIL). Vacío = la app arranca, los workflows SEO devuelven "no data" |
|
||||
| `OPENSEO_TELEMETRY_DISABLED` | `1` | Apaga el heartbeat anónimo |
|
||||
| `DO_NOT_TRACK` | `1` | Alias del anterior |
|
||||
| `OPENROUTER_API_KEY` | vacío | Habilita a SAM (el agente SEO integrado) si se setea |
|
||||
| `OPENROUTER_MODEL` | vacío | Modelo a usar con SAM |
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. (Manual, una sola vez) Ingress del túnel de Cloudflare
|
||||
|
||||
El token de Cloudflare **no está** en `.env.local.ps1`, así que esto se hace en
|
||||
el dashboard:
|
||||
|
||||
1. Cloudflare Zero Trust → Networks → Tunnels → tunnel `urieljareth` →
|
||||
Configure → Public hostname.
|
||||
2. Add a public hostname:
|
||||
- Subdomain: `openseo`
|
||||
- Domain: `urieljareth.org`
|
||||
- Service: **HTTP** (no HTTPS, lo gestiona Coolify/Traefik)
|
||||
- URL: `coolify.urieljareth.org` (o la IP interna del proxy de Coolify —
|
||||
misma que usan los demás subdominios)
|
||||
|
||||
### 2. Crear el servicio en Coolify
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
|
||||
# Crear el archivo de env real (gitignored)
|
||||
Copy-Item .\stacks\open-seo\.env.example .\stacks\open-seo\.env.coolify
|
||||
# Editar .\stacks\open-seo\.env.coolify si quieres setear DATAFORSEO_API_KEY
|
||||
|
||||
.\deploy_skill\scripts\New-CoolifyService.ps1 `
|
||||
-AppPath .\stacks\open-seo `
|
||||
-AppName open-seo `
|
||||
-Fqdn https://openseo.urieljareth.org `
|
||||
-PrimaryService app `
|
||||
-ProjectName "AI AGENCY" -EnvironmentName production `
|
||||
-EnvFile .\stacks\open-seo\.env.coolify `
|
||||
-InstantDeploy
|
||||
```
|
||||
|
||||
### 3. Esperar al primer arranque
|
||||
|
||||
El primer `up` tarda **1-2 min**: preflight + migrations + vite build + arranque
|
||||
de `vite preview`. Traefik no enruta hasta que el contenedor esté `healthy`.
|
||||
|
||||
```powershell
|
||||
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
|
||||
```
|
||||
|
||||
### 4. Verificar
|
||||
|
||||
```powershell
|
||||
# 1. Endpoint público responde (TLS emitido, Traefik enrutando)
|
||||
curl.exe -k -sSI https://openseo.urieljareth.org/
|
||||
|
||||
# 2. Status del contenedor
|
||||
.\coolify_skill\scripts\Get-CoolifyDockerStatus.ps1 -Filter openseo
|
||||
|
||||
# 3. Logs del entrypoint (debería verse "Preflight passed")
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs <cont> --tail 60"
|
||||
|
||||
# 4. Preflight reporta lo que falta
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> wget -qO- http://127.0.0.1:3001/api/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback / limpieza
|
||||
|
||||
| Acción | Comando |
|
||||
|---|---|
|
||||
| Parar la app rota original | `docker stop kj0kccsb4d46tm0d6qe6docy-055629993145` (vía `pct exec 102 --`) |
|
||||
| Borrar la app rota de Coolify | UI → Service `kj0kccsb4d46tm0d6qe6docy` → Delete |
|
||||
| Re-deployar | UI → Service `open-seo` → Redeploy |
|
||||
|
||||
---
|
||||
|
||||
## Cosas que caducan
|
||||
|
||||
- **El tag `:sha-c469a48` se queda viejo.** Cuando el upstream publique un SHA
|
||||
más reciente, actualizar el `image:` en `docker-compose.coolify.yml` y
|
||||
redeployar. `v0.1.6` también existe (publicado 8 días antes).
|
||||
- **El entrypoint vuelve a buildear `dist`** cada vez que algún env var del
|
||||
prefijo `VITE_*` / `AUTH_MODE` / `POSTHOG_*` / `TURNSTILE_SITE_KEY` /
|
||||
`BYPASS_EMAIL_VERIFICATION` cambie. Es intencional — el fingerprint está
|
||||
ahí para no rehacer cuando nada relevante cambió.
|
||||
- **Coolify normaliza el compose al guardarlo y borra los comentarios.** La
|
||||
versión con explicaciones es la del repo, no la que se ve en la UI.
|
||||
@@ -0,0 +1,78 @@
|
||||
# OpenSEO self-host — Coolify stack (LXC 102)
|
||||
#
|
||||
# Por qué este compose en lugar del repo upstream (`every-app/open-seo`) vía
|
||||
# railpack:
|
||||
# - el deploy anterior (uuid kj0kccsb4d46tm0d6qe6docy) terminó con Caddy
|
||||
# respondiendo 404 a todo porque /app/dist no existía en la imagen cacheada
|
||||
# (Coolify saltó el build: "No build configuration changed & image found ...
|
||||
# Build step skipped").
|
||||
# - el README upstream lo dice explícitamente: "We recommend self-hosting
|
||||
# with Cloudflare as opposed to Railway, Coolify or Dokploy. We plan to
|
||||
# make it simpler to host on those platforms in the next few months."
|
||||
# - esta imagen (`ghcr.io/every-app/open-seo:sha-c469a48`) es la build del
|
||||
# mismo commit, pero el build de Vite corre en `docker-entrypoint.sh` al
|
||||
# arrancar el contenedor, no en el build de la imagen. Y el entrypoint ya
|
||||
# tiene la lógica de fingerprint para no reconstruir cuando los env vars
|
||||
# relevantes no cambiaron.
|
||||
#
|
||||
# Contrato: docs/AGENTS-coolify-apps.md
|
||||
# - sin publicar 80/443: solo `expose`, Traefik enruta (§2.3)
|
||||
# - SECRETOS vía env vars inyectados por Coolify (§2.5)
|
||||
# - volumen con nombre para /app/.wrangler (SQLite que sobrevive al redeploy, §5)
|
||||
# - healthcheck independiente de servicios externos al boot
|
||||
# - AUTH_MODE=local_noauth porque aquí no hay TEAM_DOMAIN/POLICY_AUD de
|
||||
# Cloudflare Access. Si más adelante se quiere proteger con auth, cambiar
|
||||
# a AUTH_MODE=cloudflare_access + TEAM_DOMAIN + POLICY_AUD.
|
||||
# - ALLOWED_HOST es obligatorio detrás del túnel: sin él Vite bloquea toda
|
||||
# petición externa con "Blocked request" (ver preflight info level).
|
||||
|
||||
services:
|
||||
app:
|
||||
image: 'ghcr.io/every-app/open-seo:sha-c469a48'
|
||||
environment:
|
||||
# Puerto en el que escucha `vite preview` (per Dockerfile.selfhost / entrypoint).
|
||||
- PORT=3001
|
||||
# Single admin user, sin pantalla de login. NO exponer públicamente sin
|
||||
# poner tu propia auth delante — el preflight lo dice literal.
|
||||
- AUTH_MODE=local_noauth
|
||||
# Host header permitido. Es el FQDN público por el que llega el tráfico
|
||||
# desde el túnel de Cloudflare.
|
||||
- ALLOWED_HOST=openseo.urieljareth.org
|
||||
# Requerido por el runtime workerd para exponer process.env a los
|
||||
# bindings (lo exige el compose upstream).
|
||||
- CLOUDFLARE_INCLUDE_PROCESS_ENV=true
|
||||
# SEO data (opcional). Vacío = la app arranca, los workflows SEO
|
||||
# devuelven "no data". Se setea después vía Coolify env.
|
||||
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY:-}
|
||||
# Telemetry opt-out (también vía DO_NOT_TRACK). Por defecto apagado.
|
||||
- OPENSEO_TELEMETRY_DISABLED=${OPENSEO_TELEMETRY_DISABLED:-}
|
||||
- DO_NOT_TRACK=${DO_NOT_TRACK:-}
|
||||
# AI features (SAM, el agente SEO integrado). Vacío = SAM deshabilitado.
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-}
|
||||
expose:
|
||||
- '3001'
|
||||
# El endpoint /api/health lo sirve el propio preflight (ver
|
||||
# src/lib/selfhost-preflight.ts) sin auth — seguro para el healthcheck.
|
||||
# Usamos `node` directamente porque la imagen es node:22 y el HEALTHCHECK
|
||||
# upstream hace exactamente esto.
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "node -e \"fetch('http://127.0.0.1:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
# Primer arranque: preflight + migrations + vite build (1-2 min).
|
||||
start_period: 300s
|
||||
volumes:
|
||||
- 'openseo-data:/app/.wrangler'
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
volumes:
|
||||
openseo-data: {}
|
||||
Reference in New Issue
Block a user