feat(deploy): automatic VPS deploy via GHCR on push to main

Build the Docker image in GitHub Actions, publish to
ghcr.io/mauricioabh/arbpulse (latest + sha tags), then SSH into the
Hetzner VPS to docker compose pull + up -d with a health-check gate.
The VPS no longer builds images, keeping CPU/RAM free for the running
apps and making rollbacks a matter of pulling a previous sha tag.

- .github/workflows/vps-deploy.yml: build-push (GHCR) + deploy (SSH) jobs
- deploy/docker-compose.yml: app image now ghcr.io/mauricioabh/arbpulse
- deploy/deploy.sh: pulls from GHCR by default (BUILD=1 for local build),
  default APP_DIR aligned to /root/projects/arbpulse
- docs: deploy/README.md CI/CD section + paths, README deploy section

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mauricio Barragan
2026-07-19 12:23:49 -06:00
parent 4ffce9a59e
commit f6108d8f8f
5 changed files with 161 additions and 29 deletions
+30 -7
View File
@@ -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-<commit>` (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-<commit>` 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
+25 -9
View File
@@ -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..."
+4 -1
View File
@@ -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: