Introducción

GitHub Actions es la plataforma de CI/CD integrada directamente en GitHub. A diferencia de Jenkins, no requiere mantener servidores propios: GitHub proporciona runners (agentes de ejecución) en la nube de forma gratuita para repositorios públicos y con un límite generoso para privados.

Para equipos que ya usan GitHub, Actions es la solución más natural para automatizar Terraform: los workflows se definen en archivos YAML dentro del propio repositorio, los secretos se gestionan desde la UI de GitHub, y la integración con Pull Requests (comentarios, checks de estado) es nativa y sin configuración adicional.

En este módulo construirás workflows completos y profesionales que implementan el patrón estándar de la industria: plan en Pull Request, apply al hacer merge.


Estructura de un workflow de GitHub Actions

Los workflows de GitHub Actions se definen en archivos YAML ubicados en .github/workflows/ dentro del repositorio. Cada archivo es un workflow independiente.

mi-proyecto-terraform/
├── .github/
│   └── workflows/
│       ├── terraform-pr.yml        # Workflow para Pull Requests
│       ├── terraform-apply.yml     # Workflow para el apply en main
│       └── terraform-destroy.yml   # Workflow para destruir (manual)
├── infrastructure/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   ├── versions.tf
│   └── environments/
│       ├── staging.tfvars
│       └── produccion.tfvars
└── README.md

La estructura básica de un workflow:

# .github/workflows/ejemplo.yml

# Nombre del workflow (aparece en la UI de GitHub)
name: Nombre del Workflow

# Eventos que disparan el workflow
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Permisos del GITHUB_TOKEN para este workflow
permissions:
  contents: read
  pull-requests: write

# Variables disponibles en todos los jobs
env:
  TF_VERSION: '1.9.0'

# Jobs a ejecutar
jobs:
  nombre-del-job:
    # Sistema operativo del runner
    runs-on: ubuntu-latest

    # Pasos a ejecutar dentro del job
    steps:
      - name: Nombre del paso
        uses: action/nombre@v4   # Usar una action existente

      - name: Otro paso
        run: echo "Comandos de shell"

La action hashicorp/setup-terraform

HashiCorp publica la action oficial hashicorp/setup-terraform que instala y configura Terraform en el runner de forma sencilla. Esta action:

  • Descarga la versión especificada de Terraform
  • La añade al PATH del runner
  • Opcionalmente configura la autenticación con Terraform Cloud
steps:
  # Instalar Terraform versión específica
  - name: Configurar Terraform
    uses: hashicorp/setup-terraform@v3
    with:
      terraform_version: '1.9.0'  # Versión exacta a instalar

      # Opciones adicionales:
      terraform_wrapper: true  # Captura el output de terraform para usarlo en steps posteriores
      # cli_config_credentials_token: ${{ secrets.TF_CLOUD_TOKEN }}  # Para Terraform Cloud

  # Verificar la instalación
  - name: Verificar versión de Terraform
    run: terraform version
    # Output: Terraform v1.9.0 on linux_amd64

  # Ahora puedes usar terraform normalmente
  - name: Terraform Init
    run: terraform init

  - name: Terraform Plan
    run: terraform plan -no-color
    # Con terraform_wrapper: true, el output queda disponible en
    # steps.terraform-plan.outputs.stdout
    # steps.terraform-plan.outputs.stderr
    # steps.terraform-plan.outputs.exitcode

Configurar secretos de AWS en GitHub

Los secretos en GitHub se configuran en el repositorio (o en la organización para compartirlos):

  1. Ve a tu repositorio en GitHub
  2. Navega a Settings > Secrets and variables > Actions
  3. Haz clic en New repository secret
  4. Crea los siguientes secretos:
    • AWS_ACCESS_KEY_ID: ID de la clave de acceso de AWS
    • AWS_SECRET_ACCESS_KEY: Clave secreta de AWS
    • TF_STATE_BUCKET: Nombre del bucket S3 para el estado

En el workflow, los secretos se acceden con ${{ secrets.NOMBRE_SECRETO }}:

steps:
  - name: Configurar credenciales AWS
    uses: aws-actions/configure-aws-credentials@v4
    with:
      aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
      aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      aws-region: us-east-1

  # Verificar que las credenciales funcionan
  - name: Verificar identidad AWS
    run: aws sts get-caller-identity

Tabla: Eventos de GitHub Actions y cuándo usarlos

