CI/CD en tu propio VPS: GitHub Actions self-hosted runner
TL;DR
GitHub Actions self-hosted runner permite ejecutar tareas de CI/CD de GitHub Actions en tu propio VPS: crear imágenes Docker, ejecutar pruebas, desplegar aplicaciones y trabajar con recursos no disponibles en GitHub-hosted runners. En esta guía se configurará un runner Linux seguro en Ubuntu 24.04 LTS con Docker, systemd, firewall, actualizaciones automáticas y copias de seguridad de la configuración.
- El runner funciona como agente: se conecta por sí mismo a GitHub mediante HTTPS, por lo que no es necesario abrir puertos entrantes para él.
- Para un repositorio privado, normalmente basta con un VPS de 2 vCPU, 4 GB de RAM y 40–60 GB de SSD.
- Docker permite ejecutar job en contenedores, crear imágenes y usar Docker Compose en workflow.
- Un self-hosted runner no debe conectarse sin precaución a repositorios públicos con pull request de usuarios externos.
- El runner se ejecuta mediante systemd y se inicia automáticamente después de reiniciar el VPS.
- Para el despliegue en production es mejor usar un runner independiente, un usuario Linux independiente y GitHub Secrets restringidos.
Qué configuramos y para qué
GitHub Actions es el sistema de CI/CD integrado de GitHub. Normalmente, los workflow se ejecutan en la infraestructura de GitHub: para cada job se crea una máquina virtual temporal con Linux, Windows o macOS. Esto es conveniente, pero este enfoque tiene limitaciones: límites de tiempo, coste por minutos, falta de acceso a la red interna, rendimiento fijo e imposibilidad de utilizar hardware específico.
Self-hosted runner es un agente instalado en tu VPS, servidor dedicated o máquina dentro de la red interna. Cuando un workflow en el repositorio contiene la condición runs-on: self-hosted, GitHub envía la tarea a un runner adecuado. El runner descarga el código fuente, ejecuta los comandos del workflow, envía los logs de vuelta a GitHub y espera la siguiente tarea.
Como resultado, obtendrás un servidor Linux que puede utilizarse para escenarios típicos de CI/CD:
- creación de imágenes Docker y publicación en GitHub Container Registry, Docker Hub o un registry privado;
- pruebas de proyectos Node.js, Python, Go, PHP, Java, Rust y otros;
- ejecución de linters, análisis estático y pruebas de integración;
- compilación del frontend y preparación de artefactos de release;
- despliegue mediante SSH, Docker Compose, Ansible o API del proveedor cloud;
- acceso a PostgreSQL privado, Redis, red VPN o API interna;
- uso de cachés de dependencias entre compilaciones;
- ejecución de tareas que consumen muchos recursos sin pagar cada minuto de GitHub-hosted runner.
Cómo funciona self-hosted runner
No es necesario abrir una interfaz web, recibir webhook ni publicar una API en el VPS. El agente establece por sí mismo una conexión TLS saliente con la infraestructura de GitHub a través del puerto 443/tcp. Por ello, el runner puede ubicarse detrás de NAT, en una red privada o en un servidor con un firewall entrante estricto.
Después del registro, GitHub proporciona al runner credenciales únicas. Estas se almacenan localmente en el directorio del runner. Al iniciarse, el agente se autentica, obtiene la lista de tareas disponibles y compara sus labels con los requisitos del workflow. Por ejemplo, un job con runs-on: [self-hosted, linux, x64, docker] no se enviará a un runner sin la etiqueta docker.
GitHub-hosted runner o self-hosted runner
| Criterio | GitHub-hosted runner | Self-hosted runner en un VPS |
|---|---|---|
| Mantenimiento | GitHub actualiza el entorno base | Tú eres responsable del SO, Docker, parches y seguridad |
| Coste | Minutos de Actions, especialmente perceptibles en repositorios privados | Coste fijo del VPS y del tráfico |
| Velocidad | Depende del plan de GitHub seleccionado | Depende de la CPU, RAM, NVMe y cachés de tu servidor |
| Red | Sin acceso a tu red privada sin soluciones adicionales | Se pueden conectar VPN, private network y servicios locales |
| Aislamiento | Una nueva VM temporal para la mayoría de los job | El estado del disco se conserva, se requiere aislamiento propio |
| Docker | Disponible, pero el entorno es desechable | Se puede utilizar un registry local, BuildKit y caché persistente |
Para un repositorio private pequeño, GitHub-hosted runner suele ser más sencillo: no hace falta administrar un servidor. Self-hosted runner se justifica si las compilaciones se ejecutan con regularidad, se necesitan cachés de Docker, acceso a una red cerrada, una IP fija propia, más espacio en disco o control sobre el entorno.
La principal limitación de seguridad
Un workflow es código. Cualquier comando del archivo .github/workflows/.yml se ejecuta en el runner con los permisos del usuario bajo el que funciona el agente. Si este usuario tiene permitido Docker, el workflow puede obtener efectivamente privilegios de nivel root en el VPS mediante Docker daemon.
No conectes un self-hosted runner permanente con acceso a secretos, red de production o Docker daemon a un repositorio público donde usuarios externos puedan crear pull request. Para código no confiable, utiliza GitHub-hosted runners, ephemeral runners aislados o máquinas virtuales independientes y desechables.
Esta guía presupone un repositorio private o un equipo con acceso de escritura controlado. Para el despliegue en production, se recomienda crear un runner independiente con la label production, que ejecute únicamente workflow protegidos desde una rama protegida y GitHub Environment.
Qué configuración de VPS se necesita para esta tarea
Los recursos de GitHub Actions self-hosted runner no los determina el propio agente, sino tus job. Un runner vacío consume poca memoria y casi no utiliza CPU. Sin embargo, Docker build, las pruebas, la compilación de TypeScript, Java, Rust o Android pueden ocupar rápidamente toda la RAM, el procesador y el disco disponibles.
| Escenario | CPU | RAM | Disco | Adecuado para |
|---|---|---|---|---|
| Mínimo | 2 vCPU | 4 GB | 40 GB SSD | Linters, pruebas unitarias, proyecto pequeño de Node.js/Python |
| Operativo y universal | 4 vCPU | 8 GB | 80–120 GB NVMe | Docker Compose, varios servicios, compilaciones regulares |
| CI intensivo | 8 vCPU | 16 GB | 160–250 GB NVMe | Java, Rust, monorepo, job paralelos, imágenes grandes |
| Compilación de Android o imágenes grandes | 8–16 vCPU | 32 GB | 300 GB+ NVMe | Gradle, emuladores, multi-platform build, artefactos grandes |
Para un primer runner destinado a un repositorio private, una configuración inicial razonable es 4 vCPU, 8 GB de RAM, 100 GB de NVMe y una conexión desde 100 Mbit/s. Este servidor soportará la compilación Docker de una API, un frontend y pruebas básicas de integración. Si es necesario, puedes elegir un VPS con las características indicadas o seleccionar una configuración de otro proveedor con un disco NVMe rápido.
Por qué es importante el espacio en disco
Docker no elimina automáticamente las imágenes no utilizadas, build cache, contenedores detenidos ni volumes. En CI, el disco suele agotarse antes que la CPU. Una imagen con varias capas puede ocupar 1–3 GB, y la caché de dependencias de Node.js, Maven, Gradle o Cargo puede ocupar decenas de gigabytes adicionales.
Deja al menos un 20–30% de espacio libre. Para Docker es útil ejecutar regularmente docker system prune, pero no realices una limpieza agresiva durante un build: puede eliminar imágenes necesarias o la caché de una tarea activa.
Cuándo se necesita un dedicated en lugar de un VPS
Un VPS es suficiente para la mayoría de los proyectos con un runner y job secuenciales. Conviene elegir un servidor dedicated cuando se necesita rendimiento garantizado de CPU y disco, se ejecutan simultáneamente varios runner, se compilan proyectos Android pesados, imágenes Docker grandes o se realizan pruebas que consumen muchos recursos.
Un dedicated también está justificado si CI procesa código confidencial, claves de firma de releases, grandes bases de datos de prueba o ejecuta una carga elevada de forma constante. En un servidor dedicado es más fácil predecir el rendimiento y reducir la influencia de las máquinas virtuales vecinas sobre el storage I/O.
Ubicación del VPS
La ubicación afecta principalmente a la latencia hacia GitHub, Docker Registry, package registry y los servidores de despliegue de destino. Si el runner crea imágenes y las envía a un registry en Europa, una ubicación europea normalmente reducirá el tiempo de carga. Si el runner despliega un servicio en un servidor de una región concreta, colócalo más cerca de la infraestructura de production.
Para pruebas habituales, la diferencia geográfica es menos crítica que la velocidad del NVMe y la estabilidad de la conexión. Pero si el workflow descarga con frecuencia dependencias de varios gigabytes, comprueba el tráfico incluido y el ancho de banda del puerto.
Preparación del servidor
A continuación se utiliza Ubuntu Server 24.04 LTS x86_64. En el momento de la configuración, en 2026, esta es una rama LTS estable con soporte de seguridad. Los comandos se ejecutan como el administrador inicial con permisos root o sudo. Sustituya los valores entre corchetes angulares por los suyos, pero no introduzca los propios símbolos < y >.
Actualice el sistema operativo
Primero instale todos los parches disponibles. Tras actualizar el kernel, será necesario reiniciar el VPS. Puede comprobar si es necesario mediante el archivo /var/run/reboot-required.
# Actualiza el índice de paquetes e instala las actualizaciones actuales de Ubuntu.
sudo apt update && sudo apt full-upgrade -y
# Elimina dependencias que ya no son necesarias y limpia la caché local de APT.
sudo apt autoremove --purge -y && sudo apt clean
# Reinicia el servidor si se actualizó el kernel o las bibliotecas del sistema.
sudo reboot
Cree un administrador independiente
No utilice root para el trabajo habitual. Cree un usuario que realizará tareas administrativas. Más adelante, el runner tendrá una cuenta independiente sin contraseña interactiva.
# Crea el usuario deployadmin con directorio personal y solicitud de contraseña.
sudo adduser deployadmin
# Añade el usuario al grupo sudo para comandos administrativos.
sudo usermod -aG sudo deployadmin
# Comprueba que el usuario recibió los grupos necesarios.
id deployadmin
Añada su clave pública SSH al archivo /home/deployadmin/.ssh/authorized_keys. Si ya está conectado como root y su clave está en /root/.ssh/authorized_keys, puede copiarla.
# Crea el directorio SSH y establece permisos de acceso seguros.
sudo install -d -m 700 -o deployadmin -g deployadmin /home/deployadmin/.ssh
# Copia las claves autorizadas existentes de root al nuevo administrador.
sudo cp /root/.ssh/authorized_keys /home/deployadmin/.ssh/authorized_keys
# Establece el propietario y los permisos del archivo de claves públicas.
sudo chown deployadmin:deployadmin /home/deployadmin/.ssh/authorized_keys
sudo chmod 600 /home/deployadmin/.ssh/authorized_keys
Abra una segunda sesión SSH y asegúrese de que puede iniciar sesión como deployadmin mediante clave. Solo después desactive el inicio de sesión root y la autenticación por contraseña. Un error en esta etapa puede hacerle perder el acceso al VPS.
Endurezca la configuración de SSH
Cree un archivo de configuración independiente. Ubuntu OpenSSH lee archivos del directorio /etc/ssh/sshd_config.d/, por lo que no es necesario editar el archivo principal ni entrar en conflicto con las actualizaciones del paquete.
# Crea una configuración SSH independiente: se desactivarán root y el inicio de sesión por contraseña.
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
EOF
# Comprueba la sintaxis de la configuración antes de reiniciar SSH.
sudo sshd -t
# Aplica parámetros SSH seguros sin detener las sesiones activas.
sudo systemctl reload ssh
Instale utilidades básicas, firewall y Fail2ban
GitHub runner no requiere un puerto entrante. Para la administración, deje solo SSH. Si cambió el puerto SSH, permita específicamente ese puerto en UFW. Antes de activar el firewall, compruebe que el IP actual no esté bloqueado por el firewall de red externo del proveedor.
# Instala herramientas de diagnóstico, firewall, Fail2ban y utilidades para trabajar con archivos.
sudo apt install -y curl wget ca-certificates gnupg lsb-release jq unzip \
git htop tmux ufw fail2ban ncdu
# Deniega las conexiones entrantes y permite las salientes.
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Permite SSH para la administración remota.
sudo ufw allow OpenSSH
# Activa el firewall y muestra las reglas vigentes.
sudo ufw --force enable
sudo ufw status verbose
Fail2ban supervisa los registros de SSH y bloquea temporalmente las direcciones IP con intentos de inicio de sesión fallidos repetidos. No sustituye las claves SSH ni el firewall, pero reduce el ruido de los escáneres automáticos.
# Activa Fail2ban al arrancar y lo inicia inmediatamente.
sudo systemctl enable --now fail2ban
# Muestra el estado de la jail SSH y el número de direcciones bloqueadas.
sudo fail2ban-client status sshd
Active las actualizaciones automáticas de seguridad
Las security-updates automáticas son útiles para la protección básica del VPS. Sin embargo, una actualización de Docker o del kernel puede requerir reiniciar los servicios. Para un production-runner crítico, es mejor elegir una maintenance window fija y probar las actualizaciones en una máquina independiente.
# Instala el mecanismo para aplicar automáticamente actualizaciones de seguridad de Ubuntu.
sudo apt install -y unattended-upgrades
# Inicia la configuración interactiva de las security-updates automáticas.
sudo dpkg-reconfigure --priority=low unattended-upgrades
Instalación de software — paso a paso
Para el self-hosted runner se instalarán Docker Engine desde el repositorio oficial de Docker y GitHub Actions Runner desde los GitHub Releases oficiales. No utilice el paquete docker.io del repositorio estándar de Ubuntu si necesita una versión reciente y predecible de Docker Engine: el repositorio oficial de Docker suele actualizarse más rápido.
Instale Docker Engine
En 2026, utilice la rama estable actual de Docker Engine 27.x o posterior, disponible para Ubuntu 24.04. Los comandos siguientes añaden la clave y el repositorio oficiales, tras lo cual APT instalará el paquete stable actual.
# Crea un directorio para las claves de repositorios APT.
sudo install -m 0755 -d /etc/apt/keyrings
# Descarga y guarda la clave GPG oficial de Docker.
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# Permite que APT lea la clave de Docker a todos los usuarios.
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Añade el repositorio stable oficial de Docker para la arquitectura actual de Ubuntu.
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Actualiza el índice de paquetes tras añadir el nuevo repositorio.
sudo apt update
# Instala Docker Engine, CLI, Buildx y Docker Compose v2.
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Activa Docker al arrancar e inicia el daemon ahora.
sudo systemctl enable --now docker
# Comprueba las versiones de Docker Engine, Compose y Buildx.
docker --version
docker compose version
docker buildx version
Por ahora, el comando docker ps de un usuario normal mostrará un error de acceso; esto es normal. El runner se ejecutará con un usuario independiente, ghrunner, que añadiremos deliberadamente al grupo docker.
La pertenencia al grupo
dockerequivale a amplios privilegios root en el servidor. No añada allí usuarios normales ni permita que se ejecuten workflows no confiables en este runner.
Cree el usuario del sistema para el runner
El agente no necesita contraseña ni inicio de sesión SSH interactivo. Un usuario de sistema independiente simplifica la auditoría de archivos, registros y permisos. El directorio personal será /opt/actions-runner.
# Crea una cuenta de sistema ghrunner sin contraseña y con directorio personal del runner.
sudo useradd --system --create-home --home-dir /opt/actions-runner \
--shell /usr/sbin/nologin ghrunner
# Da al runner acceso al daemon de Docker para workflows con contenedores.
sudo usermod -aG docker ghrunner
# Comprueba el UID, el directorio personal y los grupos del nuevo usuario.
id ghrunner
getent passwd ghrunner
Descargue el GitHub Actions Runner actual
GitHub publica regularmente actualizaciones del runner 2.x. En lugar de un número fijado de forma rígida, el siguiente comando obtiene la última versión estable para la arquitectura x64 mediante la API oficial de GitHub. Esto reduce el riesgo de instalar un agente obsoleto. Para ARM64, sustituya el filtro linux-x64 por linux-arm64.
# Obtiene la URL del último release oficial del runner para Linux x64.
RUNNER_URL=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest \
| jq -r '.assets[] | select(.name | test("actions-runner-linux-x64-[0-9.]+\\.tar\\.gz$")) | .browser_download_url')
# Muestra la URL encontrada; no debe estar vacía.
echo "$RUNNER_URL"
# Descarga el archivo del runner en el directorio personal del usuario del sistema.
sudo -u ghrunner curl -fL "$RUNNER_URL" -o /opt/actions-runner/actions-runner.tar.gz
# Descomprime el runner y elimina el archivo tras una descompresión correcta.
sudo -u ghrunner tar xzf /opt/actions-runner/actions-runner.tar.gz \
-C /opt/actions-runner
sudo -u ghrunner rm /opt/actions-runner/actions-runner.tar.gz
GitHub suele publicar sumas de comprobación en los releases. Para un entorno con requisitos elevados, descargue el archivo checksums desde la misma página del release y compruebe SHA-256 antes de descomprimir. Esto es especialmente importante si el VPS está en una red no confiable o si el paquete se descarga a través de un proxy corporativo.
Instale las dependencias del runner
El script installdependencies.sh instala las bibliotecas del sistema necesarias para el agente. Ejecútelo mediante sudo y luego asegúrese de que el directorio del runner pertenezca a ghrunner.
# Instala las bibliotecas del sistema requeridas por GitHub Actions Runner.
cd /opt/actions-runner
sudo ./bin/installdependencies.sh
# Devuelve la propiedad de los archivos de trabajo al agente después de instalar las dependencias.
sudo chown -R ghrunner:ghrunner /opt/actions-runner
# Muestra la versión del runner instalado.
sudo -u ghrunner /opt/actions-runner/bin/Runner.Listener --version
Compruebe Docker como el runner
Esta comprobación es crítica. Si Docker funciona para el administrador, pero no está disponible para el usuario ghrunner, los workflows con contenedores y docker build terminarán con el error permission denied.
# Comprueba que el usuario runner ve el daemon de Docker y puede ejecutar un contenedor de prueba.
sudo -u ghrunner docker run --rm hello-world
# Muestra el espacio ocupado por imágenes, contenedores, volumes y build cache.
sudo docker system df
Configuración de GitHub Actions self-hosted runner
El registro del runner se realiza desde la interfaz de GitHub. Para un runner a nivel de repositorio, abra el repositorio y vaya a Settings → Actions → Runners → New self-hosted runner. Seleccione Linux y x64. GitHub mostrará un registration token de un solo uso y un comando de configuración.
Para varios repositorios, resulta más conveniente crear un runner a nivel de organización: Organization Settings → Actions → Runners. Sin embargo, un organisation-level runner recibe tareas de todos los repositorios permitidos, por lo que debe usar runner groups y labels para limitar el acceso.
Registre el runner con labels
El token de registro tiene una validez limitada, normalmente de aproximadamente una hora. No lo guarde en notas, shell history, un repositorio Git ni un archivo de configuración. Sustituya la URL de su repositorio y el token obtenido en GitHub.
# Переходит в каталог GitHub Actions Runner.
cd /opt/actions-runner
# Регистрирует runner для private-репозитория с понятным именем и labels.
sudo -u ghrunner ./config.sh \
--url https://github.com/ORG/REPOSITORY \
--token YOUR_ONE_TIME_REGISTRATION_TOKEN \
--name ci-vps-01 \
--labels linux,x64,docker,ci-vps \
--work _work \
--unattended \
--replace
El parámetro --replace es útil al volver a registrar un runner con el mismo nombre. El directorio _work contiene los checkout de los repositorios y datos temporales de job. Puede crecer rápidamente, por lo que debe tenerse en cuenta al planificar el disco y el mantenimiento.
Después del comando, el runner debe mostrar el estado Idle en GitHub. Mientras el agente no esté instalado como servicio, puede quedar Offline tras finalizar el proceso. Para un funcionamiento permanente, registre un servicio systemd.
# Устанавливает systemd unit, который будет запускать runner после перезагрузки.
cd /opt/actions-runner
sudo ./svc.sh install ghrunner
# Включает сервис в автозагрузку и запускает его сейчас.
sudo ./svc.sh start
# Проверяет, что systemd видит активный сервис runner.
sudo systemctl status actions.runner..service --no-pager
El nombre de la unidad systemd contiene el propietario y el nombre del repositorio o de la organización. El nombre exacto depende de la URL de registro. Para ver logs en tiempo real, use journalctl.
# Показывает последние 100 строк лога и продолжает вывод в реальном времени.
sudo journalctl -u 'actions.runner.' -n 100 -f
# Показывает все сервисы GitHub Actions Runner на текущем сервере.
systemctl list-units --type=service --all | grep actions.runner
Workflow de ejemplo para comprobación
Cree el archivo .github/workflows/self-hosted-check.yml en su repositorio. Se ejecuta manualmente, muestra las características del VPS, comprueba Docker y construye un contenedor pequeño. El workflow no contiene secretos, por lo que es seguro usarlo como diagnóstico inicial.
name: Self-hosted runner check
on:
workflow_dispatch:
jobs:
diagnostics:
runs-on: [self-hosted, linux, x64, docker, ci-vps]
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Show runner information
run: |
whoami
hostnamectl
uname -a
nproc
free -h
df -h /
- name: Check Docker
run: |
docker --version
docker compose version
docker ps
docker run --rm hello-world
- name: Build test image
run: |
printf 'FROM alpine:3.21\nCMD ["echo", "CI works"]\n' > Dockerfile.ci-test
docker build -t ci-test:${{ github.run_id }} -f Dockerfile.ci-test .
docker run --rm ci-test:${{ github.run_id }}
Haga commit, abra la pestaña Actions, seleccione el workflow y pulse Run workflow. En los logs debe aparecer información sobre el servidor, la versión de Docker y la línea Hello from Docker!. Tras finalizar el job, el runner volverá al estado Idle en GitHub.
Ejemplo de workflow Docker CI/CD
A continuación se muestra un ejemplo que construye una imagen y la publica en GitHub Container Registry. La variable GITHUB_TOKEN es proporcionada automáticamente por GitHub. Para publicar se requieren los permissions mínimos contents: read y packages: write. La ejecución está limitada a la rama main.
name: Build and publish image
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: [self-hosted, linux, x64, docker, ci-vps]
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository_owner }}/my-app:latest
Secretos, environment variables y despliegue en producción
No escriba contraseñas, claves API, claves SSH, registration token ni tokens de registry en archivos YAML. Guárdelos en Settings → Secrets and variables → Actions. Para producción, use GitHub Environments: cree el entorno production, añada approval rules y vincule los secretos a él. Así, los secretos se proporcionan solo a los job que usan explícitamente este entorno.
name: Deploy production
on:
workflow_dispatch:
jobs:
deploy:
runs-on: [self-hosted, linux, x64, production]
environment: production
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Deploy through protected SSH key
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
ssh "$DEPLOY_USER@$DEPLOY_HOST" 'cd /srv/my-app && docker compose pull && docker compose up -d'
Si una aplicación concreta realmente necesita un archivo .env en el VPS, guárdelo fuera del repositorio Git, por ejemplo en /etc/my-app/my-app.env con permisos 600. No confunda este archivo con el entorno del runner: GitHub Actions Secrets se transmiten al job mediante variables de entorno y normalmente no requieren almacenamiento permanente en el VPS.
# Создаёт закрытый каталог для локальных production-конфигураций приложения.
sudo install -d -m 700 -o root -g root /etc/my-app
# Создаёт .env-файл, доступный только root; секреты вводятся интерактивно в редакторе.
sudoedit /etc/my-app/my-app.env
# Проверяет владельца и режим доступа, не печатая содержимое секретов.
sudo stat -c '%U:%G %a %n' /etc/my-app/my-app.env
TLS/HTTPS y acceso de red
Caddy y Certbot no son necesarios para GitHub Actions runner: el agente no publica un servicio HTTP y usa HTTPS saliente hacia GitHub. No abra los puertos 80 y 443 «para el runner»: esto no mejora el funcionamiento de CI/CD y aumenta la superficie de ataque.
Compruebe DNS, TLS saliente y la API de GitHub con los siguientes comandos. Si el VPS está detrás de un proxy corporativo, configure las variables HTTPS_PROXY, HTTP_PROXY y NO_PROXY en el override systemd del runner, no en un workflow con secretos.
# Проверяет, что VPS может установить TLS-соединение с GitHub API.
curl -I --connect-timeout 10 https://api.github.com
# Проверяет, что GitHub Actions endpoint доступен по HTTPS.
curl -I --connect-timeout 10 https://github.com
# Проверяет DNS-разрешение домена GitHub.
getent hosts github.com
Limite el paralelismo y limpie el directorio de trabajo
Un runner ejecuta solo un job a la vez. Esto suele ser correcto para un VPS con recursos limitados. Si necesita paralelismo, cree un segundo runner en un directorio independiente y aumente CPU, RAM y disco solo después de realizar mediciones. No ejecute varias Docker build pesadas en un servidor con 4 GB de RAM.
Después de un job, GitHub Actions deja el checkout en _work. Para proyectos privados, esto acelera tareas posteriores, pero puede conservar archivos temporales. Al final del workflow, elimine los artefactos especialmente sensibles y limpie periódicamente los directorios antiguos después de comprobar que no hay job activos.
# Показывает размер рабочей директории и Docker-хранилища.
sudo du -sh /opt/actions-runner/_work 2>/dev/null
sudo docker system df
# Удаляет только неиспользуемые Docker-образы, контейнеры, сети и build cache.
sudo docker system prune -af
Copias de seguridad y mantenimiento
El runner de GitHub Actions no contiene la base de datos principal del negocio: el código fuente se almacena en GitHub, los workflow en el repositorio y GitHub Secrets no se descargan al VPS como configuración permanente. Por ello, hacer una copia de seguridad del runner es más sencillo que hacerla de GitLab o Jenkins. Sin embargo, debe conservar la documentación, las configuraciones locales, los scripts de despliegue, los overrides de systemd y los datos creados por sus workflow.
Qué se debe y no se debe respaldar
| Datos | Respaldar | Motivo |
|---|---|---|
| Repositorio de GitHub y workflow | Normalmente no por separado | Ya se almacenan en GitHub, pero las réplicas son útiles para proyectos críticos |
/opt/actions-runner/.runner y credentials |
No | Contienen credenciales de registro; es más sencillo registrar el runner de nuevo |
| Scripts de despliegue fuera de Git | Sí | Es configuración operativa local |
| Override de systemd y configuraciones de proxy | Sí | Son necesarios para restaurar rápidamente el entorno |
/etc/my-app/.env |
Sí, cifrado | Contienen secretos de producción de la aplicación |
| Docker build cache e imágenes temporales | No | Ocupan mucho espacio y se restauran durante la siguiente compilación |
| Volumes de la aplicación de producción | Sí | Pueden contener bases de datos, uploads, colas o datos de usuarios |
Copia de seguridad mediante restic en S3
Restic crea copias de seguridad cifradas con deduplicación. Como almacenamiento puede utilizar S3-compatible storage, un VPS de backup independiente mediante SFTP u otro backend remoto. A continuación se utiliza S3. No guarde la contraseña del repositorio restic en el shell history ni la añada a Git.
# Instala restic desde el repositorio de Ubuntu.
sudo apt install -y restic
# Crea un directorio restringido para las variables de copia de seguridad.
sudo install -d -m 700 -o root -g root /etc/restic
# Crea un archivo de entorno con las credenciales de S3 y la contraseña de cifrado.
sudoedit /etc/restic/runner-backup.env
# Restringe el acceso al archivo de secretos únicamente al usuario root.
sudo chmod 600 /etc/restic/runner-backup.env
El archivo /etc/restic/runner-backup.env debe tener el siguiente aspecto. Utilice un usuario S3 independiente con acceso únicamente al backup bucket. La contraseña RESTIC_PASSWORD debe ser larga y guardarse también en un gestor de contraseñas: sin ella es imposible restaurar los datos.
export RESTIC_REPOSITORY="s3:https://s3.example.net/ci-runner-backup"
export AWS_ACCESS_KEY_ID="CHANGE_ME"
export AWS_SECRET_ACCESS_KEY="CHANGE_ME"
export RESTIC_PASSWORD="CHANGE_ME_TO_A_LONG_UNIQUE_PASSWORD"
Inicialice un repositorio vacío una vez. Después cree un script que archive solo los directorios necesarios y excluya las registration credentials del runner.
# Inicializa un nuevo repositorio restic cifrado en el almacenamiento S3 remoto.
sudo bash -c 'source /etc/restic/runner-backup.env && restic init'
# Crea un script de copia de seguridad diaria de configuraciones y datos locales.
sudo tee /usr/local/sbin/backup-ci-runner.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source /etc/restic/runner-backup.env
restic backup \
/etc/systemd/system \
/etc/my-app \
/usr/local/sbin \
/opt/actions-runner \
--exclude=/opt/actions-runner/_work \
--exclude=/opt/actions-runner/.credentials \
--exclude=/opt/actions-runner/.credentials_rsaparams \
--exclude=/opt/actions-runner/.runner \
--tag ci-runner
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check
EOF
# Hace que el script solo pueda ejecutarse como root.
sudo chmod 700 /usr/local/sbin/backup-ci-runner.sh
Antes de añadirlo a cron, ejecute la copia de seguridad manualmente y compruebe los snapshots. La primera ejecución puede tardar mucho; las posteriores transferirán únicamente los bloques modificados.
# Crea el primer backup y comprueba simultáneamente la disponibilidad del almacenamiento remoto.
sudo /usr/local/sbin/backup-ci-runner.sh
# Muestra la lista de snapshots creados sin revelar el contenido de los secretos.
sudo bash -c 'source /etc/restic/runner-backup.env && restic snapshots'
# Abre el root-crontab para añadir una programación.
sudo crontab -e
Añada al root-crontab una línea para la ejecución diaria a las 03:20. El log es útil para investigar errores, pero no debe ser el único indicador: configure monitoreo externo o una notificación de backup fallido.
# Ejecuta el backup diariamente a las 03:20 y escribe stdout/stderr en un log independiente.
20 3 /usr/local/sbin/backup-ci-runner.sh >> /var/log/backup-ci-runner.log 2>&1
Prueba de restauración
Una copia de seguridad sin una prueba de restauración es solo una suposición. Una vez por trimestre, restaure un snapshot en un directorio temporal de otra máquina o en un directorio bajo /root/restore-test. Compruebe la presencia de configuraciones, los permisos de acceso y la posibilidad de utilizar los deployment scripts restaurados.
# Crea un directorio temporal para comprobar la restauración.
sudo mkdir -p /root/restore-test
# Restaura el último snapshot en una ruta de prueba sin afectar al sistema en funcionamiento.
sudo bash -c 'source /etc/restic/runner-backup.env && restic restore latest --target /root/restore-test'
# Muestra los primeros niveles del árbol restaurado para una comprobación rápida.
sudo find /root/restore-test -maxdepth 3 -type f | head -50
Actualización del runner y Docker
GitHub Actions Runner normalmente puede realizar actualizaciones automáticas entre job. No desactive este mecanismo sin motivo: las versiones antiguas pueden dejar de recibir tareas al finalizar el soporte. Antes de una actualización manual, espere a que el runner quede Idle, detenga el servicio, actualice los archivos y restaure el propietario del directorio.
Para un runner individual, elija una maintenance window: desactive temporalmente el workflow o espere a que finalice el job. Esto es más seguro que actualizar Docker daemon en medio de una compilación. Para varios runner, use una actualización gradual: retire un runner del runner group, actualícelo y compruébelo, y después pase al siguiente.
# Muestra el estado actual del runner antes del mantenimiento programado.
sudo systemctl status 'actions.runner.' --no-pager
# Instala las actualizaciones disponibles del SO y Docker mediante APT.
sudo apt update && sudo apt upgrade -y
# Comprueba las versiones del runner y Docker después del mantenimiento.
sudo -u ghrunner /opt/actions-runner/bin/Runner.Listener --version
docker --version
Revise regularmente el espacio libre, los contenedores activos y los errores del servicio. El conjunto mínimo de comprobaciones semanales: df -h, docker system df, systemctl status, journalctl y el estado del runner en GitHub.
Solución de problemas + FAQ
¿Por qué el runner aparece como Offline en GitHub?
Primero compruebe systemd: sudo systemctl status 'actions.runner.'. Después consulte el registro mediante sudo journalctl -u 'actions.runner.' -n 100 --no-pager. Causas frecuentes: el servicio no se instaló después del registro, el VPS no tiene acceso saliente a 443/tcp, el proxy está configurado incorrectamente o la hora del sistema difiere mucho de la real. Compruebe timedatectl y curl -I https://api.github.com. Si las credentials están dañadas, elimine el runner en la interfaz de GitHub y regístrelo de nuevo con un nuevo token de un solo uso.
El workflow queda en cola y no se asigna al runner. ¿Qué se debe comprobar?
Compare los labels del workflow con los labels mostrados en la sección GitHub Actions Runners. Un job con runs-on: [self-hosted, linux, docker] esperará indefinidamente si el runner no tiene al menos una de estas etiquetas o está ocupado con otra tarea. Compruebe también si el runner está permitido para este repositorio, si se encuentra en otro runner group y si las restricciones de la organización están activadas. Para el diagnóstico, use temporalmente runs-on: self-hosted y después restaure los labels específicos.
¿Por qué Docker en el workflow muestra permission denied al conectarse a /var/run/docker.sock?
El runner se ejecuta con el usuario ghrunner, que necesita acceso al grupo docker. Ejecute id ghrunner: la salida debe incluir el grupo docker. Si no está, añada el usuario con el comando sudo usermod -aG docker ghrunner y reinicie el servicio runner. Compruebe sudo -u ghrunner docker ps. No solucione el problema con el comando chmod 666 /var/run/docker.sock: es inseguro y normalmente temporal.
El VPS se quedó sin espacio durante docker build. ¿Cómo liberar disco?
Primero identifique el origen: df -h, sudo docker system df -v y sudo du -sh /opt/actions-runner/_work. Tras finalizar todos los job, elimine los datos de Docker no utilizados mediante sudo docker system prune -af. Si los volumes no son necesarios, puede añadir --volumes, pero esto eliminará los volúmenes no utilizados y puede dañar la aplicación local. Para una carga permanente, amplíe el disco, limite el tamaño de los artefactos y mueva el registry o la caché a un almacenamiento independiente.
¿Se puede usar un self-hosted runner para un public repository?
Técnicamente sí, pero no se debe proporcionar un runner permanente con Docker y secretos a código no confiable. En un public repository, colaboradores externos pueden abrir un pull request con un workflow o código que intente leer archivos, robar tokens, modificar imágenes de Docker o mantenerse en el servidor. Para pull request públicos, use GitHub-hosted runner. Si el self-hosted es obligatorio, cree un ephemeral runner para cada job, aíslelo en una VM independiente y no le dé acceso a la red de producción, Docker socket ni secretos sensibles.
¿Qué configuración de VPS es la mínima adecuada?
El mínimo para un proyecto private pequeño es 2 vCPU, 4 GB de RAM, 40 GB de SSD y acceso saliente estable a internet. Esta configuración es suficiente para linting, pruebas unitarias y compilaciones ligeras de Node.js o Python. Si el workflow utiliza Docker Compose, PostgreSQL, Redis y compilación de imágenes, es más práctico elegir desde el inicio 4 vCPU, 8 GB de RAM y 80–100 GB de NVMe. Vigile docker system df: el tamaño del disco en CI es más importante de lo que parece al principio.
¿Qué elegir para esta tarea: VPS o dedicated?
Para un runner y un proyecto web habitual, elija VPS: es más económico, se implementa más rápido y se escala fácilmente cambiando el plan. Dedicated es necesario para compilaciones pesadas constantes, varios runner en paralelo, requisitos de CPU estable y NVMe I/O, trabajo con bases de datos y artefactos grandes o mayores requisitos de aislamiento. Comience con un VPS y mida la duración de los job, el consumo de RAM, la carga de CPU y el espacio libre. Tiene sentido migrar a dedicated cuando los recursos del VPS se convierten sistemáticamente en un cuello de botella.
¿Es necesario abrir 80, 443 o configurar Caddy para GitHub Actions Runner?
No. El runner crea por sí mismo conexiones HTTPS salientes con GitHub, por lo que no necesita puertos HTTP/HTTPS entrantes. Mantenga cerrados los puertos 80 y 443 si el VPS no tiene un servicio web independiente. Caddy o Certbot solo son necesarios cuando este mismo servidor publica una aplicación, registry, dashboard u otro servicio HTTP. Para el runner basta con 443/tcp saliente, DNS correcto y hora sincronizada. Este enfoque es más seguro: el servidor no tiene una superficie de red pública innecesaria.
¿Por qué el job deja archivos y contenedores después de finalizar?
A diferencia de GitHub-hosted VM, un self-hosted runner es permanente. GitHub limpia algunos datos de servicio del job, pero las imágenes de Docker, build cache, volumes, parte del checkout y archivos temporales pueden permanecer en el disco. Esto acelera las compilaciones repetidas, pero requiere mantenimiento. Añada un cleanup-step al workflow para los datos sensibles, establezca límites para los artefactos y ejecute la limpieza programada de Docker durante una ventana libre. No elimine el directorio de trabajo mientras haya job activos: esto provocará errores de compilación impredecibles.
Conclusiones y siguientes pasos
Ahora el VPS ejecuta un GitHub Actions self-hosted runner con Docker, systemd, firewall y una estrategia básica de copia de seguridad. Puede ejecutar workflow de CI/CD, compilar contenedores, probar código y desplegar aplicaciones sin necesidad de abrir puertos entrantes para el propio agente.
- Cree labels y runner groups independientes para CI, staging y production, para que los production-job no se ejecuten en una máquina de desarrollo normal.
- Añada monitoreo del disco, memoria, estado de systemd y éxito de las copias de seguridad; CI suele fallar debido al almacenamiento de Docker lleno.
- A medida que aumente la carga, distribuya las tareas entre varios runner o cambie a ephemeral runners para workflow no confiables y públicos.