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 Dockerfile y 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.

Cómo funciona el flujo

EntornoSe despliegaConfiguraciónProtección
developmentEn cada push a main, automáticamente.env del servidor de desarrolloNinguna
stagingCuando desarrollo termina bien.env del servidor de stagingSolo desde main
productionCuando staging termina bien y alguien lo aprueba.env del servidor de producciónSolo 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

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 staging y production, en Deployment branches and tags, elige Selected branches and tags y añade main. 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.
  • concurrency con cancel-in-progress: false pone 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 build para 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.