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.exitcodeConfigurar secretos de AWS en GitHub
Los secretos en GitHub se configuran en el repositorio (o en la organización para compartirlos):
- Ve a tu repositorio en GitHub
- Navega a Settings > Secrets and variables > Actions
- Haz clic en New repository secret
- Crea los siguientes secretos:
AWS_ACCESS_KEY_ID: ID de la clave de acceso de AWSAWS_SECRET_ACCESS_KEY: Clave secreta de AWSTF_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-identityTabla: 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 1Workflow 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-colorUsar 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/PowerUserAccessUsar 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-approveGitHub 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
- Ve a Settings > Environments en tu repositorio
- Crea un environment llamado
produccion - 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
mainpuede 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"
EOFConcurrency: 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: falseEjemplo 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-colorEjercicio práctico
Enunciado
Crea un workflow de GitHub Actions que implemente el siguiente flujo:
-
Cuando se abre o actualiza un Pull Request:
- Ejecuta
terraform fmt -check - Ejecuta
terraform validate - Ejecuta
terraform planpara el ambientestaging - 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
- Ejecuta
-
Cuando se hace merge a
main:- Ejecuta
terraform applyautomáticamente enstaging - Si el apply es exitoso, crea un deployment de GitHub con el estado "success"
- Notifica el resultado
- Ejecuta
Los secretos disponibles son:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYTF_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 OIDCError 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 lockError 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 cursoError 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_CODEResumen
En este módulo aprendiste a:
- Estructurar workflows de GitHub Actions para Terraform con separación clara entre plan y apply.
- Usar
hashicorp/setup-terraformpara 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.
Curso de Terraform
Módulo 1: Introducción a Terraform
- ¿Qué es Terraform?
- Instalando Terraform
- Conceptos Básicos de Terraform
- Primera Configuración de Terraform
Módulo 2: Lenguaje de Configuración de Terraform
Módulo 3: Gestión del Estado
Módulo 4: Módulos de Terraform
Módulo 5: Aprovisionamiento de Recursos
- Conceptos Básicos de Aprovisionamiento
- Aprovisionamiento de Recursos AWS
- Aprovisionamiento de Recursos Azure
- Aprovisionamiento de Recursos GCP
Módulo 6: Funcionalidades Avanzadas de Terraform
Módulo 7: Mejores Prácticas de Terraform
- Organización del Código
- Control de Versiones
- Pruebas del Código de Terraform
- Mejores Prácticas de Seguridad
Módulo 8: Terraform en CI/CD
- Integración de Terraform con CI/CD
- Automatización de Terraform con Jenkins
- Uso de Terraform con GitHub Actions
- Terraform Cloud y Enterprise
