diff --git a/.github/workflows/vps-deploy.yml b/.github/workflows/vps-deploy.yml new file mode 100644 index 0000000..dc863bb --- /dev/null +++ b/.github/workflows/vps-deploy.yml @@ -0,0 +1,92 @@ +# Production deploy: build the image in CI, publish to GHCR, then SSH into the +# Hetzner VPS to pull + restart. The VPS never builds — it only pulls the image +# that passed CI. Rollback = re-run this workflow from an older commit, or +# `docker compose pull` a previous sha-tag on the VPS. +# +# Secrets: VPS_SSH_KEY (dedicated deploy key), VPS_HOST, VPS_USER. + +name: VPS Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: vps-deploy + cancel-in-progress: false + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build-push: + name: Build & push to GHCR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest + type=sha,format=long + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to VPS + needs: build-push + runs-on: ubuntu-latest + steps: + - name: Setup SSH + run: | + mkdir -p ~/.ssh + printf '%s' "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Pull image and restart app + run: | + ssh -i ~/.ssh/deploy_key "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" bash -s <<'EOF' + set -euo pipefail + cd /root/projects/arbpulse + git fetch origin main + git checkout main + git reset --hard origin/main + cd deploy + docker compose pull app + docker compose up -d app + echo "waiting for health..." + for i in $(seq 1 12); do + if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then + echo "app healthy" + docker image prune -f >/dev/null + exit 0 + fi + sleep 5 + done + echo "app did not become healthy in time" >&2 + docker compose logs --tail 50 app >&2 + exit 1 + EOF diff --git a/README.md b/README.md index 3621dd2..69b323e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Capturas completas (scroll): [`dashboard-live.png`](docs/screenshots/dashboard-l | Feeds | WebSocket nativo (`ws`) — APIs públicas | | Frontend | React 18, Vite, Tailwind CSS v3 | | Estado | In-memory (sin base de datos) | -| Deploy | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) | +| Deploy | VPS Hetzner — Docker Compose + GHCR (CI/CD en `vps-deploy.yml`); Fly.io como alternativa manual | No se requieren API keys: los feeds de mercado son públicos. @@ -308,21 +308,19 @@ Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolution --- -## Deploy en Fly.io +## Deploy (producción: VPS Hetzner + GHCR) -El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`. +Producción corre en una **VPS de Hetzner** (`https://arbpulse.wayool.com`) con Docker Compose detrás de nginx. El deploy es **automático**: -1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`. -2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`. -3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX. -4. Fly expone el servicio en el puerto interno **8080** con healthcheck `GET /api/health`. -5. Variables de entorno: opcionales; `fly.toml` incluye defaults razonables (`STALE_MS`, inventario inicial, etc.). -6. **CI/CD:** cada push a `main` dispara deploy automático vía GitHub Actions (`fly-deploy.yml`; requiere secret `FLY_API_TOKEN`). Los PRs pasan `CI / quality` (typecheck, test, build) sin desplegar. -7. Abre la URL pública: badge **LIVE**, cuatro venues en la price matrix, y —en mercado real— sobre todo oportunidades rechazadas por fees (esperado). +1. Push/merge a `main` dispara `.github/workflows/vps-deploy.yml`. +2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-`). +3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`). -> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses. +Detalles, bootstrap manual y rollback: [`deploy/README.md`](deploy/README.md). -Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls. +> Fly.io queda como opción alternativa manual (`fly-deploy.yml` vía workflow_dispatch, requiere `FLY_API_TOKEN`; región recomendada `sin`). `railway.json` es un artefacto legacy; no lo uses. + +Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls. --- diff --git a/deploy/README.md b/deploy/README.md index beb6dc5..9597f46 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -2,7 +2,29 @@ Despliegue con Docker Compose para correr 24/7 desde la rama `main`. El contenedor `app` (Node + tsx) queda en `127.0.0.1:8080` y un **reverse proxy del host** expone -HTTPS. Dos modos: +HTTPS. + +## Deploy automático (CI/CD con GHCR) — flujo normal + +Cada push/merge a `main` dispara `.github/workflows/vps-deploy.yml`: + +1. **Build en GitHub Actions** → imagen publicada en `ghcr.io/mauricioabh/arbpulse` + con tags `latest` + `sha-` (la VPS nunca buildea). +2. **Deploy por SSH** → en la VPS: `git reset --hard origin/main` (en + `/root/projects/arbpulse`), `docker compose pull app`, `docker compose up -d app` + y espera del health check. + +Secrets del repo: `VPS_SSH_KEY` (deploy key dedicada, solo para esto), +`VPS_HOST`, `VPS_USER`. Rollback: re-ejecutar el workflow desde un commit +anterior (`workflow_dispatch`) o en la VPS hacer `docker compose pull` de un +tag `sha-` previo. + +> No editar archivos del repo directamente en la VPS: el deploy hace +> `git reset --hard` y los pisará. La config local vive solo en `deploy/.env` +> (no trackeado). + +Lo que sigue abajo es el **camino manual/bootstrap** (primera instalación o +fallback). Dos modos: - **Opción A — detrás de nginx existente (recomendado en este VPS).** El servidor ya corre nginx en 80/443 con certbot para otras apps (p. ej. `consumet.wayool.com`, @@ -36,13 +58,13 @@ curl -fsSL https://raw.githubusercontent.com/mauricioabh/arbpulse/main/deploy/de bash /tmp/arbpulse-deploy.sh # 2) Editar el .env (DOMAIN + secretos opcionales) -nano /opt/arbpulse/deploy/.env # DOMAIN=arbpulse.wayool.com ; SENTRY_TRACING=false ; ... +nano /root/projects/arbpulse/deploy/.env # DOMAIN=arbpulse.wayool.com ; SENTRY_TRACING=false ; ... # 3) Volver a correr: build + up del contenedor app (127.0.0.1:8080) bash /tmp/arbpulse-deploy.sh # 4) Instalar el vhost de nginx y emitir el cert -sudo cp /opt/arbpulse/deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com +sudo cp /root/projects/arbpulse/deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx sudo certbot --nginx -d arbpulse.wayool.com @@ -57,7 +79,7 @@ server TLS que genera, así que los ajustes SSE se mantienen en HTTPS. Solo si nada más usa 80/443: ```bash -cd /opt/arbpulse/deploy +cd /root/projects/arbpulse/deploy cp .env.vps.example .env && nano .env # DOMAIN=arbpulse.wayool.com ... WITH_CADDY=1 bash /tmp/arbpulse-deploy.sh # o: docker compose --profile caddy up -d --build ``` @@ -67,7 +89,7 @@ WITH_CADDY=1 bash /tmp/arbpulse-deploy.sh # o: docker compose --profile ca ## Verificar ```bash -docker compose -f /opt/arbpulse/deploy/docker-compose.yml ps # app healthy/running +docker compose -f /root/projects/arbpulse/deploy/docker-compose.yml ps # app healthy/running curl -s http://127.0.0.1:8080/api/health # local curl -s https://arbpulse.wayool.com/api/health # público (HTTPS) ``` @@ -90,11 +112,12 @@ Ver `.env.vps.example`. Claves: ## Operación ```bash -cd /opt/arbpulse/deploy +cd /root/projects/arbpulse/deploy docker compose logs -f app # logs de la app docker compose restart app # reiniciar docker compose down # detener (app; agrega --profile caddy si aplica) -bash /tmp/arbpulse-deploy.sh # actualizar (git pull main + rebuild) +bash /tmp/arbpulse-deploy.sh # actualizar manualmente (git pull main + pull GHCR) +BUILD=1 bash /tmp/arbpulse-deploy.sh # fallback: build local en la VPS ``` ## Notas diff --git a/deploy/deploy.sh b/deploy/deploy.sh index cb75288..aff1864 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash # Idempotent VPS deploy for Arb Pulse (Ubuntu/Debian). # Safe to re-run: installs Docker if missing, clones or fast-forwards the repo, -# then (re)builds and restarts the containers. On first run it creates .env from -# the example and stops so you can fill in DOMAIN + secrets. +# pulls the CI-built image from GHCR (or builds locally with BUILD=1) and +# restarts the containers. On first run it creates .env from the example and +# stops so you can fill in DOMAIN + secrets. +# +# NOTE: normal production deploys are automatic — push/merge to `main` triggers +# .github/workflows/vps-deploy.yml (build in CI -> GHCR -> SSH pull + restart). +# This script is the manual/bootstrap path. # # Default: runs ONLY the app on 127.0.0.1:8080 (put it behind your host reverse # proxy — see deploy/nginx/). Set WITH_CADDY=1 to also start the bundled Caddy on @@ -10,9 +15,11 @@ set -euo pipefail REPO_URL="${REPO_URL:-https://github.com/mauricioabh/arbpulse.git}" -APP_DIR="${APP_DIR:-/opt/arbpulse}" +APP_DIR="${APP_DIR:-/root/projects/arbpulse}" BRANCH="${BRANCH:-main}" WITH_CADDY="${WITH_CADDY:-0}" +# BUILD=1 builds the image locally instead of pulling from GHCR (fallback). +BUILD="${BUILD:-0}" echo "==> Arb Pulse VPS deploy (branch: $BRANCH, dir: $APP_DIR)" @@ -52,13 +59,22 @@ if [ ! -f .env ]; then exit 0 fi -# 4) Build + run -if [ "$WITH_CADDY" = "1" ]; then - echo "==> Building and starting app + bundled Caddy (ports 80/443)..." - docker compose --profile caddy up -d --build +# 4) Image: pull from GHCR (default) or build locally (BUILD=1) +if [ "$BUILD" = "1" ]; then + echo "==> Building image locally..." + docker compose build app else - echo "==> Building and starting app only (127.0.0.1:8080)..." - docker compose up -d --build + echo "==> Pulling image from GHCR..." + docker compose pull app +fi + +# 5) Run +if [ "$WITH_CADDY" = "1" ]; then + echo "==> Starting app + bundled Caddy (ports 80/443)..." + docker compose --profile caddy up -d +else + echo "==> Starting app only (127.0.0.1:8080)..." + docker compose up -d app fi echo "==> Waiting for health..." diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b4cfeb4..60df6c1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -12,10 +12,13 @@ services: app: + # Normal flow: the image is built by CI (.github/workflows/vps-deploy.yml) + # and pulled from GHCR. `build:` remains for local/manual builds only + # (docker compose build app). build: context: .. dockerfile: Dockerfile - image: arbpulse:latest + image: ghcr.io/mauricioabh/arbpulse:latest container_name: arbpulse restart: unless-stopped env_file: