GitHub envía PR no confiables a entornos aislados; el deploy aprobado y la CI usan Runner separados en el mismo VPS con límites systemd.

Diagrama basado en la infraestructura real de dos Runner, Jobs históricos y configuración systemd revisada. La animación primero demuestra el enrutamiento de tareas y los límites de recursos, y después conserva solo un resplandor de conexión de baja frecuencia; cuando está activado el ajuste del sistema “reducir movimiento”, se muestra un diagrama de arquitectura estático.

Cuando la CI corre mucho en un repositorio privado, los minutos gratuitos del plan de GitHub Actions pueden convertirse rápidamente en un número que hay que vigilar. Si ya tienes un VPS que permanece encendido todo el día, ¿puedes dejar que GitHub siga gestionando los workflows mientras entregas los builds y las pruebas reales a esa máquina?

Sí. Dos workflows que mantengo ya han entregado parte de sus tareas deterministas al mismo VPS: un runner se encarga de los builds y despliegues del blog, y otro corre las verificaciones de las herramientas de ingeniería. GitHub se encarga de disparar y registrar; el cómputo ocurre en mi propio servidor. GitHub actualmente no cobra minutos de cómputo por los trabajos de Self-hosted Runner; pero eso no significa que el VPS sea gratis — ni que debas lanzar todos los PR a la máquina de producción.

Sobre si hacerlo, yo empezaría con tres preguntas:

  • ¿Los minutos gratuitos se están agotando de verdad? Si todavía sobran de sobra, la comodidad de GitHub-hosted puede valer más que montarlo uno mismo.
  • ¿El VPS ya necesita estar en línea todo el día? Reutilizar capacidad existente es una cuenta distinta de alquilar una máquina nueva solo para CI.
  • ¿Hasta qué punto es confiable el código a ejecutar? Las tareas fijas en una rama main protegida pueden considerarse para la infraestructura propia; los PR no confiables van primero a un entorno de ejecución aislado.

Las dos primeras preguntas deciden si el esfuerzo merece la pena; la tercera decide si por ahorrar unos minutos acabas sacrificando también el entorno de producción.

Con Self-hosted, lo gratis son los minutos de cómputo, no toda la factura

La documentación de facturación de GitHub Actions dice actualmente: los trabajos de Self-hosted Runner son gratuitos; los runners estándar GitHub-hosted en repositorios privados primero consumen los minutos incluidos en el plan, y el exceso se cobra. Según las cuotas oficiales actuales, GitHub Free incluye 2000 minutos al mes, y Pro y Team 3000 minutos — la cifra exacta sigue dependiendo de la cuenta del propietario del repositorio. Tomando como ejemplo un runner estándar Linux x64 de 2 vCPU, el precio oficial de lista es de $0.006 por minuto, y los trabajos alojados redondean hacia arriba las fracciones de minuto.

Esto hay que dividirlo en tres partidas:

CostoQué pasa tras mover a un VPS
Minutos de cómputo GitHub-hostedLos trabajos Self-hosted que se muevan dejan de consumir estos minutos alojados
Cómputo y mantenimiento del VPSCPU, memoria, disco, ancho de banda y costos de operación los asumes tú
Otros SKU de GitHubEl almacenamiento extra de Artifact/Cache y otros trabajos alojados se siguen facturando por separado

También hay un momento que es fácil malinterpretar por noticias viejas. GitHub anunció originalmente que cobraría una tarifa de plataforma de $0.002 por minuto para Self-hosted Runner, y luego actualizó el anuncio para retrasar su aplicación. El “cobro desde marzo de 2026” de un artículo viejo no puede tomarse como regla vigente; a la inversa, tampoco hay garantía de que no cambie nunca.

Mis tres trabajos históricos sirven para un buen hipotético: si hubieran corrido en runners alojados Linux estándar de 2 vCPU exactamente el mismo tiempo, redondearían a 9, 13 y 2 minutos, 24 minutos en total. Solo si todos esos minutos fueran excesos facturados, el precio de referencia de lista sería $0.144. Eso es aritmética contra precios de lista, no un ahorro real; la factura y el uso incluido en el plan del mismo ciclo deben comprobarse en el Usage de tu cuenta.

Un VPS, dos runners: no basta con cambiar runs-on

Un Self-hosted Runner es un proceso que corre en tu propia máquina y se conecta activamente a GitHub para recoger trabajos — no es GitHub entrando al VPS por SSH. La documentación oficial de runners explica el mecanismo completo.