Evento Sintaxis Cuándo se dispara Uso en Terraform
push on: push Cuando se hace push a una rama Apply en main, destruir en rama destroy
pull_request on: pull_request Cuando se abre/actualiza/cierra un PR Ejecutar terraform plan y comentar
workflow_dispatch on: workflow_dispatch Manualmente desde la UI de GitHub Ejecutar plan o apply bajo demanda
schedule on: schedule En intervalos regulares (cron) Drift detection: verificar si la infra cambió
release on: release Cuando se crea un release/tag Deploy a producción al publicar versión
pull_request_review on: pull_request_review Cuando se aprueba/rechaza un PR Apply automático tras aprobación
repository_dispatch on: repository_dispatch Disparado via API de GitHub Integración con sistemas externos
workflow_call on: workflow_call Llamado desde otro workflow Reutilizar workflows como módulos

Workflow completo para Terraform

Workflow 1: Plan en Pull Request

# .github/workflows/terraform-pr.yml
# Este workflow se ejecuta en cada Pull Request:
# 1. Verifica el formato del código
# 2. Valida la configuración
# 3. Genera el plan
# 4. Comenta el plan en el PR para revisión

name: Terraform Plan (Pull Request)

# ─────────────────────────────────────────────────────────────
# EVENTOS: Se ejecuta cuando se abre o actualiza un PR hacia main
# ─────────────────────────────────────────────────────────────
on:
  pull_request:
    branches:
      - main
    # Solo ejecutar si cambian archivos de Terraform o del workflow
    paths:
      - 'infrastructure/**'
      - '.github/workflows/terraform-pr.yml'

# ─────────────────────────────────────────────────────────────
# PERMISOS: Necesarios para comentar en el PR
# ─────────────────────────────────────────────────────────────
permissions:
  contents: read          # Leer el código del repositorio
  pull-requests: write    # Escribir comentarios en el PR
  id-token: write         # Necesario para OIDC (si se usa)

# ─────────────────────────────────────────────────────────────
# CONCURRENCIA: Evitar múltiples runs simultáneos en el mismo PR
# Si hay un run en curso y se hace otro push, cancelar el anterior
# ─────────────────────────────────────────────────────────────
concurrency:
  group: terraform-pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

# ─────────────────────────────────────────────────────────────
# VARIABLES DE ENTORNO: Disponibles en todos los jobs
# ─────────────────────────────────────────────────────────────
env:
  TF_VERSION: '1.9.0'                # Versión de Terraform a instalar
  TF_WORKING_DIR: './infrastructure'  # Directorio con el código Terraform
  AWS_REGION: 'us-east-1'            # Región de AWS
  ENVIRONMENT: 'staging'             # Ambiente objetivo para el PR

jobs:
  # ──────────────────────────────────────────────────────────
  # JOB: terraform-plan
  # ──────────────────────────────────────────────────────────
  terraform-plan:
    name: Terraform Plan
    runs-on: ubuntu-latest  # Usar el runner de Ubuntu de GitHub
    # Si el PR es desde un fork externo, no ejecutar
    # (los forks no tienen acceso a los secretos del repositorio)
    if: github.event.pull_request.head.repo.full_name == github.repository

    defaults:
      run:
        # Todos los comandos 'run' se ejecutan desde este directorio
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      # ── PASO 1: Obtener el código ────────────────────────
      - name: Checkout del código
        uses: actions/checkout@v4
        # actions/checkout clona el repositorio en el runner
        # Por defecto hace checkout del commit que disparó el evento

      # ── PASO 2: Instalar Terraform ───────────────────────
      - name: Configurar Terraform ${{ env.TF_VERSION }}
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          # terraform_wrapper: true permite capturar el output de terraform
          terraform_wrapper: true

      # ── PASO 3: Cachear el directorio .terraform ─────────
      # Esto evita descargar los providers en cada run
      - name: Cachear directorio .terraform
        uses: actions/cache@v4
        with:
          # El directorio a cachear
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          # La clave del cache incluye el hash del archivo de lock
          # Si el lock cambia (nuevos providers), el cache se invalida
          key: terraform-${{ runner.os }}-${{ hashFiles('**/terraform.lock.hcl') }}
          # Si no hay cache exacto, buscar uno con el mismo prefijo
          restore-keys: |
            terraform-${{ runner.os }}-

      # ── PASO 4: Configurar credenciales AWS ──────────────
      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # ── PASO 5: Terraform Init ───────────────────────────
      - name: Terraform Init
        id: init  # id permite referenciar este paso más adelante
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=${{ env.ENVIRONMENT }}/terraform.tfstate" \
            -backend-config="region=${{ env.AWS_REGION }}" \
            -backend-config="dynamodb_table=terraform-state-locks" \
            -backend-config="encrypt=true" \
            -input=false \
            -no-color

      # ── PASO 6: Verificar formato ────────────────────────
      - name: Terraform Format Check
        id: fmt
        # continue-on-error: true hace que el workflow continúe aunque falle
        # pero marca el paso como fallido (visible en el PR)
        continue-on-error: true
        run: terraform fmt -check -recursive -no-color

      # ── PASO 7: Validar configuración ────────────────────
      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      # ── PASO 8: Generar el plan ──────────────────────────
      - name: Terraform Plan
        id: plan
        # Si el plan falla, continuar para poder mostrar el error en el PR
        continue-on-error: true
        run: |
          terraform plan \
            -var-file="environments/${{ env.ENVIRONMENT }}.tfvars" \
            -no-color \
            -input=false \
            2>&1 | tee plan-output.txt

          # Salir con el código de error original de terraform
          exit ${PIPESTATUS[0]}

      # ── PASO 9: Comentar el plan en el PR ────────────────
      - name: Comentar resultado en el PR
        uses: actions/github-script@v7
        # Este paso siempre se ejecuta, incluso si los anteriores fallaron
        if: always()
        env:
          # Capturar el output del plan
          PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
          PLAN_STDERR: ${{ steps.plan.outputs.stderr }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // Función para truncar textos largos
            // Los comentarios de GitHub tienen límite de 65536 caracteres
            const truncate = (str, maxLength) => {
              if (str.length <= maxLength) return str;
              return str.substring(0, maxLength) + '\n... [truncado, ver logs para el plan completo]';
            };

            // Determinar emojis de estado para cada paso
            const fmtResult = '${{ steps.fmt.outcome }}' === 'success' ? '✅' : '❌';
            const validateResult = '${{ steps.validate.outcome }}' === 'success' ? '✅' : '❌';
            const planResult = '${{ steps.plan.outcome }}' === 'success' ? '✅' : '❌';

            // Construir el comentario con el resultado del plan
            const planOutput = process.env.PLAN_OUTPUT || process.env.PLAN_STDERR || 'Sin output';
            const truncatedPlan = truncate(planOutput, 60000);

            const comment = `## Resultado de Terraform Plan 🔍

            | Paso | Estado |
            |------|--------|
            | Formato (fmt) | ${fmtResult} |
            | Validación | ${validateResult} |
            | Plan | ${planResult} |

            <details>
            <summary><b>Ver output completo del plan</b></summary>

            \`\`\`hcl
            ${truncatedPlan}
            \`\`\`

            </details>

            > Workflow: \`${{ github.workflow }}\` | Commit: \`${{ github.event.pull_request.head.sha }}\`
            > Ejecutado por: @${{ github.actor }}`;

            // Buscar si ya existe un comentario nuestro en el PR
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const botComment = comments.find(comment =>
              comment.user.type === 'Bot' &&
              comment.body.includes('Resultado de Terraform Plan')
            );

            // Actualizar comentario existente o crear uno nuevo
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: comment
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: comment
              });
            }

      # ── PASO 10: Fallar si el plan falló ─────────────────
      # Este paso asegura que el workflow falle si el plan falló
      # (necesario porque usamos continue-on-error: true arriba)
      - name: Verificar resultado del plan
        if: steps.plan.outcome == 'failure'
        run: exit 1

Workflow 2: Apply en push a main

# .github/workflows/terraform-apply.yml
# Este workflow se ejecuta cuando se hace merge a main:
# 1. Genera el plan final
# 2. (Opcional) Espera aprobación mediante GitHub Environments
# 3. Aplica los cambios

name: Terraform Apply (Main)

# ─────────────────────────────────────────────────────────────
# EVENTOS: Solo en push a main
# ─────────────────────────────────────────────────────────────
on:
  push:
    branches:
      - main
    paths:
      - 'infrastructure/**'
      - '.github/workflows/terraform-apply.yml'

# ─────────────────────────────────────────────────────────────
# CONCURRENCIA: Solo un apply a la vez en main
# No cancelar en progreso - esperar a que termine
# ─────────────────────────────────────────────────────────────
concurrency:
  group: terraform-apply-main
  cancel-in-progress: false  # Importante: no cancelar applies en curso

permissions:
  contents: read
  id-token: write  # Para OIDC

env:
  TF_VERSION: '1.9.0'
  TF_WORKING_DIR: './infrastructure'
  AWS_REGION: 'us-east-1'

