Manual completo (todo en uno)

Convenciones & Deploy

  • Estructura: /srv/projects/<slug>/ con venv/, .env, run.py, deploy/.
  • Slugs: dominio-subdominio. Puertos apps 8101–8199; infra ≥ 9000.
  • run.py autodetecta: wsgi:app (gunicorn), main:app (uvicorn/FastAPI) o app:app (Flask). Fallback expone /health.
  • systemd: /etc/systemd/system/<slug>.service, ExecStart usa venv/bin/python run.py.
  • Caddy: vhosts con snippet (tls_cf) (DNS-01 Cloudflare) y health.
  • Monitor: infra.illanes00.cl + /status.json.

Runbook de deploy rápido

# crear proyecto
sudo mkdir -p /srv/projects/<slug> && cd /srv/projects/<slug>
python3 -m venv venv
echo PORT=<puerto> > .env
venv/bin/pip install --upgrade pip gunicorn uvicorn fastapi flask python-dotenv

# run.py estándar (módulos → respeta venv)
cat > run.py <<'PY'
#!/usr/bin/env python3
import os, sys, importlib, subprocess
from pathlib import Path
try:
  from dotenv import load_dotenv; load_dotenv(".env")
except Exception:
  pass
HOST=os.getenv("HOST","127.0.0.1"); PORT=os.getenv("PORT","8101")
exists=lambda p: Path(p).exists()
if exists("wsgi.py"):
  sys.exit(subprocess.call([sys.executable,"-m","gunicorn",f"--bind={HOST}:{PORT}","wsgi:app"]))
if exists("app/main.py") or exists("main.py"):
  target="app.main:app" if exists("app/main.py") else "main:app"
  try:
    importlib.import_module(target.split(":")[0])
    sys.exit(subprocess.call([sys.executable,"-m","uvicorn",target,"--host",HOST,"--port",PORT]))
  except Exception: pass
if exists("app.py"):
  try:
    appmod=importlib.import_module("app")
    if getattr(appmod,"app",None) is not None:
      sys.exit(subprocess.call([sys.executable,"-m","gunicorn",f"--bind={HOST}:{PORT}","app:app"]))
  except Exception: pass
from flask import Flask
app=Flask(__name__)
@app.get("/health")
def h(): return {"ok":True}
if __name__=="__main__": app.run(host=HOST, port=int(PORT))
PY
chmod +x run.py

# systemd
sudo tee /etc/systemd/system/<slug>.service >/dev/null <<'UNIT'
[Unit]
Description=%i service (run.py)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=%i
WorkingDirectory=/srv/projects/%i
EnvironmentFile=-/srv/projects/%i/.env
ExecStart=/srv/projects/%i/venv/bin/python /srv/projects/%i/run.py
Restart=on-failure
StartLimitIntervalSec=120
StartLimitBurst=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable --now <slug>

# Caddy vhost (ejemplo)
sudo tee /etc/caddy/sites.d/<slug>.caddy >/dev/null <<'CADDY'
dominio.example.com {
  import tls_cf
  encode zstd gzip
  @health path /health
  handle_path /health* {
    reverse_proxy 127.0.0.1:8101
  }
  reverse_proxy 127.0.0.1:8101
}
CADDY
sudo caddy reload --config /etc/caddy/Caddyfile

Crear un nuevo proyecto

  1. Elegir slug y puerto libre (8101–8199).
  2. Crear carpeta + venv + .env + run.py.
  3. Instalar deps y definir /health.
  4. Crear unit systemd + vhost Caddy.
  5. Smoke tests local y público.
sudo mkdir -p /srv/projects/<slug> && cd /srv/projects/<slug>
python3 -m venv venv
echo PORT=<puerto> > .env
venv/bin/pip install fastapi uvicorn gunicorn flask python-dotenv
# (pegar run.py estándar de Deploy)

Backups & Base de datos

  • PostgreSQL central (futuro PgBouncer).
  • Dumps diarios locales + (opcional) subida a S3.
  • Retención sugerida: 7 días locales + 30 en remoto.

Dump manual

PGPASSWORD=*** pg_dump -h localhost -U user dbname | gzip > /var/backups/dbname-$(date +%F).sql.gz

status.json

Documento JSON consumido por el infra-monitor para pintar el estado. Debe incluir cada servicio con systemd y HTTP (local/público) cuando aplique.

Observabilidad

  • Grafana: dashboards y alertas.
  • Prometheus: scraping de exporters.
  • Netdata: visión de nodo (CPU, RAM, IO).

Buenas prácticas

  • Todo servicio expone /health (200 JSON).
  • Etiquetas estándar en métricas: service, slug, env.
  • Alertas mínimas: 5xx > 1% 5min; down de systemd; latency p95 > umbral.

Debugging rápido

¿Qué falla? Comando Pista
Servicio down systemctl status <slug> Logs de run.py/gunicorn/uvicorn
HTTP down curl -I 127.0.0.1:PORT/health ¿Escucha el backend?
502 journalctl -u caddy -n 50 Proxy/backend mal
TLS caddy validate Token Cloudflare
Ports ss -ltnp | grep :PORT Conflictos

Troubleshooting

systemd en loop (restart storm)

sudo systemctl status <slug> -l --no-pager
sudo journalctl -u <slug> -n 200 --no-pager

Revisar StartLimit*, excepciones en run.py y que se usen módulos (-m) para gunicorn/uvicorn.

Gunicorn no encontrado

Usar sys.executable -m gunicorn (respeta venv) o instalar en el venv:

sudo -u <slug> /srv/projects/<slug>/venv/bin/pip install --upgrade gunicorn

Caddy v2

TLS via Cloudflare (DNS-01)

{ 
  acme_dns cloudflare {
    api_token {env.CLOUDFLARE_API_TOKEN}
  }
}
(import "includes/*.caddy")

Vhost típico

sub.dominio.tld {
  import tls_cf
  encode zstd gzip
  @health path /health
  handle_path /health* { reverse_proxy 127.0.0.1:PORT }
  reverse_proxy 127.0.0.1:PORT
}

Tips

  • Validar: sudo --preserve-env=CLOUDFLARE_API_TOKEN caddy validate --config /etc/caddy/Caddyfile
  • Logs: journalctl -u caddy -n 100 --no-pager
  • No duplicar el mismo host en más de un archivo (ambiguous site definition).

Admin & API central

  • api.illanes00.cl: FastAPI de inventario/health/systemd/DNS/cron/métricas. Rate limit con Redis.
  • admin.illanes00.cl: Dashboard (consumidor de la API). Protegeremos más adelante.

DB & pooling

DATABASE_URL=postgresql://app_user:***@localhost:5432/app_db
REDIS_URL=redis://localhost:6379/0

Descubrimiento OpenAPI

La API central indexa /openapi.json de cada servicio y expone catálogo unificado.

Autenticación

Estado actual: infraestructura lista, no habilitada.

Opciones

  • oauth2-proxy por subdominio (Google/GitHub/OIDC).
  • Authelia como IdP OIDC propio (MFA, grupos, políticas).
  • Mixto: Authelia (IdP) + oauth2-proxy en cada app.

oauth2-proxy (ejemplo Google)

oauth2-proxy \\
  --provider=google \\
  --client-id=... --client-secret=... \\
  --cookie-secret=... \\
  --upstream=http://127.0.0.1:PORT_APP \\
  --redirect-url=https://DOMINIO/oauth2/callback