De mis registros existentes de trabajos de GitHub Actions saqué tres ejecuciones exitosas:

Tarea realRol del runnerResultadoIntervalo entre inicio y fin del log del job
Build del blog y despliegue a producciónRunner de producciónSuccess8 min 32 s
Validación de contenido de un PR del blog (3er intento)El mismo runner de producciónSuccess12 min 12 s
Verificaciones de seguridad y contratos de herramientas de ingenieríaRunner de CI independienteSuccess1 min 27 s

Los dos runners tienen identidades distintas en GitHub, y los logs de los trabajos apuntan al mismo VPS. Esto al menos demuestra que dos repositorios consumen de verdad capacidad de cómputo propia, no solo un YAML bien escrito. Los tiempos de la tabla son los intervalos de los logs de trabajos históricos — sirven para cotejar registros de ejecución; las preguntas de capacidad siguen necesitando límites de recursos y disciplina operativa.

En un VPS no muy potente, el problema más frecuente es más bien la contención de recursos: la CI arranca un build grande y el servicio de producción, siempre encendido, se vuelve de repente lento. Por eso trato por separado las identidades de los runners, los límites del host y la admisión de workflows.

Dos etiquetas de runner no bastan: systemd es quien realmente limita los recursos

Cada uno de los dos repositorios puede crear su propio runner a nivel de repositorio en Settings → Actions → Runners, tomando de la página actual de GitHub la URL de descarga, el checksum y el token de registro de corta duración. Producción y CI corren con cuentas Linux no root distintas, directorios de trabajo independientes y privilegios mínimos, y reciben etiquetas propias, por ejemplo:

# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]

# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]

Las etiquetas solo eligen quién corre, no aíslan. Si ambos runners pueden leer los secretos de producción, o ambos tienen sudo sin restricciones o acceso al socket de Docker, nombres distintos no protegen nada en el VPS.

El script de instalación que escribí para el runner de CI de baja prioridad contiene en realidad este drop-in de servicio de systemd. Este es el conjunto inicial de límites que uso en un VPS compartido pequeño de 2 vCPU y unos 2.4 GiB de memoria:

[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10

Aquí hay un detalle fácil de malinterpretar. Según la documentación de control de recursos de systemd, CPUQuota=75% significa el 75 % del tiempo de CPU de un solo núcleo, no el 75 % de todo el VPS de dos núcleos. MemoryHigh=700M es un umbral blando a partir del cual aumenta la presión de recuperación de memoria; MemoryMax=1050M es un límite duro — superarlo puede provocar OOM y hacer fallar el job de CI. Nice=10 y OOMScoreAdjust=400 hacen que este CI tienda más a ceder ante los servicios críticos, pero no pueden prometer que la producción nunca se verá afectada.

El requisito previo es un host con cgroup v2 con controladores de memoria y una versión de systemd acorde. Ejecuta primero test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version para confirmar la jerarquía unificada, el controlador de memoria y la versión de systemd; no lo copies tal cual en un entorno antiguo y des por hecho que los límites surtieron efecto.

El ./config.sh del runner solo registra; no crea automáticamente un servicio de systemd. Siguiendo los pasos oficiales de servicio Linux de GitHub, entra en cada directorio de instalación registrado de los runners de producción y CI, y con privilegios de administrador ejecuta install <usuario dedicado> en cada svc.sh, y luego start. Por ejemplo, ejecútalo dentro del propio directorio de instalación del runner de CI (registrado, con svc.sh presente):

sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status

ci-runner es una cuenta de ejemplo — no reutilices la cuenta de producción; si ya hay un servicio en ejecución, no hagas install dos veces. Después busca la unit de CI real y establece los límites en una ventana de mantenimiento:

systemctl list-unit-files 'actions.runner.*.service'

# 替换为上一行显示的真实 CI unit,切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax

restart interrumpirá el job que esté ejecutando este runner — primero confirma que esté inactivo, y no reinicies por error el runner de producción. Tras reiniciar, confirma en la página de Runners del repositorio que volvió a Online, y verifica los valores efectivos con systemctl show. Al revertir, elimina solo el drop-in de CI añadido esta vez — no borres de paso personalizaciones de servicio existentes. Los códigos de registro, nombres de host, cuentas de servicio y directorios de producción no necesitan ir en los workflows, ni deberían aparecer en un artículo público.

Revisa los recursos una vez más antes de que arranque el workflow

systemd puede poner límites al grupo de procesos de un servicio, pero no puede crear espacio libre en disco de la nada. Mi CI de ingeniería tiene además una puerta de admisión de recursos: lee la memoria disponible del host y el espacio libre del sistema de archivos del directorio de trabajo. Abajo va una receta Node.js reducida para una rama main confiable:

name: Trusted main validation
on:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: trusted-main-validation
  cancel-in-progress: false

jobs:
  test:
    runs-on: [self-hosted, linux, x64, ci-lab]
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - name: Check available resources
        shell: bash
        run: |
          set -euo pipefail
          mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
          disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
          echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
          if [ "$mem_kib" -lt 600000 ]; then
            echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
            exit 1
          fi
          if [ "$disk_kib" -lt 5242880 ]; then
            echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
            exit 1
          fi
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npm test

Este ejemplo usa actions/checkout@v7 y actions/setup-node@v7; usan Node.js 24 como runtime de la acción y requieren al menos la versión v2.327.1 de Actions Runner. Revisa la versión del runner antes de migrar máquinas existentes — no te limites a actualizar el YAML. checkout v7 y setup-node v7 tienen las notas correspondientes.

Unos 586 MiB de memoria disponible y 5 GiB de disco libre son los umbrales de admisión de este pequeño VPS. El script imprime las cantidades restantes y el motivo del fallo, para distinguir fácilmente falta de memoria de falta de disco.

Este paso solo puede comprobar, no reservar. Los grupos concurrency de dos repositorios no son un bloqueo global entre repositorios. Mi elección es mantener la CI de ingeniería bajo los topes de CPU/memoria de su propio cgroup de systemd — fallando cuando falten recursos en lugar de crecer sin límite; el servicio de producción conserva sus propios permisos y límites de recursos. Para builds grandes, o casos donde los dos repositorios no deban ejecutarse nunca a la vez, la opción más fiable es pasar a una única cola de tareas controlada o a un VPS independiente, con un único planificador distribuyendo trabajo en serie; no confundas un concurrency homónimo entre repositorios con un mutex global. Si quieres verdadera serialización a nivel de host, puedes diseñar un ejecutor envoltorio flock revisado por separado — pero debe bloquear la entrada de todas las tareas intensivas en recursos; bloquear solo antes de un job no sirve; este enfoque no está desplegado en mi máquina. El costo es que las tareas hacen cola; no se puede mantener el rendimiento concurrente de ambos repositorios.

Si de verdad necesitas serializar los pasos pesados de los dos repositorios en el mismo host, puedes elegir una receta de bloqueo de archivo compartido revisada por separado. Primero, que un administrador cree un grupo de sistema solo para el bloqueo, añada a ambos usuarios runner de confianza al grupo, y configure el archivo de bloqueo compartido (solo para coordinar builds; no le des al grupo permisos sobre el directorio de producción):

sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock

Los nombres de cuenta aquí son marcadores de posición; tras añadir nuevos grupos conviene reiniciar los servicios runner afectados en una ventana de mantenimiento para que recojan los nuevos grupos suplementarios — los trabajos en ejecución no deben interrumpirse a la ligera. Luego ambos repositorios deben meter sus comandos intensivos en recursos dentro del alcance del mismo bloqueo:

flock -w 900 /var/lock/ci-queue/build.lock \
  bash -e -c 'npm ci && npm test'

Así el segundo participante espera como máximo 900 segundos y luego falla, en lugar de colgarse para siempre. Solo protege los comandos envueltos por el mismo bloqueo — no serializa automáticamente todos los pasos de un job de GitHub, ni puede impedir que un workflow no confiable se salte el bloqueo a propósito. También por eso, mis runners duales existentes no usan este esquema de bloqueo; es una receta replicable que necesita evaluación independiente, no un resultado medido de un montaje en producción.

No subas MemoryMax sin límite solo para poner un CI en verde; las tareas que superan crónicamente el margen pertenecen a una máquina separada.

Que los PR y producción compartan runner es otra cuenta de riesgos

Revisando los workflows existentes del blog, descubrí que la validación de contenido de PR y el despliegue a producción siguen usando las mismas etiquetas de runner de producción. En un repositorio privado con solo mantenedores de confianza puede ser tolerable por ahora — pero no debe enseñarse como tutorial de seguridad para colaboración abierta.

La guía de seguridad de GitHub advierte: los Self-hosted Runner persistentes que ejecutan código no confiable pueden dejar contaminación persistente entre trabajos, o permitir que los scripts toquen archivos y permisos del host. Si en el futuro colaboradores externos, PR de fork u otro código no confiable entran en la cadena de validación, deben separarse de la máquina de producción:

不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署

Esto significa que parte de la validación de PR puede seguir gastando minutos GitHub-hosted. Pero permitir que scripts desconocidos se acerquen a las credenciales de despliegue para ahorrar unos minutos es un mal intercambio. Incluso en un repositorio privado, hay que mirar los permisos reales de los colaboradores, la protección de ramas y quién puede modificar los workflows.

Runner caído o VPS con fallos: cómo recuperar el despliegue

El costo de los runners propios no es solo el alquiler mensual — también incluye caídas, actualizaciones y tiempo de recuperación. Una diferencia muy práctica: cuando un Self-hosted Runner se desconecta, los trabajos con sus etiquetas se encolan; no caen automáticamente a ubuntu-latest. La documentación de GitHub indica que un trabajo sin runner disponible espera hasta unas 24 horas antes de fallar.

El orden de recuperación puede ser sencillo: primero mira en la página de Runners del repositorio de GitHub si está Online, luego revisa el ./svc.sh status de la CI, journalctl -u <unit-real> -n 100 --no-pager y el espacio libre en disco; si hay un OOM de cgroup, primero reduce la carga o espera a que esté inactivo, en lugar de quitar el límite directamente. La aplicación del runner normalmente se autoactualiza, pero si desactivaste la autoactualización debes seguir las versiones tú mismo; según las reglas oficiales, un runner fuera de su ventana de actualización — o que carezca de una actualización de seguridad crítica — puede dejar de recibir trabajos.

El despliegue a producción debería tener un canal de recuperación separado y claramente definido: por ejemplo, un build GitHub-hosted aprobado por separado + paso de despliegue controlado tras un punto de entrada de publicación protegido, o reintentar tras restaurar el VPS por tu canal de operaciones habitual. Los runners GitHub-hosted por defecto no pueden acceder a los directorios locales del VPS ni conservar las credenciales originales de despliegue de producción, así que cambiar solo runs-on a ubuntu-latest no es una alternativa funcional. Hasta no haberlo ensayado por separado, no describas esta reserva como una recuperación ante desastres automática verificada.

Si realmente se ahorra, se decide al final en Usage

Los Actions en verde solo prueban que los trabajos terminaron; no hacen tu contabilidad mensual. En Billing & licensing → Usage de GitHub, revisa el uso de Actions por ciclo de facturación; para el detalle, exporta un informe según el método oficial. Separa los minutos alojados por encima de la cuota, el almacenamiento de Artifact/Cache, el alquiler extra del VPS y los costos de mantenimiento.

Cuando miro este tipo de migraciones, mi orden es siempre: primero ver si los minutos del plan están realmente ajustados, luego ver si el VPS ya estaba siempre encendido, y por último contrastar la frontera de confianza del código ejecutado. Los builds estables, frecuentes y confiables en un VPS existente son buenos candidatos para Self-hosted; los proyectos con poca CI y minutos gratuitos sin usar quizá salgan mejor con GitHub-hosted.

El cálculo de costos admite tus propios números directamente: minutos alojados migrables por encima del plan al mes × precio unitario del SKU correspondiente − costo nuevo de VPS/operación. Por ejemplo, supón 1000 minutos todos como exceso de Linux 2-core estándar: precio de lista $6; si estaban dentro de la cuota gratuita, la factura marginal puede ser $0. Solo merece la pena mover las tareas confiables, mantenibles y recuperables.

El anterior recorrido práctico de ChatGPT Web + GitHub + Self-hosted Runner trataba del punto de entrada del desarrollo y la salida del despliegue; este responde a otra pregunta: cómo ahorrar minutos GitHub-hosted, y cómo evitar que varios runners en un VPS se entorpezcan entre sí.

Nota de tiempo: facturación y anuncios oficiales verificados al 19 de octubre de 2026; los tiempos de los trabajos provienen de registros de ejecución existentes, los valores de systemd de scripts de instalación existentes — no de nuevas pruebas de estrés del host.