# Deployment Cerbero_mcp Guida operativa per il deploy della suite MCP su un VPS pubblico. L'architettura è: Gitea ospita codice + container registry; le immagini vengono buildate e pushate dalla **macchina di sviluppo** (laptop) verso il registry; il VPS produzione non builda nulla, fa solo pull dei container già pronti e usa Watchtower per il rollover automatico. ``` ┌──────────────────────────┐ ┌─────────────────────────┐ ┌──────────────────────────────────┐ │ Laptop dev │ │ Gitea git.tielogic.xyz │ │ VPS produzione │ │ │ │ │ │ cerbero-mcp.tielogic.xyz │ │ build-push.sh ──push──▶ │───▶│ ┌────────────────────┐ │ │ │ │ (8 image) │ │ │ Container registry │ │ │ ┌────────────────────────────┐ │ │ git push ─────────────▶ │───▶│ └────────────────────┘ │◀──┼──┤ docker compose │ │ │ │ │ ┌────────────────────┐ │ pull │ (docker-compose.prod.yml) │ │ │ │ │ │ Cerbero-mcp repo │ │ │ │ gateway, mcp-* │ │ │ │ │ └────────────────────┘ │ │ │ watchtower (poll 5min) │ │ │ │ │ │ │ └────────────────────────────┘ │ └──────────────────────────┘ └─────────────────────────┘ └──────────────────────────────────┘ ``` Niente CI/CD su Gitea — qualità e build sono responsabilità del laptop prima del push (lint/test in locale, poi `scripts/build-push.sh`). ## 1. Build & push image (dal laptop) Lo script `scripts/build-push.sh` builda e pusha le 8 image al registry Gitea, replicando il vecchio job CI ma in locale. Pre-requisiti: - `docker` + `buildx` sul laptop. - Personal Access Token Gitea con scope `write:package` (User Settings → Applications → Generate Token). ```bash export GITEA_PAT='' export GITEA_USER=adriano # Tutte le 8 image (base + gateway + 6 mcp-*) ./scripts/build-push.sh # Solo specifiche (es. dopo modifica a un singolo servizio) ./scripts/build-push.sh base mcp-bybit ``` Lo script: - Fa `docker login git.tielogic.xyz`. - Builda con `docker buildx build --push` (cache buildx locale del laptop, niente cache registry: build successivi rapidi senza pesare sul registry). - Tagga `:latest` + `:sha-`. - Per le mcp-* passa `BASE_IMAGE`/`BASE_TAG` come build-arg in modo da ereditare dall'image `base` appena pushata. Ordine consigliato: builda `base` prima delle `mcp-*` (lo script lo fa di default se chiamato senza argomenti). ## 1b. Quality gate locale (consigliato prima del push) Prima di `build-push.sh` esegui in locale i check che prima girava il CI: ```bash uv run ruff check services/ uv run mypy services/common/src/mcp_common uv run pytest services/ --tb=short docker compose -f docker-compose.prod.yml config -q ``` Tutti devono essere verdi prima di pushare image al registry. ## 2a. Topologia: standalone vs behind-Traefik Cerbero_mcp supporta due topologie di deploy: ### Standalone (Caddy gestisce TLS direttamente) ``` Internet ──[443]──► Caddy gateway ──► mcp-* services (ACME Let's Encrypt) ``` Setto: `docker-compose.prod.yml` da solo. Caddy bind sulle porte 80/443 host, fa cert auto via ACME. Adatto a un VPS dedicato senza altri servizi sulle 80/443. ### Behind-Traefik (Traefik termina TLS) ``` Internet ──[443]──► Traefik ──[traefik network]──► Caddy gateway ──► mcp-* services (TLS+ACME) (rate-limit, IP allowlist) ``` Setto: `docker-compose.prod.yml` + `docker-compose.traefik.yml` overlay. Caddy non bind su host, ascolta plain HTTP `:80` interno alla `traefik` network. Traefik fa routing per `Host(cerbero-mcp.tielogic.xyz)`, TLS, ACME. Adatto a VPS condiviso con altri servizi (Gitea, ecc.). ## 2. Deploy automatizzato (script no-clone) Il modo più rapido è `scripts/deploy-noclone.sh`, idempotente. Sul VPS **non** viene clonato il repo: lo script scarica via raw HTTP solo i file strettamente necessari al runtime (compose, Caddyfile, public assets). Esegui sul VPS: ```bash # Prerequisiti export GITEA_PAT="" export GITEA_USER=adriano # Crea la dir di deploy e mettici i secrets via scp dal posto sicuro sudo mkdir -p /docker/cerbero_mcp/secrets sudo chown -R "$USER" /docker/cerbero_mcp # scp deribit.json bybit.json hyperliquid.json alpaca.json \ # macro.json sentiment.json core.token observer.token \ # vps:/docker/cerbero_mcp/secrets/ # Behind Traefik (opzionale, solo se VPS condiviso con Gitea o altri) # export BEHIND_TRAEFIK=true # export TRAEFIK_NETWORK=gitea_traefik-public curl -sL -o /tmp/deploy-noclone.sh \ https://git.tielogic.xyz/Adriano/Cerbero-mcp/raw/branch/main/scripts/deploy-noclone.sh chmod +x /tmp/deploy-noclone.sh /tmp/deploy-noclone.sh ``` Lo script esegue: docker login registry → scarica `docker-compose.prod.yml`, `docker-compose.traefik.yml`, `gateway/Caddyfile`, `gateway/public/*` in `/docker/cerbero_mcp/` → chmod 600 sui secrets → genera `.env` iniziale (testnet) → crea `/var/log/cerbero-mcp` con permessi `1000:1000` → pull image dal registry → `docker compose up -d` → smoke test pubblico. Per aggiornare in seguito: ri-esegui lo stesso script (preserva `.env` e secrets, ricarica config dal branch `main` aggiornato). **Override paths**: `DEPLOY_DIR` (default `/docker/cerbero_mcp`), `SECRETS_SRC` (default `$DEPLOY_DIR/secrets`), `AUDIT_LOG_DIR` (default `/var/log/cerbero-mcp`). ### Modalità behind-Traefik Se sul VPS gira già un Traefik (es. lo stesso VPS di Gitea), prima di lanciare lo script aggiungi al tuo `.env`: ```bash BEHIND_TRAEFIK=true TRAEFIK_NETWORK=gitea_traefik-public # nome network esterna di Traefik TRAEFIK_CERTRESOLVER=letsencrypt # nome resolver in Traefik TRAEFIK_ENTRYPOINT=websecure # entrypoint HTTPS Traefik # Porte gateway non più necessarie (Traefik bind 80/443): # GATEWAY_HTTP_PORT, GATEWAY_HTTPS_PORT non vengono usate. ``` Lo script rileva `BEHIND_TRAEFIK=true` e usa `docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml`. Il gateway Caddy NON bind su 80/443 host; viene esposto via Traefik con labels per `Host(cerbero-mcp.tielogic.xyz)`. Verifica della network Traefik: ```bash docker network ls | grep -i traefik # Tipicamente vedrai: gitea_traefik-public, traefik_default, ecc. # Usa il nome ESATTO come TRAEFIK_NETWORK in .env. ``` ## 3. Safety: switch testnet → mainnet `mcp_common.environment.consistency_check` (richiamato dal boot `run_exchange_main`) PREVIENE switch accidentali: - Se l'ambiente risolto è **mainnet** ma il secret JSON corrispondente non contiene `"environment": "mainnet"` esplicito → boot abort con `EnvironmentMismatchError`. - Se il secret dichiara un environment diverso da quello risolto (es. `creds["environment"]="mainnet"` ma env var setta testnet) → boot abort. **Per passare a mainnet su un exchange specifico** (es. bybit): 1. Edita `secrets/bybit.json`: aggiungi `"environment": "mainnet"`. 2. Modifica `.env`: `BYBIT_TESTNET=false`. 3. `docker compose -f docker-compose.prod.yml --env-file .env restart mcp-bybit`. Senza il flag esplicito nel secret, il container mcp-bybit fallirà al boot e Watchtower NON aggiornerà su versioni con cred mainnet rotti. Override `STRICT_MAINNET=false` in `.env` permette mainnet senza la conferma esplicita (downgrade safety, sconsigliato in produzione). ## 4. Audit log persistente Tutti i write endpoint (`place_order`, `place_combo_order`, `cancel_*`, `set_*`, `close_*`, `transfer_*`, `amend_*`, `switch_*`) emettono un record JSON strutturato sul logger `mcp.audit`. **Sink**: - stdout/stderr container (sempre, visibile via `docker logs`). - File JSONL persistente su volume host: `${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}/.audit.jsonl`. Rotation a mezzanotte UTC con retention `AUDIT_LOG_BACKUP_DAYS` (default 30 giorni). **Esempio record**: ```json { "audit_event": "write_op", "action": "place_order", "exchange": "bybit", "principal": "core", "target": "BTCUSDT", "payload": {"side": "Buy", "qty": 0.01, "price": 60000, "leverage": 3}, "result": {"order_id": "abc123", "status": "submitted"} } ``` **Query operative**: ```bash # Tutto l'audit log oggi tail -f /var/log/cerbero-mcp/*.audit.jsonl # Solo place_order su bybit jq -c 'select(.action=="place_order" and .exchange=="bybit")' \ /var/log/cerbero-mcp/bybit.audit.jsonl # Errori jq -c 'select(.error)' /var/log/cerbero-mcp/*.audit.jsonl # Operazioni di un principal jq -c 'select(.principal=="core")' /var/log/cerbero-mcp/*.audit.jsonl ``` I secret (api_key, password) sono filtrati automaticamente da `SecretsFilter` prima di arrivare al sink. ## 5. Setup iniziale del VPS (manuale, alternativa allo script) **Pre-requisiti**: Docker Engine ≥ 24, `docker compose` plugin, accesso SSH sudo, dominio DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS, porte 80 e 443 aperte sul firewall (per ACME challenge + traffico HTTPS). ### a) Login al registry Gitea Crea un Personal Access Token su Gitea (`Settings → Applications → Generate new token`) con scope `read:package`. Quindi sul VPS: ```bash echo "$GITEA_PAT" | docker login git.tielogic.xyz -u --password-stdin ``` Le credenziali vengono salvate in `~/.docker/config.json`. Watchtower lo bind-monta in sola lettura per fare i pull autenticati. ### b) Crea dir di deploy e scarica i file di config Sul VPS NON serve clonare il repo. Bastano i file di compose, il `Caddyfile` e i public assets del gateway: ```bash sudo mkdir -p /docker/cerbero_mcp/{secrets,gateway/public} sudo chown -R "$USER" /docker/cerbero_mcp cd /docker/cerbero_mcp BASE=https://git.tielogic.xyz/Adriano/Cerbero-mcp/raw/branch/main curl -fsSL -o docker-compose.prod.yml $BASE/docker-compose.prod.yml curl -fsSL -o docker-compose.traefik.yml $BASE/docker-compose.traefik.yml curl -fsSL -o gateway/Caddyfile $BASE/gateway/Caddyfile curl -fsSL -o gateway/public/index.html $BASE/gateway/public/index.html curl -fsSL -o gateway/public/status.js $BASE/gateway/public/status.js curl -fsSL -o gateway/public/style.css $BASE/gateway/public/style.css ``` Il VPS NON ha bisogno di buildare; usa `docker-compose.prod.yml` che fa solo pull dal registry. ### c) Prepara secrets ```bash mkdir -p secrets # Copia (via scp) i file JSON con cred reali: # secrets/deribit.json, bybit.json, alpaca.json, hyperliquid.json, # secrets/macro.json, sentiment.json # secrets/core.token, observer.token chmod 600 secrets/* ``` ### d) `.env` con configurazione runtime Crea `/docker/cerbero_mcp/.env`: ```bash # Gateway ACME_EMAIL=adrianodalpastro@tielogic.com GATEWAY_HTTP_PORT=80 GATEWAY_HTTPS_PORT=443 WRITE_ALLOWLIST="127.0.0.1/32 ::1/128 172.16.0.0/12" # Image tag — `latest` per auto-update Watchtower, oppure pin a sha-XXXXXXX IMAGE_TAG=latest IMAGE_PREFIX=git.tielogic.xyz/adriano/cerbero-mcp # Environment exchange (true=testnet, false=mainnet) DERIBIT_TESTNET=true BYBIT_TESTNET=true HYPERLIQUID_TESTNET=true ALPACA_PAPER=true # Watchtower polling interval (sec). 300=5min default. WATCHTOWER_POLL_INTERVAL=300 ``` ### e) Avvio ```bash docker compose -f docker-compose.prod.yml --env-file .env pull docker compose -f docker-compose.prod.yml --env-file .env up -d docker compose -f docker-compose.prod.yml logs -f gateway ``` Caddy chiede automaticamente il certificato Let's Encrypt al primo contatto su `https://cerbero-mcp.tielogic.xyz`. ## 6. Auto-update via Watchtower Watchtower (servizio `watchtower` nel compose) polla il registry ogni `WATCHTOWER_POLL_INTERVAL` secondi. Se trova un nuovo digest dietro al tag `:latest` di un container etichettato `com.centurylinklabs.watchtower.enable=true`, fa: 1. `docker pull` della nuova image 2. `docker stop` graceful del container vecchio 3. `docker rm` + start del nuovo container con stessa config + secret + volumi 4. Cleanup image vecchia (`WATCHTOWER_CLEANUP=true`) I container con label sono: `gateway`, `mcp-deribit`, `mcp-bybit`, `mcp-hyperliquid`, `mcp-alpaca`, `mcp-macro`, `mcp-sentiment`. Il container `watchtower` stesso non si auto-aggiorna (per evitare loop). ### Disabilitare auto-update temporaneamente Pin a uno SHA specifico nel `.env`: ```bash IMAGE_TAG=sha-6b7b3f7 docker compose -f docker-compose.prod.yml --env-file .env up -d ``` In questo modo `:latest` non viene più seguito; per riattivare il rollover automatico ripristina `IMAGE_TAG=latest`. ### Disabilitare auto-update per un singolo servizio Rimuovi la label `com.centurylinklabs.watchtower.enable=true` per quel servizio nel compose (oppure imposta `=false`). Watchtower lo ignora ma continua a tenere aggiornati gli altri. ## 7. Rollback ```bash # Trova lo SHA della versione precedente docker images "git.tielogic.xyz/adriano/cerbero-mcp/*" --format "{{.Tag}}" # Pin nel .env IMAGE_TAG=sha-XXXXXXX docker compose -f docker-compose.prod.yml --env-file .env up -d ``` Watchtower NON downgraderà perché il digest del tag pin corrisponde a quello locale. ## 8. Smoke test post-deploy ```bash # Da fuori VPS (laptop) curl -s https://cerbero-mcp.tielogic.xyz/mcp-macro/health # {"status":"ok",...} # Test write endpoint allowlist (deve rispondere 403 da IP esterno): curl -X POST https://cerbero-mcp.tielogic.xyz/mcp-deribit/tools/place_order \ -H "Authorization: Bearer $(cat secrets/core.token)" \ -d '{"instrument_name":"BTC-PERPETUAL","side":"buy","amount":1}' # 403 forbidden: source ip not in allowlist ← OK # Sul VPS: GATEWAY=http://localhost bash tests/smoke/run.sh ``` ## 9. Sicurezza VPS - Firewall `ufw`: `allow 22, 80, 443`. Tutto il resto deny in. - `fail2ban` su SSH e (opz) sul log Caddy 401. - Secret rotation manuale: aggiorna i file `secrets/*.token` → `docker compose restart` (i token vengono ricaricati al boot di ogni servizio MCP). - Audit log in `docker compose logs | grep audit_event` — per produzione meglio redirezionare a syslog o a un servizio dedicato. ## 10. Note Traefik / reverse proxy davanti a Gitea Gitea è esposto via Traefik (ROOT_URL `https://git.tielogic.xyz`). Per il push di image Docker il reverse proxy deve consentire upload di body grossi (un singolo layer può superare i 100MB). Traefik default va bene, ma se vedi `413 Request Entity Too Large` durante `docker push` aumenta il limite nel middleware: ```yaml # traefik dynamic config http: middlewares: gitea-upload: buffering: maxRequestBodyBytes: 524288000 # 500MB ``` Applicalo come middleware al router Gitea. ## 11. Aggiornamento del compose stesso (file YAML) Watchtower aggiorna le **image**, non `docker-compose.prod.yml` né `Caddyfile`. Se cambi struttura (nuovi servizi, nuove env var, modifiche al gateway), ri-esegui sul VPS lo script no-clone, che ri-scarica i file di config dal branch `main` di Gitea e applica: ```bash /tmp/deploy-noclone.sh ``` Lo script è idempotente: preserva `.env` e `secrets/`, aggiorna solo i file di config + fa `pull` + `up -d`.