--- title: "Despliegue en desarrollo, staging y producción con CI/CD" description: "Crea un pipeline de GitHub Actions que construye una imagen una vez y la promueve de desarrollo a staging y producción, con secretos por entorno y aprobación." category: "CI/CD DevOps" language: es source: https://cubepath.com/es/docs/ci-cd-devops/implementacion-en-multiples-entornos-con-cicd updated: 2026-09-25 --- # Despliegue en desarrollo, staging y producción con CI/CD 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. > Nota: los 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`: ```bash sudo adduser --disabled-password --comment "" deploy sudo usermod -aG docker deploy sudo mkdir -p /opt/miapp sudo chown deploy:deploy /opt/miapp ``` > Advertencia: pertenecer al grupo `docker` equivale 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`: ```bash sudo -u deploy nano /opt/miapp/compose.yaml ``` ```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: ```bash 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: ```bash sudo -u deploy nano /opt/miapp/app.env ``` ```ini APP_ENV=staging LOG_LEVEL=info DATABASE_URL=postgresql://miapp:tu_contraseña_segura@10.0.0.20:5432/miapp_staging ``` ```bash 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`: ```bash 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`: ```bash sudo nano /usr/local/bin/desplegar-miapp ``` ```bash #!/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 ``` ```bash 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`: ```bash sudo nano /usr/local/bin/desplegar-ssh ``` ```bash #!/usr/bin/env bash set -euo pipefail exec /usr/local/bin/desplegar-miapp "${SSH_ORIGINAL_COMMAND:-}" ``` ```bash 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: ```bash 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): ```bash sudo -u deploy mkdir -p -m 700 /home/deploy/.ssh sudo -u deploy nano /home/deploy/.ssh/authorized_keys ``` ```text restrict,command="/usr/local/bin/desplegar-ssh" ssh-ed25519 AAAAC3Nza...contenido_de_ci_staging.pub ci-staging ``` ```bash 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: ```bash ssh -i ~/.ssh/ci_staging deploy@your_staging_ip "bash" ``` ```output Versión no válida: 'bash' ``` ## Paso 4: Crear los entornos en GitHub Crea los tres entornos con la API de GitHub: ```bash 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: ```bash 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: ```bash gh variable list --env staging ``` ```output 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`: ```yaml 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`: ```yaml 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: ```bash grep APP_VERSION /opt/miapp/.env docker compose -f /opt/miapp/compose.yaml ps app ``` ```output 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.