Trabajar con varios entornos (desarrollo, staging y producción) permite probar cada cambio en condiciones cada vez más parecidas a las reales antes de que lo vean los usuarios. La regla que hace que esto funcione es construir una vez y promover el mismo artefacto: la imagen que pasa por staging tiene que ser byte a byte la que llega a producción, y lo único que cambia entre entornos es la configuración. En este tutorial montarás ese flujo con GitHub Actions: una imagen Docker construida una sola vez, desplegada automáticamente en desarrollo y staging, y en producción solo tras una aprobación manual. Cada entorno es un servidor Ubuntu 24.04 con Docker Compose.
Requisitos previos
- Un repositorio en GitHub con un
Dockerfiley permisos de administrador. - Tres servidores con Ubuntu 24.04 LTS, uno por entorno (por ejemplo, tres VPS de CubePath), con Docker Engine y el plugin Compose instalados. Para una prueba, desarrollo y staging pueden compartir servidor en directorios distintos.
- La aplicación debe exponer un endpoint de salud, por ejemplo
/health, y un nombre de dominio o IP por entorno. - La CLI de GitHub (
gh) autenticada en tu máquina local.
Notalos entornos de GitHub están disponibles en todos los planes para repositorios públicos. En repositorios privados requieren GitHub Pro, Team o Enterprise, y la regla de revisores obligatorios (necesaria para la aprobación de producción) requiere GitHub Enterprise.
Cómo funciona el flujo
| Entorno | Se despliega | Configuración | Protección |
|---|---|---|---|
development | En cada push a main, automáticamente | .env del servidor de desarrollo | Ninguna |
staging | Cuando desarrollo termina bien | .env del servidor de staging | Solo desde main |
production | Cuando staging termina bien y alguien lo aprueba | .env del servidor de producción | Solo desde main y revisores obligatorios |
El pipeline no guarda configuración de la aplicación: cada servidor tiene su propio archivo .env con sus valores (URL de la base de datos, nivel de logs, claves de terceros). El pipeline solo sabe qué versión de imagen desplegar y cómo conectarse a cada servidor.
Paso 1: Preparar cada servidor
Repite este paso en los tres servidores. Crea un usuario dedicado deploy que solo pueda ejecutar el despliegue, sin sudo:
sudo adduser --disabled-password --comment "" deploy
sudo usermod -aG docker deploy
sudo mkdir -p /opt/miapp
sudo chown deploy:deploy /opt/miapp
Advertenciapertenecer al grupo
dockerequivale en la práctica a tener acceso root en ese servidor. Por eso, más abajo, la clave SSH de este usuario quedará limitada a un único comando.
Crea /opt/miapp/compose.yaml. Es idéntico en todos los entornos; la versión y la configuración vienen del .env:
sudo -u deploy nano /opt/miapp/compose.yaml
services:
app:
image: ghcr.io/tu_usuario/miapp:${APP_VERSION:?APP_VERSION no definida}
restart: unless-stopped
env_file: app.env
ports:
- "127.0.0.1:8080:8080"
Crea /opt/miapp/.env, que solo guarda la versión desplegada:
echo "APP_VERSION=ninguna" | sudo -u deploy tee /opt/miapp/.env
Y /opt/miapp/app.env con la configuración propia de cada entorno. Este es el de staging; en desarrollo y producción cambian los valores, no las claves:
sudo -u deploy nano /opt/miapp/app.env
APP_ENV=staging
LOG_LEVEL=info
DATABASE_URL=postgresql://miapp:tu_contraseñ[email protected]:5432/miapp_staging
sudo chmod 600 /opt/miapp/app.env
Si la imagen es privada, inicia sesión en el registro como deploy con un token personal de GitHub que solo tenga el permiso read:packages:
sudo -u deploy -H docker login ghcr.io -u tu_usuario
Paso 2: Crear el script de despliegue
El script recibe una versión, la escribe en .env, descarga la imagen, recrea el contenedor y comprueba la salud. Créalo en /usr/local/bin/desplegar-miapp:
sudo nano /usr/local/bin/desplegar-miapp
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/opt/miapp
COMPOSE=(docker compose -f "$APP_DIR/compose.yaml")
version="${1:-}"
if [[ ! "$version" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Versión no válida: '$version'" >&2
exit 1
fi
anterior="$(sed -n 's/^APP_VERSION=//p' "$APP_DIR/.env")"
sed -i "s/^APP_VERSION=.*/APP_VERSION=$version/" "$APP_DIR/.env"
if ! "${COMPOSE[@]}" pull app; then
sed -i "s/^APP_VERSION=.*/APP_VERSION=$anterior/" "$APP_DIR/.env"
echo "No se pudo descargar la versión $version" >&2
exit 1
fi
"${COMPOSE[@]}" up -d app
for _ in $(seq 1 30); do
if curl -fsS --max-time 2 http://127.0.0.1:8080/health > /dev/null 2>&1; then
echo "Versión $version desplegada (anterior: $anterior)"
exit 0
fi
sleep 2
done
echo "La versión $version no responde al health check" >&2
exit 1
sudo chmod 755 /usr/local/bin/desplegar-miapp
Si el health check falla, el script termina con error y el pipeline se detiene antes de pasar al siguiente entorno. La guía de estrategias de reversión explica cómo ampliarlo para que además vuelva automáticamente a la versión anterior.
Ahora limita lo que puede hacer la clave SSH del pipeline. Crea un pequeño envoltorio que solo acepte una versión como comando remoto, /usr/local/bin/desplegar-ssh:
sudo nano /usr/local/bin/desplegar-ssh
#!/usr/bin/env bash
set -euo pipefail
exec /usr/local/bin/desplegar-miapp "${SSH_ORIGINAL_COMMAND:-}"
sudo chmod 755 /usr/local/bin/desplegar-ssh
SSH_ORIGINAL_COMMAND contiene lo que el cliente pidió ejecutar. Como se pasa entre comillas y el script valida el formato, la clave no sirve para abrir una shell ni para ejecutar otra cosa.
Paso 3: Crear una clave SSH por entorno
En tu máquina local, genera una clave distinta para cada entorno. Así, si se filtra la de desarrollo, producción no queda expuesta:
for entorno in development staging production; do
ssh-keygen -t ed25519 -N "" -C "ci-$entorno" -f ~/.ssh/ci_"$entorno"
done
En cada servidor, autoriza su clave pública con las restricciones restrict (sin reenvío de puertos, agente ni terminal) y command (el envoltorio del paso anterior):
sudo -u deploy mkdir -p -m 700 /home/deploy/.ssh
sudo -u deploy nano /home/deploy/.ssh/authorized_keys
restrict,command="/usr/local/bin/desplegar-ssh" ssh-ed25519 AAAAC3Nza...contenido_de_ci_staging.pub ci-staging
sudo chmod 600 /home/deploy/.ssh/authorized_keys
Comprueba desde tu máquina que la clave solo despliega. Pedir una shell debe fallar la validación:
ssh -i ~/.ssh/ci_staging deploy@your_staging_ip "bash"
Versión no válida: 'bash'
Paso 4: Crear los entornos en GitHub
Crea los tres entornos con la API de GitHub:
for entorno in development staging production; do
gh api -X PUT "repos/tu_usuario/miapp/environments/$entorno" > /dev/null
done
Guarda en cada entorno la clave privada como secreto y los datos de conexión como variables. DEPLOY_KNOWN_HOSTS fija la huella del servidor para que el pipeline no acepte un servidor suplantado:
gh secret set DEPLOY_SSH_KEY --env staging < ~/.ssh/ci_staging
gh variable set DEPLOY_HOST --env staging --body "your_staging_ip"
gh variable set DEPLOY_KNOWN_HOSTS --env staging --body "$(ssh-keyscan -t ed25519 your_staging_ip 2>/dev/null)"
Repite los tres comandos para development y production con su clave e IP. Comprueba el resultado:
gh variable list --env staging
NAME VALUE UPDATED
DEPLOY_HOST 203.0.113.20 less than a minute ago
DEPLOY_KNOWN_HOSTS 203.0.113.20 ssh-ed25519 AAAAC3... less than a minute ago
Por último, protege staging y producción desde Settings > Environments del repositorio:
- En
stagingyproduction, en Deployment branches and tags, elige Selected branches and tags y añademain. Ninguna otra rama podrá desplegar ni leer sus secretos. - En
production, activa Required reviewers y añade a las personas que pueden aprobar despliegues. Puedes marcar Prevent self-review para que quien lanza el cambio no pueda aprobarlo.
Paso 5: Escribir el workflow de despliegue de un entorno
Los tres despliegues hacen lo mismo con distintos datos, así que se escriben una vez como workflow reutilizable. Recibe el entorno y la versión, y al declarar environment el job accede a los secretos y variables de ese entorno y respeta sus protecciones.
Crea .github/workflows/deploy-env.yml:
name: deploy-env
on:
workflow_call:
inputs:
environment:
required: true
type: string
version:
required: true
type: string
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-24.04
environment: ${{ inputs.environment }}
steps:
- name: Preparar SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Desplegar ${{ inputs.version }} en ${{ inputs.environment }}
env:
HOST: ${{ vars.DEPLOY_HOST }}
VERSION: ${{ inputs.version }}
run: ssh "deploy@$HOST" "$VERSION"
Los valores se pasan a los comandos mediante env y no interpolados en el script, para que un valor inesperado no pueda inyectar comandos.
Paso 6: Escribir el pipeline de promoción
El workflow principal construye la imagen una sola vez, etiquetada con el SHA del commit, y la pasa por los tres entornos en orden. Cada job depende del anterior con needs, así que un fallo en staging impide llegar a producción.
Crea .github/workflows/pipeline.yml:
name: pipeline
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: despliegue
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: version
run: echo "version=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
- name: Construir y publicar
env:
IMAGE: ghcr.io/tu_usuario/miapp
VERSION: ${{ steps.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker build -t "$IMAGE:$VERSION" .
docker push "$IMAGE:$VERSION"
development:
needs: build
uses: ./.github/workflows/deploy-env.yml
with:
environment: development
version: ${{ needs.build.outputs.version }}
secrets: inherit
staging:
needs: [build, development]
uses: ./.github/workflows/deploy-env.yml
with:
environment: staging
version: ${{ needs.build.outputs.version }}
secrets: inherit
production:
needs: [build, staging]
uses: ./.github/workflows/deploy-env.yml
with:
environment: production
version: ${{ needs.build.outputs.version }}
secrets: inherit
Puntos clave:
- La etiqueta es el SHA del commit, no
latest. Cada entorno recibe exactamente la misma imagen y en todo momento sabes qué commit corre en cada uno. concurrencyconcancel-in-progress: falsepone los despliegues en cola: dos pushes seguidos no se pisan en mitad de un despliegue.- En este punto es donde encaja tu job de pruebas: añádelo como dependencia de
buildpara no construir imágenes de código que no pasa las pruebas.
Paso 7: Probar la promoción completa
Haz un push a main y abre la ejecución en la pestaña Actions. Verás el grafo build, development, staging y production. Los dos primeros entornos se despliegan solos; production queda en estado Waiting con el botón Review deployments. Hasta que un revisor lo apruebe, ese job no se ejecuta ni puede leer los secretos de producción.
Mientras tanto, comprueba en el servidor de staging que corre la versión esperada:
grep APP_VERSION /opt/miapp/.env
docker compose -f /opt/miapp/compose.yaml ps app
APP_VERSION=4f9c2d1a7b3e
NAME IMAGE SERVICE STATUS
miapp-app-1 ghcr.io/tu_usuario/miapp:4f9c2d1a7b3e app Up 2 minutes
Aprueba el despliegue en GitHub y repite la comprobación en producción: la etiqueta debe coincidir. En la sección Deployments de la página principal del repositorio, GitHub mantiene el historial de qué versión se desplegó en cada entorno y cuándo.
Solución de problemas
Host key verification failed: la variable DEPLOY_KNOWN_HOSTS está vacía o se generó con otra IP o tipo de clave. Vuelve a ejecutar ssh-keyscan -t ed25519 contra la IP exacta de DEPLOY_HOST.
Permission denied (publickey): el secreto no coincide con la clave pública de authorized_keys, o el archivo tiene permisos demasiado abiertos. Revisa que /home/deploy/.ssh tenga 700 y authorized_keys 600, propiedad de deploy.
El job de un entorno no ve los secretos: el secreto está creado a nivel de repositorio con otro nombre, o en otro entorno. Comprueba con gh secret list --env nombre_del_entorno.
Un despliegue lanzado desde otra rama falla por las reglas de protección del entorno: es el comportamiento esperado; solo main puede desplegar en staging y producción.
denied al hacer docker compose pull en el servidor: la imagen es privada y el usuario deploy no ha iniciado sesión en ghcr.io, o su token no tiene read:packages.
Conclusión
Tienes un pipeline que construye una única imagen por commit y la promueve de desarrollo a staging y producción, con la configuración de cada entorno en su propio servidor, secretos y claves SSH separados por entorno, claves limitadas a un solo comando y una aprobación humana obligatoria antes de producción. Como siguientes pasos, puedes añadir pruebas de humo específicas después de cada despliegue, entornos efímeros por pull request para revisar cambios antes de fusionarlos, y reversión automática en el script de despliegue para que un fallo en producción vuelva solo a la versión anterior.