Terraform v CI/CD: GitHub Actions, Atlantis a Pull Request Workflow
Vybudujte profesionální potrubí Terraform: z automatických vláken a ověřte s pracovním postupem GitHub Actions to Pull Request s Atlantis pro plán/aplikaci kolaborativní, až do plánované detekce posunu.
Proč Terraform vyžaduje speciální potrubí
Běh terraform apply ručně z vývojářského notebooku
nejnebezpečnější model v infrastruktuře. Bez automatizovaného workflow problémy
typické jsou: stavový soubor není aktualizován, místní přihlašovací údaje odlišné od prostředí CI,
žádná kontrola plánu před podáním žádosti, žádná stopa toho, kdo co a kdy použil.
Profesionální potrubí Terraform řeší všechny tyto problémy: plán přichází automaticky vygenerováno, zkontrolováno v žádosti o stažení a k žádosti dojde až poté schválení, s trvalými záznamy ve verzovacím systému. V tomto článku Toto potrubí budujeme dvěma přístupy: Akce GitHubu pro týmy malé a Atlantis pro střední a velké týmy.
Co se naučíte
- Průběh akcí GitHub: fmt, validovat, tflint, plánovat PR a použít při sloučení
- Bezpečná cloudová správa pověření pomocí OIDC (žádná dlouhotrvající tajemství)
- Atlantis: instalace, atlantis.yaml a společný PR workflow
- Terraform Cloud / HCP Terraform jako spravovaná alternativa
- Detekce posunu: úloha cron pro detekci nesrovnalostí mezi stavem a skutečnou infrastrukturou
- Matrix strategie pro multi-prostředí potrubí (dev/staging/prod)
- Oznámení Slack/Teams o plánech a upozornění na posun
Struktura úložiště Terraform
Před vybudováním potrubí potřebujete jasnou strukturu úložiště. Vzor nejběžnější je oddělení podle prostředí do odlišných adresářů, s moduly sdílenými v samostatném adresáři.
terraform-infra/
modules/
vpc/
main.tf
variables.tf
outputs.tf
eks-cluster/
rds-postgres/
environments/
dev/
main.tf # Usa i moduli con variabili dev
variables.tf
terraform.tfvars
backend.tf # Backend S3 per dev
staging/
main.tf
terraform.tfvars
backend.tf
production/
main.tf
terraform.tfvars
backend.tf
.terraform-version # tfenv: specifica la versione di Terraform
.tflint.hcl # Configurazione tflint
.github/
workflows/
terraform.yml
# .terraform-version
# Usato da tfenv per installare la versione corretta
1.7.5
Akce GitHubu: Kompletní kanál
Námi vytvořený kanál akcí GitHub má dva pracovní postupy: jeden spuštěný Pull Requests (lint + plán) a jeden spuštěný sloučením na main (použít).
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
push:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
pull_request:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
permissions:
contents: read
pull-requests: write # Per commentare il plan sul PR
id-token: write # Per OIDC authentication con AWS
env:
TF_VERSION: '1.7.5'
TFLINT_VERSION: 'v0.50.0'
jobs:
# Job 1: Lint e validazione statica (tutti gli ambienti in parallelo)
lint-validate:
name: Lint & Validate (${{ matrix.environment }})
runs-on: ubuntu-latest
strategy:
matrix:
environment: [dev, staging, production]
fail-fast: false
defaults:
run:
working-directory: environments/${{ matrix.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: ${{ env.TFLINT_VERSION }}
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: .
- name: Terraform Init (solo backend locale per lint)
run: terraform init -backend=false
- name: Terraform Validate
run: terraform validate
- name: TFLint
run: |
tflint --init
tflint --format compact
# Job 2: Plan su Pull Request
plan:
name: Plan (${{ matrix.environment }})
runs-on: ubuntu-latest
needs: lint-validate
if: github.event_name == 'pull_request'
strategy:
matrix:
environment: [dev, staging] # Non pianifichiamo prod su ogni PR
fail-fast: false
defaults:
run:
working-directory: environments/${{ matrix.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
# OIDC: nessun secret long-lived necessario
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
aws-region: eu-west-1
- name: Terraform Init
run: terraform init
- name: Terraform Plan
id: plan
run: |
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
echo "PLAN_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"
# Commenta il plan sul Pull Request
- name: Comment Plan on PR
uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const planOutput = fs.readFileSync('environments/${{ matrix.environment }}/plan_output.txt', 'utf8');
const truncated = planOutput.length > 65000
? planOutput.substring(0, 65000) + '\n... (truncated)'
: planOutput;
const body = `## Terraform Plan - ${{ matrix.environment }}
\`\`\`
${truncated}
\`\`\`
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
// Cerca commenti precedenti dello stesso workflow e aggiorna
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.data.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Terraform Plan - ${{ matrix.environment }}')
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# Job 3: Apply su merge in main
apply:
name: Apply (${{ matrix.environment }})
runs-on: ubuntu-latest
needs: lint-validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
strategy:
# Apply sequenziale: prima dev, poi staging, poi production
max-parallel: 1
matrix:
environment: [dev, staging, production]
environment:
name: ${{ matrix.environment }} # Richiede approvazione manuale per production
defaults:
run:
working-directory: environments/${{ matrix.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
aws-region: eu-west-1
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve -no-color
OIDC: Pověření bez tajemství dlouhotrvající
Moderní osvědčený postup pro ověřování akcí GitHub na AWS je
OIDC (OpenID Connect): GitHub vygeneruje podepsaný token JWT, který AWS
přijmout, aniž byste se museli učit nazpaměť AWS_ACCESS_KEY_ID v tajemstvích.
# Configurazione IAM Role per GitHub Actions OIDC
# Crea questo con Terraform stesso (bootstrap)
# iam.tf
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1"
]
}
resource "aws_iam_role" "github_actions_terraform" {
for_each = toset(["dev", "staging", "production"])
name = "GitHubActionsterraform-${each.key}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Permetti solo da questo repo specifico
"token.actions.githubusercontent.com:sub" = "repo:myorg/terraform-infra:*"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "terraform_permissions" {
for_each = aws_iam_role.github_actions_terraform
role = each.value.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
# In produzione usa policy più ristrette con least-privilege
}
Atlantis: Plán/aplikace spolupráce prostřednictvím PR
Atlantis je server, který naslouchá webhookům GitHub/GitLab/Bitbucket
a odpovídá na komentáře v PR. Když píšeš atlantis plan v komentáři,
Atlantis popraví terraform plan a odpoví výstupem. Tím vznikne a
workflow plně sledovatelné a kontrolovatelné v PR.
# atlantis.yaml - Configurazione nella root del repository
version: 3
# Abilita auto-planning: piano automatico quando il PR tocca file .tf
automerge: false
parallel_plan: true
parallel_apply: false
projects:
- name: dev
dir: environments/dev
workspace: default
autoplan:
when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
enabled: true
apply_requirements:
- approved # Richiede almeno 1 approvazione
- mergeable # Il PR deve essere mergeable
- name: staging
dir: environments/staging
workspace: default
autoplan:
when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
enabled: true
apply_requirements:
- approved
- mergeable
- name: production
dir: environments/production
workspace: default
autoplan:
enabled: false # Non pianifica automaticamente in prod
apply_requirements:
- approved # Richiede almeno 2 approvazioni
- mergeable
allowed_override_dentists: # Solo questi utenti possono fare apply in prod
- alice
- bob
# Comandi Atlantis nel Pull Request (come commento)
# Esegui plan per il progetto 'dev'
atlantis plan -p dev
# Esegui plan per tutti i progetti modificati
atlantis plan
# Applica dopo approvazione del PR
atlantis apply -p dev
# Annulla un plan in corso
atlantis unlock
# Mostra lo stato attuale
atlantis status
# docker-compose.yml per Atlantis self-hosted
version: '3.8'
services:
atlantis:
image: ghcr.io/runatlantis/atlantis:v0.27.0
ports:
- "4141:4141"
environment:
ATLANTIS_GH_USER: atlantis-bot # GitHub user del bot
ATLANTIS_GH_TOKEN: ${GH_TOKEN} # Personal Access Token del bot
ATLANTIS_GH_WEBHOOK_SECRET: ${WEBHOOK_SECRET}
ATLANTIS_REPO_ALLOWLIST: "github.com/myorg/terraform-infra"
ATLANTIS_AUTOMERGE: "false"
ATLANTIS_WRITE_GIT_CREDS: "true"
# Credenziali AWS (oppure usa IAM Instance Profile)
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION: eu-west-1
volumes:
- atlantis-data:/home/atlantis
- ./atlantis.yaml:/home/atlantis/atlantis.yaml:ro
volumes:
atlantis-data:
Detekce posunu: Cron Job pro detekci nesrovnalostí
Il drift je to tehdy, když se skutečná infrastruktura liší z toho, co je popsáno ve stavovém souboru Terraform. Dochází k driftu když někdo ručně upravuje prostředky z konzoly AWS, když zdroj se změní externím procesem nebo když cloudové rozhraní API změní chování.
# .github/workflows/drift-detection.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 8 * * 1-5' # Ogni giorno lavorativo alle 8:00 UTC
workflow_dispatch: # Esecuzione manuale
permissions:
contents: read
issues: write # Per aprire issue se drift rilevato
id-token: write
jobs:
detect-drift:
name: Detect Drift (${{ matrix.environment }})
runs-on: ubuntu-latest
strategy:
matrix:
environment: [dev, staging, production]
fail-fast: false
defaults:
run:
working-directory: environments/${{ matrix.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: '1.7.5'
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsReadOnly-${{ matrix.environment }}
aws-region: eu-west-1
- name: Terraform Init
run: terraform init -no-color
- name: Terraform Plan (Drift Detection)
id: drift
run: |
# Esci con codice 0 se no changes, 1 se error, 2 se changes (drift)
terraform plan -detailed-exitcode -no-color 2>&1 | tee drift_output.txt
echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"
- name: Alert on Drift
if: env.EXIT_CODE == '2'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const output = fs.readFileSync('environments/${{ matrix.environment }}/drift_output.txt', 'utf8');
// Crea un issue GitHub per il drift rilevato
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '[DRIFT] Infrastructure drift detected in ${{ matrix.environment }}',
body: `## Drift rilevato in ambiente: ${{ matrix.environment }}
Data: ${new Date().toISOString()}
\`\`\`
${output.substring(0, 60000)}
\`\`\`
**Azione richiesta:** Analizza le modifiche e riconcilia lo state.
- Se la modifica è intenzionale: aggiorna il codice Terraform e fai un PR
- Se è una modifica non autorizzata: ripristina con \`terraform apply\``,
labels: ['infrastructure', 'drift', '${{ matrix.environment }}'],
});
- name: Notify Slack on Drift
if: env.EXIT_CODE == '2'
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": ":warning: Terraform drift rilevato in *${{ matrix.environment }}*!\nControlla il <${{ github.server_url }}/${{ github.repository }}/issues|GitHub Issue> creato.",
"channel": "#infra-alerts"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
TFLint: Advanced Linting pro HCL
# .tflint.hcl - Configurazione TFLint
plugin "aws" {
enabled = true
version = "0.29.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
plugin "azurerm" {
enabled = true
version = "0.26.0"
source = "github.com/terraform-linters/tflint-ruleset-azurerm"
}
# Regole generali
rule "terraform_deprecated_interpolation" {
enabled = true
}
rule "terraform_documented_outputs" {
enabled = true
}
rule "terraform_documented_variables" {
enabled = true
}
rule "terraform_naming_convention" {
enabled = true
format = "snake_case"
}
rule "terraform_required_providers" {
enabled = true
}
rule "terraform_required_version" {
enabled = true
}
# Regole AWS specifiche
rule "aws_instance_invalid_type" {
enabled = true
}
rule "aws_instance_previous_type" {
enabled = true
}
Terraform Cloud: Spravovaná alternativa
Pokud nechcete spravovat Atlantis sami, HCP Terraform (dříve Terraform Cloud) nabízí vzdálený plán/používání, správu stavu, jednotné přihlášení a politiku (Sentinel) jako službu.
# terraform.tf - Configurazione HCP Terraform come backend
terraform {
required_version = "~> 1.7"
# HCP Terraform (gestisce state, plan e apply in cloud)
cloud {
organization = "my-organization"
workspaces {
# Workspace per environment con tagging
tags = ["terraform-infra", "aws"]
}
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Con HCP Terraform:
# - Nessun backend S3 da gestire
# - Plan/Apply avvengono nei server HCP (non in CI)
# - Variabili e secrets gestiti nel workspace
# - VCS integration: trigger automatico da PR
# - Team-based access control
# - Run history permanente
Nejlepší postupy pro potrubí Terraform ve výrobě
Kontrolní seznam potrubí Terraform
- USA OIDC místo dlouhodobých pověření AWS v tajných klíčích CI
- Běh
terraform fmt -checkzablokovat PR pomocí neformátovaného kódu - USA
terraform validatepřed plánem zachytit syntaktické chyby - Uložte binární plán (
-out=tfplan) a použijte to k aplikaci, neregeneruje se - Run se aplikuje v sekvenčním pořadí (dev → staging → prod), nikoli paralelně
- Vyžádejte si ruční schválení produkce s pravidly ochrany prostředí GitHub
- Implementujte plánovanou detekci posunu pro detekci manuálních změn
- Přidat
prevent_destroy = truena kritických stavových zdrojích - Zaznamenejte všechny plány a použijte časové razítko a autora v protokolu CI
Závěry a další kroky
Dobře navržené potrubí Terraform je základem infrastruktury bezpečné a sledovatelné. Pracovní postup plán-revidovat-použít v PR zajišťuje, že žádný změna infrastruktury probíhá bez kontroly, zatímco detekce posunu Zachyťte neplánované změny dříve, než se stanou problémy.
Další články v seriálu
- Článek 5: Testování IaC — Terratest, Terraform Test Nativní e Smluvní testování: Jak psát automatizované testy pro vaše moduly Terraform.
- Článek 6: Zabezpečení IaC — Checkov, Trivy a OPA Policy-as-Code: integruje bezpečnostní skenování do předaplikační brány potrubí.