jobs:
  # ──────────────────────────────────────────────────────────
  # JOB 1: Plan
  # ──────────────────────────────────────────────────────────
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest

    outputs:
      # Pasar el resultado del plan al job de apply
      plan-exitcode: ${{ steps.plan.outputs.exitcode }}

    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configurar Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Cachear .terraform
        uses: actions/cache@v4
        with:
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          key: terraform-${{ runner.os }}-${{ hashFiles('**/terraform.lock.hcl') }}

      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=staging/terraform.tfstate" \
            -backend-config="region=${{ env.AWS_REGION }}" \
            -input=false

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan \
            -var-file="environments/staging.tfvars" \
            -out=tfplan \
            -no-color \
            -input=false \
            -detailed-exitcode
          # -detailed-exitcode retorna:
          #   0 = No hay cambios
          #   1 = Error
          #   2 = Hay cambios (el apply procederá)

      # Guardar el plan como artefacto para que el job de apply lo descargue
      - name: Subir plan como artefacto
        uses: actions/upload-artifact@v4
        with:
          name: terraform-plan
          path: ${{ env.TF_WORKING_DIR }}/tfplan
          retention-days: 1  # Solo necesitamos el plan por 1 día

  # ──────────────────────────────────────────────────────────
  # JOB 2: Apply (depende del job Plan)
  # ──────────────────────────────────────────────────────────
  apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    needs: plan  # Este job espera a que "plan" termine exitosamente

    # ENVIRONMENT: Habilita las protecciones de GitHub Environments
    # Configura "staging" en Settings > Environments para añadir:
    # - Revisores requeridos
    # - Wait timer
    # - Branch protections
    environment: staging

    # Solo ejecutar si hay cambios (exitcode 2 del plan)
    if: needs.plan.outputs.plan-exitcode == 2

    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configurar Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Cachear .terraform
        uses: actions/cache@v4
        with:
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          key: terraform-${{ runner.os }}-${{ hashFiles('**/terraform.lock.hcl') }}

      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=staging/terraform.tfstate" \
            -backend-config="region=${{ env.AWS_REGION }}" \
            -input=false

      # Descargar el plan generado por el job anterior
      - name: Descargar plan
        uses: actions/download-artifact@v4
        with:
          name: terraform-plan
          path: ${{ env.TF_WORKING_DIR }}

      - name: Terraform Apply
        run: |
          echo "Aplicando el plan de Terraform..."
          terraform apply -input=false tfplan
          echo "Apply completado exitosamente"

      - name: Mostrar outputs
        run: terraform output -no-color

Usar OIDC para autenticación sin credenciales estáticas

OIDC (OpenID Connect) es el método moderno y más seguro para autenticar GitHub Actions con AWS. En lugar de usar AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY (credenciales estáticas que pueden filtrarse), GitHub Actions obtiene un token temporal que puede asumir un rol de IAM.

Ventajas de OIDC:

  • No hay credenciales de larga duración que rotar o que puedan filtrarse
  • Los tokens expiran automáticamente (duran minutos, no meses)
  • Puedes controlar exactamente qué repositorios y ramas pueden asumir el rol

Configurar OIDC en AWS

# 1. Crear el Identity Provider de GitHub en AWS
aws iam create-open-id-connect-provider \
  --url "https://token.actions.githubusercontent.com" \
  --client-id-list "sts.amazonaws.com" \
  --thumbprint-list "6938fd4d98bab03faadb97b34396831e3780aea1"
// 2. Crear el rol IAM que GitHub Actions puede asumir
// Guardar como trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          // Solo permitir a este repositorio específico asumir el rol
          // Y solo desde la rama main o PRs
          "token.actions.githubusercontent.com:sub": [
            "repo:mi-organizacion/mi-repo:ref:refs/heads/main",
            "repo:mi-organizacion/mi-repo:pull_request"
          ]
        }
      }
    }
  ]
}
# Crear el rol con la política de confianza
aws iam create-role \
  --role-name GitHubActionsTerrformRole \
  --assume-role-policy-document file://trust-policy.json

# Adjuntar permisos de Terraform al rol
aws iam attach-role-policy \
  --role-name GitHubActionsTerrformRole \
  --policy-arn arn:aws:iam::aws:policy/PowerUserAccess

Usar OIDC en el workflow

# Workflow usando OIDC en lugar de credenciales estáticas
name: Terraform con OIDC

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Necesario para solicitar el JWT de OIDC
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Con OIDC, no necesitas secrets de AWS
      # GitHub Actions obtiene automáticamente un token temporal
      - name: Configurar credenciales AWS via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsTerrformRole
          role-session-name: GitHubActionsSession
          aws-region: us-east-1
          # No se necesita aws-access-key-id ni aws-secret-access-key

      - name: Verificar identidad (para debugging)
        run: aws sts get-caller-identity

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.9.0'

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        run: terraform apply -auto-approve

GitHub Environments y aprobaciones manuales

Los GitHub Environments permiten agregar protecciones al deployment en ciertos ambientes, incluyendo aprobaciones manuales requeridas.

Configurar un Environment con aprobación

  1. Ve a Settings > Environments en tu repositorio
  2. Crea un environment llamado produccion
  3. En Protection rules, habilita:
    • Required reviewers: añade los usuarios o teams que deben aprobar
    • Wait timer: tiempo de espera antes de que se pueda aprobar (ej: 5 minutos)
    • Allowed branches: solo main puede deployar a este environment
# Usar el environment con aprobación requerida
jobs:
  apply-produccion:
    name: Apply en Producción
    runs-on: ubuntu-latest
    needs: plan

    # Este job no continuará hasta que un reviewer apruebe
    # La aprobación se hace desde la UI de GitHub
    environment:
      name: produccion
      url: https://mi-app.empresa.com  # URL mostrada en el deployment

    steps:
      - uses: actions/checkout@v4

      # ... pasos de terraform ...

      - name: Terraform Apply en Producción
        run: terraform apply -auto-approve tfplan

      # Registrar el deployment en GitHub
      - name: Registrar deployment exitoso
        if: success()
        run: |
          echo "Deployment a producción completado"
          echo "Versión: ${{ github.sha }}"

Cachear el directorio .terraform

Los providers de Terraform pueden pesar varios cientos de MB. Cachearlos entre runs reduce significativamente el tiempo de ejecución.

- name: Cachear directorio .terraform
  uses: actions/cache@v4
  id: cache-terraform
  with:
    # Qué cachear
    path: |
      ${{ env.TF_WORKING_DIR }}/.terraform
      ~/.terraform.d/plugin-cache
    # Clave del cache: cambia cuando cambia el archivo de lock de providers
    key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
    # Si no hay cache exacto, usar el más reciente con este prefijo
    restore-keys: |
      ${{ runner.os }}-terraform-

# Verificar si se usó el cache
- name: Verificar estado del cache
  run: |
    if [ "${{ steps.cache-terraform.outputs.cache-hit }}" == "true" ]; then
      echo "Cache de providers restaurado exitosamente"
    else
      echo "No había cache disponible, los providers se descargarán"
    fi

# Para mejorar el caching, configurar el directorio de caché de plugins
- name: Configurar caché de plugins de Terraform
  run: |
    mkdir -p ~/.terraform.d/plugin-cache
    cat > ~/.terraformrc << 'EOF'
    plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"
    EOF

Concurrency: evitar runs paralelos

El bloque concurrency es crucial en workflows de Terraform para evitar que dos applies corran al mismo tiempo, lo que podría corromper el estado.

# Patrón 1: Un solo apply a la vez en main, no cancelar el que está en curso
concurrency:
  group: terraform-apply-${{ github.ref }}
  cancel-in-progress: false  # Esperar a que termine el apply actual

# Patrón 2: Para PRs, cancelar el run anterior si hay uno nuevo
# (los plans son seguros para cancelar)
concurrency:
  group: terraform-plan-pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true  # Cancelar el plan anterior y empezar uno nuevo

# Patrón 3: Concurrencia por ambiente para proyectos multi-ambiente
concurrency:
  group: terraform-${{ github.workflow }}-${{ inputs.environment }}
  cancel-in-progress: false

Ejemplo completo de workflow para multi-ambiente

# .github/workflows/terraform-multi-env.yml
# Workflow reutilizable para múltiples ambientes

name: Terraform Multi-Ambiente

on:
  push:
    branches: [main, develop]
    paths: ['infrastructure/**']
  pull_request:
    branches: [main, develop]
    paths: ['infrastructure/**']
  workflow_dispatch:
    inputs:
      environment:
        description: 'Ambiente objetivo'
        required: true
        type: choice
        options: [staging, produccion]
      action:
        description: 'Acción a ejecutar'
        required: true
        type: choice
        options: [plan, apply, destroy]

env:
  TF_VERSION: '1.9.0'
  TF_WORKING_DIR: './infrastructure'

jobs:
  # ──────────────────────────────────────────────────────────
  # Determinar qué ambiente y acción ejecutar
  # ──────────────────────────────────────────────────────────
  setup:
    name: Configurar pipeline
    runs-on: ubuntu-latest
    outputs:
      environment: ${{ steps.config.outputs.environment }}
      action: ${{ steps.config.outputs.action }}

    steps:
      - name: Determinar configuración
        id: config
        run: |
          # Si es un evento manual, usar los inputs
          if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
            echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT
            echo "action=${{ inputs.action }}" >> $GITHUB_OUTPUT

          # Si es un PR, siempre hacer plan en staging
          elif [ "${{ github.event_name }}" == "pull_request" ]; then
            echo "environment=staging" >> $GITHUB_OUTPUT
            echo "action=plan" >> $GITHUB_OUTPUT

          # Si es push a main, apply en staging
          elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
            echo "environment=staging" >> $GITHUB_OUTPUT
            echo "action=apply" >> $GITHUB_OUTPUT

          # Si es push a develop, solo plan
          else
            echo "environment=staging" >> $GITHUB_OUTPUT
            echo "action=plan" >> $GITHUB_OUTPUT
          fi

  # ──────────────────────────────────────────────────────────
  # Terraform Init y Validate (común para todos los ambientes)
  # ──────────────────────────────────────────────────────────
  validate:
    name: Validar código
    runs-on: ubuntu-latest
    needs: setup

    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Cachear .terraform
        uses: actions/cache@v4
        with:
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          key: ${{ runner.os }}-tf-${{ hashFiles('**/.terraform.lock.hcl') }}

      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=${{ needs.setup.outputs.environment }}/terraform.tfstate" \
            -backend-config="region=us-east-1" \
            -input=false

      - name: Terraform Format Check
        run: terraform fmt -check -recursive

      - name: Terraform Validate
        run: terraform validate

  # ──────────────────────────────────────────────────────────
  # Terraform Plan
  # ──────────────────────────────────────────────────────────
  plan:
    name: Terraform Plan (${{ needs.setup.outputs.environment }})
    runs-on: ubuntu-latest
    needs: [setup, validate]

    outputs:
      exitcode: ${{ steps.plan.outputs.exitcode }}

    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - uses: actions/cache@v4
        with:
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          key: ${{ runner.os }}-tf-${{ hashFiles('**/.terraform.lock.hcl') }}

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=${{ needs.setup.outputs.environment }}/terraform.tfstate" \
            -backend-config="region=us-east-1" \
            -input=false

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan \
            -var-file="environments/${{ needs.setup.outputs.environment }}.tfvars" \
            -out=tfplan \
            -no-color \
            -input=false \
            -detailed-exitcode

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: tfplan-${{ needs.setup.outputs.environment }}
          path: ${{ env.TF_WORKING_DIR }}/tfplan
          retention-days: 1

  # ──────────────────────────────────────────────────────────
  # Comentar plan en PR (solo para pull_request)
  # ──────────────────────────────────────────────────────────
  comment-pr:
    name: Comentar plan en PR
    runs-on: ubuntu-latest
    needs: [setup, plan]
    if: github.event_name == 'pull_request'

    permissions:
      pull-requests: write

    steps:
      - uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const exitcode = '${{ needs.plan.outputs.exitcode }}';
            const status = exitcode === '0' ? '✅ Sin cambios' :
                           exitcode === '2' ? '🔄 Hay cambios pendientes' :
                                             '❌ Error en el plan';

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `## Terraform Plan - ${{ needs.setup.outputs.environment }}

              **Estado**: ${status}

              Ver el output completo en: [${context.runId}](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${context.runId})
              `
            });

  # ──────────────────────────────────────────────────────────
  # Terraform Apply (solo si hay cambios y la acción es apply)
  # ──────────────────────────────────────────────────────────
  apply:
    name: Terraform Apply (${{ needs.setup.outputs.environment }})
    runs-on: ubuntu-latest
    needs: [setup, plan]

    environment: ${{ needs.setup.outputs.environment }}

    if: |
      needs.setup.outputs.action == 'apply' &&
      needs.plan.outputs.exitcode == '2'

    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - uses: actions/cache@v4
        with:
          path: ${{ env.TF_WORKING_DIR }}/.terraform
          key: ${{ runner.os }}-tf-${{ hashFiles('**/.terraform.lock.hcl') }}

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=${{ needs.setup.outputs.environment }}/terraform.tfstate" \
            -backend-config="region=us-east-1" \
            -input=false

      - uses: actions/download-artifact@v4
        with:
          name: tfplan-${{ needs.setup.outputs.environment }}
          path: ${{ env.TF_WORKING_DIR }}

      - name: Terraform Apply
        run: terraform apply -input=false tfplan

      - name: Terraform Output
        run: terraform output -no-color

Ejercicio práctico

Enunciado

Crea un workflow de GitHub Actions que implemente el siguiente flujo:

  1. Cuando se abre o actualiza un Pull Request:

    • Ejecuta terraform fmt -check
    • Ejecuta terraform validate
    • Ejecuta terraform plan para el ambiente staging
    • Comenta el resultado del plan en el PR (con el resumen de cambios)
    • Falla el check si hay errores de formato o el plan falla
  2. Cuando se hace merge a main:

    • Ejecuta terraform apply automáticamente en staging
    • Si el apply es exitoso, crea un deployment de GitHub con el estado "success"
    • Notifica el resultado

Los secretos disponibles son:

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • TF_STATE_BUCKET

El código Terraform está en el directorio infra/.

Solución completa

# .github/workflows/terraform.yml
name: Terraform CI/CD

on:
  pull_request:
    branches: [main]
    paths: ['infra/**', '.github/workflows/terraform.yml']
  push:
    branches: [main]
    paths: ['infra/**', '.github/workflows/terraform.yml']

permissions:
  contents: read
  pull-requests: write
  deployments: write

concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  TF_VERSION: '1.9.0'
  AWS_REGION: 'us-east-1'
  ENVIRONMENT: 'staging'

jobs:
  # ──────────────────────────────────────────────────────────
  # Job único que maneja tanto PR como push a main
  # ──────────────────────────────────────────────────────────
  terraform:
    name: Terraform
    runs-on: ubuntu-latest

    # Solo usar el environment de staging cuando es push a main
    # Esto habilita las protecciones configuradas en GitHub
    environment: ${{ github.event_name == 'push' && 'staging' || '' }}

    defaults:
      run:
        working-directory: infra

    steps:
      # ── Checkout ─────────────────────────────────────────
      - name: Checkout
        uses: actions/checkout@v4

      # ── Setup Terraform ──────────────────────────────────
      - name: Configurar Terraform ${{ env.TF_VERSION }}
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: true

      # ── Cache providers ──────────────────────────────────
      - name: Cachear providers de Terraform
        uses: actions/cache@v4
        with:
          path: infra/.terraform
          key: ${{ runner.os }}-tf-${{ hashFiles('infra/.terraform.lock.hcl') }}
          restore-keys: ${{ runner.os }}-tf-

      # ── Credenciales AWS ─────────────────────────────────
      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # ── Terraform Init ───────────────────────────────────
      - name: Terraform Init
        id: init
        run: |
          terraform init \
            -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
            -backend-config="key=${{ env.ENVIRONMENT }}/terraform.tfstate" \
            -backend-config="region=${{ env.AWS_REGION }}" \
            -input=false \
            -no-color

      # ── Formato ──────────────────────────────────────────
      - name: Terraform Format Check
        id: fmt
        continue-on-error: true
        run: terraform fmt -check -recursive -no-color

      # ── Validación ───────────────────────────────────────
      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      # ── Plan ─────────────────────────────────────────────
      - name: Terraform Plan
        id: plan
        continue-on-error: true
        run: |
          terraform plan \
            -var-file="environments/${{ env.ENVIRONMENT }}.tfvars" \
            -out=tfplan \
            -no-color \
            -input=false

      # ── Comentar en PR ───────────────────────────────────
      - name: Publicar resultado en PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        env:
          PLAN: "${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const fmtOk = '${{ steps.fmt.outcome }}' === 'success';
            const validateOk = '${{ steps.validate.outcome }}' === 'success';
            const planOk = '${{ steps.plan.outcome }}' === 'success';

            const planOutput = process.env.PLAN || '(sin output)';

            // Crear resumen con tabla de estado
            const body = `## 📋 Resultado de Terraform Plan

            | Verificación | Resultado |
            |---|---|
            | Formato (fmt) | ${fmtOk ? '✅ Correcto' : '❌ Incorrecto - ejecuta \`terraform fmt\`'} |
            | Validación | ${validateOk ? '✅ Válido' : '❌ Inválido'} |
            | Plan | ${planOk ? '✅ Exitoso' : '❌ Falló'} |

            <details>
            <summary>📄 Ver output completo del plan</summary>

            \`\`\`
            ${planOutput.substring(0, 60000)}
            \`\`\`

            </details>

            ---
            *Commit: \`${{ github.event.pull_request.head.sha }}\` | Ambiente: \`${{ env.ENVIRONMENT }}\`*`;

            // Buscar y actualizar comentario existente
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number
            });

            const existing = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Resultado de Terraform Plan')
            );

            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body
              });
            }

      # ── Fallar si el plan o fmt fallaron ─────────────────
      - name: Verificar resultados críticos
        run: |
          if [ "${{ steps.fmt.outcome }}" == "failure" ]; then
            echo "Error: El código no está formateado correctamente"
            echo "Ejecuta 'terraform fmt -recursive' y haz commit"
            exit 1
          fi
          if [ "${{ steps.plan.outcome }}" == "failure" ]; then
            echo "Error: terraform plan falló"
            exit 1
          fi

      # ── Apply (solo en push a main) ───────────────────────
      - name: Terraform Apply
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          echo "Aplicando cambios en ${{ env.ENVIRONMENT }}..."
          terraform apply -input=false tfplan
          echo "Apply completado exitosamente"

      # ── Outputs post-apply ────────────────────────────────
      - name: Mostrar outputs de Terraform
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: terraform output -no-color

      # ── Crear deployment en GitHub ────────────────────────
      - name: Registrar deployment exitoso en GitHub
        if: |
          github.event_name == 'push' &&
          github.ref == 'refs/heads/main' &&
          success()
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // Crear el deployment
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: '${{ env.ENVIRONMENT }}',
              description: 'Terraform apply exitoso',
              auto_merge: false,
              required_contexts: []
            });

            // Actualizar el estado del deployment
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deployment.data.id,
              state: 'success',
              description: 'Infraestructura actualizada exitosamente',
              environment_url: 'https://staging.mi-app.com'
            });

Errores comunes con GitHub Actions y Terraform

Error 1: GITHUB_TOKEN no tiene permisos para comentar en PRs

Problema: Resource not accessible by integration

Solución: Añadir los permisos correctos al workflow.

# En el nivel del workflow o del job
permissions:
  contents: read
  pull-requests: write  # Necesario para comentar en PRs
  id-token: write       # Necesario para OIDC

Error 2: Cache no se restaura correctamente

Problema: Los providers se descargan en cada run a pesar del cache.

Solución: Verificar que el path del cache es correcto y que el archivo .terraform.lock.hcl existe y está commiteado.

# Asegúrate de commitear el archivo de lock
git add infra/.terraform.lock.hcl
git commit -m "Añadir lock file de Terraform"

# El archivo .terraform.lock.hcl se crea automáticamente con:
terraform init
# o con:
terraform providers lock

Error 3: Runs paralelos corrompen el estado

Problema: Dos PRs mergeados casi al mismo tiempo disparan dos terraform apply simultáneos.

Solución: Usar el bloque concurrency con cancel-in-progress: false para applies.

concurrency:
  group: terraform-apply-${{ github.ref }}
  cancel-in-progress: false  # NUNCA cancelar un apply en curso

Error 4: Secretos no disponibles en PRs de forks

Problema: Los workflows de PRs de forks externos no tienen acceso a los secretos del repositorio (medida de seguridad de GitHub).

Solución: Separar el workflow de PR en dos partes: uno que corre sin secretos (el plan) y otro que corre con secretos cuando se comenta en el PR (usando pull_request_target).

# Alternativa: usar pull_request_target (con cuidado por implicaciones de seguridad)
on:
  pull_request_target:  # Tiene acceso a secretos, pero puede ser peligroso
    types: [opened, synchronize]

Error 5: Terraform wrapper causa problemas con scripts

Problema: El terraform_wrapper: true modifica el comportamiento de salida de Terraform, causando problemas en scripts que comprueban el exit code.

# Si tienes problemas con el wrapper, desactívalo
- uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: '1.9.0'
    terraform_wrapper: false  # Desactivar el wrapper

# Y captura el output manualmente
- name: Terraform Plan
  id: plan
  run: |
    OUTPUT=$(terraform plan -no-color -out=tfplan 2>&1)
    EXIT_CODE=$?
    echo "stdout<<EOF" >> $GITHUB_OUTPUT
    echo "$OUTPUT" >> $GITHUB_OUTPUT
    echo "EOF" >> $GITHUB_OUTPUT
    exit $EXIT_CODE

Resumen

En este módulo aprendiste a:

  • Estructurar workflows de GitHub Actions para Terraform con separación clara entre plan y apply.
  • Usar hashicorp/setup-terraform para instalar la versión correcta de Terraform en los runners.
  • Configurar credenciales de AWS tanto con secretos estáticos como con OIDC (el método moderno y más seguro).
  • Implementar el patrón plan-en-PR, apply-en-merge con comentarios automáticos que muestran el plan directamente en el Pull Request.
  • Usar GitHub Environments para agregar aprobaciones manuales antes de deployar a producción.
  • Cachear los providers de Terraform para reducir el tiempo de ejecución de los workflows.
  • Controlar la concurrencia para evitar que múltiples applies corran simultáneamente.
  • Construir un workflow multi-ambiente que determina automáticamente qué ambiente y acción usar según el evento que disparó el workflow.
  • Resolver errores comunes como permisos del token, cache fallido y runs paralelos.

En el próximo y último módulo de esta sección veremos Terraform Cloud y Terraform Enterprise, soluciones gestionadas que integran todas estas funcionalidades de CI/CD directamente en la plataforma de HashiCorp, eliminando la necesidad de configurar y mantener workflows de CI/CD manualmente.

© Copyright 2026. Todos los derechos reservados