Os artigos anteriores apresentaram o Terraform e o Ansible como ferramentas distintas. Este artigo demonstra como combiná-las em um fluxo de trabalho coeso — o que muitos times chamam de pipeline de IaC completo.
A divisão de responsabilidades é clara: o Terraform cria a infraestrutura e o Ansible a configura. Mas a fronteira entre os dois não é sempre óbvia na prática. Algumas perguntas comuns:
Devo instalar o Docker com Terraform user_data ou com Ansible? — Com Ansible. O user_data do Terraform é executado apenas uma vez, no momento da criação da instância. O Ansible pode ser reexecutado quantas vezes for necessário, garantindo que o estado do servidor converge para o desejado mesmo após mudanças manuais ou falhas parciais.
Devo criar usuários IAM com Ansible ou Terraform? — Com Terraform. Usuários IAM são recursos de infraestrutura gerenciados pela API da AWS. O Ansible não é a ferramenta natural para esse tipo de recurso.
Como o Ansible descobre os IPs dos servidores que o Terraform criou? — Essa é a integração central que este artigo demonstra.
Estrutura do Projeto Integrado
infraestrutura-completa/
├── terraform/
│ ├── modules/
│ │ ├── vpc/
│ │ └── rds/
│ ├── environments/
│ │ ├── staging/
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── terraform.tfvars
│ │ └── production/
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ └── scripts/
│ └── gerar-inventory.sh
│
├── ansible/
│ ├── inventory/
│ │ ├── staging/
│ │ │ ├── hosts.yml # Gerado pelo Terraform
│ │ │ └── group_vars/
│ │ └── production/
│ │ ├── hosts.yml # Gerado pelo Terraform
│ │ └── group_vars/
│ ├── roles/
│ │ ├── base/
│ │ ├── docker/
│ │ ├── nginx/
│ │ └── aplicacao/
│ ├── playbooks/
│ │ ├── site.yml
│ │ ├── deploy.yml
│ │ └── hardening.yml
│ └── ansible.cfg
│
└── scripts/
├── provisionar.sh # Terraform apply
├── configurar.sh # Ansible playbook
└── pipeline-completo.sh # Os dois em sequência
Gerando o Inventory Ansible a Partir dos Outputs do Terraform
A integração mais direta entre Terraform e Ansible é usar os outputs do Terraform para gerar o inventory do Ansible. O Terraform conhece os IPs dos servidores que acabou de criar — basta exportar esse conhecimento em um formato que o Ansible entenda.
Outputs do Terraform preparados para o Ansible:
# terraform/environments/staging/outputs.tf
output "webservers" {
description = "Informações dos servidores web para o inventory Ansible"
value = {
hosts = {
for idx, instance in aws_instance.web :
"web${format("%02d", idx + 1)}" => {
ansible_host = instance.public_ip
ansible_user = "ubuntu"
instance_id = instance.id
}
}
vars = {
app_port = 3000
nginx_port = 80
environment = "staging"
}
}
}
output "databases" {
description = "Informações dos servidores de banco de dados"
sensitive = true
value = {
vars = {
postgres_host = aws_db_instance.principal.address
postgres_port = aws_db_instance.principal.port
postgres_db = aws_db_instance.principal.db_name
postgres_user = var.db_username
postgres_password = var.db_password
}
}
}
output "bastion_host" {
description = "IP do bastion host para acesso SSH aos servidores privados"
value = aws_eip.bastion.public_ip
}
Script que converte os outputs do Terraform em inventory YAML:
#!/bin/bash
# terraform/scripts/gerar-inventory.sh
set -euo pipefail
AMBIENTE=${1:-staging}
TERRAFORM_DIR="terraform/environments/$AMBIENTE"
INVENTORY_DIR="ansible/inventory/$AMBIENTE"
echo "Gerando inventory Ansible para o ambiente: $AMBIENTE"
# Garante que o diretório do inventory existe
mkdir -p "$INVENTORY_DIR"
# Lê os outputs do Terraform em JSON
cd "$TERRAFORM_DIR"
OUTPUTS=$(terraform output -json)
cd - > /dev/null
# Extrai o IP do bastion host
BASTION_IP=$(echo "$OUTPUTS" | jq -r '.bastion_host.value')
# Extrai informações dos webservers
WEBSERVERS=$(echo "$OUTPUTS" | jq -r '.webservers.value')
# Extrai variáveis do banco de dados
DB_HOST=$(echo "$OUTPUTS" | jq -r '.databases.value.vars.postgres_host')
DB_PORT=$(echo "$OUTPUTS" | jq -r '.databases.value.vars.postgres_port')
# Gera o arquivo hosts.yml
cat > "$INVENTORY_DIR/hosts.yml" << EOF
---
all:
vars:
ansible_python_interpreter: /usr/bin/python3
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
ansible_ssh_common_args: >-
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ProxyJump=ubuntu@${BASTION_IP}
ambiente: ${AMBIENTE}
children:
webservers:
hosts:
$(echo "$WEBSERVERS" | jq -r '
.hosts | to_entries[] |
" \(.key):\n ansible_host: \(.value.ansible_host)\n ansible_user: \(.value.ansible_user)\n instance_id: \(.value.instance_id)"
')
vars:
$(echo "$WEBSERVERS" | jq -r '
.vars | to_entries[] |
" \(.key): \(.value)"
')
databases:
hosts:
db01:
ansible_host: ${DB_HOST}
ansible_user: ubuntu
ansible_port: 22
vars:
postgres_host: ${DB_HOST}
postgres_port: ${DB_PORT}
EOF
echo "Inventory gerado em: $INVENTORY_DIR/hosts.yml"
echo ""
echo "Hosts configurados:"
cat "$INVENTORY_DIR/hosts.yml"
Inventory Dinâmico com o Plugin AWS EC2
Para ambientes que mudam frequentemente — com Auto Scaling Groups — o inventory estático gerado pelo Terraform rapidamente fica desatualizado. O Ansible oferece um inventory dinâmico que consulta a AWS diretamente no momento da execução:
# ansible/inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
# Filtra apenas instâncias em execução
filters:
instance-state-name: running
tag:Environment: staging
# Define os grupos com base em tags das instâncias
keyed_groups:
- prefix: tag
key: tags.Role
- prefix: tag
key: tags.Environment
# Usa o IP privado como hostname (acesso via bastion)
hostnames:
- private-ip-address
# Variáveis disponíveis em cada host
compose:
ansible_host: private_ip_address
ansible_user: "'ubuntu'"
instance_id: instance_id
instance_type: instance_type
# Configura o acesso via bastion host
vars:
ansible_ssh_common_args: >-
-o StrictHostKeyChecking=no
-o ProxyJump=ubuntu@{{ bastion_ip }}
Para usar o inventory dinâmico, é necessário instalar a coleção AWS:
# Instala a coleção oficial da AWS para Ansible
ansible-galaxy collection install amazon.aws
# Testa o inventory dinâmico
ansible-inventory \
-i ansible/inventory/aws_ec2.yml \
--list
# Executa um playbook usando o inventory dinâmico
ansible-playbook \
-i ansible/inventory/aws_ec2.yml \
ansible/playbooks/site.yml
O Playbook Completo de Configuração Inicial
Com o inventory gerado, o playbook de configuração inicial prepara os servidores do zero:
# ansible/playbooks/site.yml
---
# ── Play 1: Configurações base em todos os servidores ──
- name: Configuração base
hosts: all
become: true
gather_facts: true
pre_tasks:
- name: Aguarda o SSH estar disponível
ansible.builtin.wait_for_connection:
delay: 10
timeout: 120
- name: Atualiza o cache do apt
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
roles:
- role: base
- role: hardening
- role: monitoramento_agente
post_tasks:
- name: Registra data da configuração inicial
ansible.builtin.copy:
content: "{{ ansible_date_time.iso8601 }}"
dest: /etc/ansible-configured-at
owner: root
group: root
mode: "0444"
# ── Play 2: Servidores web ────────────────────────────
- name: Configura servidores web
hosts: tag_Role_webserver
become: true
gather_facts: true
vars_files:
- "{{ playbook_dir }}/../inventory/{{ ambiente }}/group_vars/all/vault.yml"
vars:
docker_users:
- ubuntu
- deploy
roles:
- role: docker
- role: nginx
- role: certbot
- role: aplicacao
# ── Play 3: Verifica saúde dos servidores ─────────────
- name: Verifica saúde da configuração
hosts: all
become: false
gather_facts: false
tasks:
- name: Verifica conectividade
ansible.builtin.ping:
- name: Verifica que o Docker está rodando
ansible.builtin.command: docker info
register: docker_info
changed_when: false
when: "'tag_Role_webserver' in group_names"
- name: Verifica que o Nginx está respondendo
ansible.builtin.uri:
url: "http://{{ ansible_host }}/health"
status_code: 200
delegate_to: localhost
when: "'tag_Role_webserver' in group_names"
Role de Hardening de Segurança
Uma role essencial que todo servidor de produção deve ter — configura as principais proteções de segurança:
# ansible/roles/hardening/tasks/main.yml
---
- name: Configura o firewall UFW
block:
- name: Instala o UFW
ansible.builtin.apt:
name: ufw
state: present
- name: Define política padrão — bloqueia entrada
community.general.ufw:
policy: deny
direction: incoming
- name: Define política padrão — permite saída
community.general.ufw:
policy: allow
direction: outgoing
- name: Permite SSH
community.general.ufw:
rule: allow
port: "22"
proto: tcp
- name: Permite HTTP
community.general.ufw:
rule: allow
port: "80"
proto: tcp
when: "'tag_Role_webserver' in group_names"
- name: Permite HTTPS
community.general.ufw:
rule: allow
port: "443"
proto: tcp
when: "'tag_Role_webserver' in group_names"
- name: Habilita o UFW
community.general.ufw:
state: enabled
- name: Configura o SSH hardening
block:
- name: Aplica configurações seguras do SSH
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
validate: sshd -t -f %s
loop:
- regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
- regexp: "^#?PermitRootLogin"
line: "PermitRootLogin no"
- regexp: "^#?X11Forwarding"
line: "X11Forwarding no"
- regexp: "^#?MaxAuthTries"
line: "MaxAuthTries 3"
- regexp: "^#?ClientAliveInterval"
line: "ClientAliveInterval 300"
- regexp: "^#?ClientAliveCountMax"
line: "ClientAliveCountMax 2"
notify: Reinicia SSH
- name: Configura o Fail2ban
block:
- name: Instala o Fail2ban
ansible.builtin.apt:
name: fail2ban
state: present
- name: Copia configuração personalizada do Fail2ban
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: Reinicia Fail2ban
- name: Garante que o Fail2ban está habilitado
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
- name: Configura atualizações automáticas de segurança
block:
- name: Instala unattended-upgrades
ansible.builtin.apt:
name:
- unattended-upgrades
- apt-listchanges
state: present
- name: Habilita atualizações automáticas de segurança
ansible.builtin.template:
src: 20auto-upgrades.j2
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
Playbook de Deploy da Aplicação
Separado do playbook de configuração inicial, o playbook de deploy é executado a cada nova versão da aplicação:
# ansible/playbooks/deploy.yml
---
- name: Deploy da aplicação
hosts: tag_Role_webserver
become: true
gather_facts: false
serial: 1 # um servidor por vez — zero downtime em clusters
vars:
app_image: "{{ image | mandatory }}"
container_name: "minha-api"
app_port: 3000
health_check_url: "http://localhost:{{ app_port }}/health"
health_check_retries: 30
health_check_delay: 2
tasks:
- name: Remove o servidor do load balancer
community.aws.elb_instance:
instance_id: "{{ instance_id }}"
ec2_elbs: "{{ load_balancer_name }}"
state: absent
region: "{{ aws_region }}"
delegate_to: localhost
when: load_balancer_name is defined
- name: Autentica no GHCR
ansible.builtin.shell: |
echo "{{ ghcr_token }}" | \
docker login ghcr.io -u "{{ ghcr_user }}" --password-stdin
no_log: true
- name: Baixa a nova imagem
community.docker.docker_image:
name: "{{ app_image }}"
source: pull
force_source: true
- name: Para e remove o container atual
community.docker.docker_container:
name: "{{ container_name }}"
state: absent
- name: Inicia o novo container
community.docker.docker_container:
name: "{{ container_name }}"
image: "{{ app_image }}"
state: started
restart_policy: unless-stopped
ports:
- "{{ app_port }}:3000"
env_file: /opt/minha-api/.env
log_driver: json-file
log_options:
max-size: "100m"
max-file: "3"
- name: Aguarda a aplicação ficar saudável
ansible.builtin.uri:
url: "{{ health_check_url }}"
status_code: 200
register: health_check
until: health_check.status == 200
retries: "{{ health_check_retries }}"
delay: "{{ health_check_delay }}"
- name: Recoloca o servidor no load balancer
community.aws.elb_instance:
instance_id: "{{ instance_id }}"
ec2_elbs: "{{ load_balancer_name }}"
state: present
region: "{{ aws_region }}"
delegate_to: localhost
when: load_balancer_name is defined
- name: Remove imagens antigas
community.docker.docker_prune:
images: true
images_filters:
dangling: false
until: "24h"
Executando o deploy via pipeline:
ansible-playbook \
-i ansible/inventory/aws_ec2.yml \
ansible/playbooks/deploy.yml \
-e "image=ghcr.io/minha-empresa/minha-api:abc1234" \
-e "load_balancer_name=minha-api-alb" \
-e "aws_region=us-east-1" \
--vault-password-file .vault-password
O Script de Pipeline Completo
Unindo tudo em um único script que o pipeline de CI/CD pode invocar:
#!/bin/bash
# scripts/pipeline-completo.sh
# Provisiona infraestrutura com Terraform e configura com Ansible
set -euo pipefail
AMBIENTE=${1:?Uso: $0 <ambiente> <versao-app>}
VERSAO_APP=${2:?Uso: $0 <ambiente> <versao-app>}
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
erro() { echo "[$(date -u +%H:%M:%S)] ERRO: $*" >&2; exit 1; }
log "=== Pipeline IaC Completo ==="
log "Ambiente: $AMBIENTE"
log "Versão da aplicação: $VERSAO_APP"
# ── Etapa 1: Terraform ────────────────────────────────
log "--- Etapa 1: Provisionamento com Terraform ---"
cd "terraform/environments/$AMBIENTE"
terraform init \
-backend-config="key=projetos/minha-api/$AMBIENTE/terraform.tfstate" \
-reconfigure
terraform validate || erro "Validação do Terraform falhou"
terraform fmt -check || erro "Arquivos Terraform não formatados"
log "Executando terraform plan..."
terraform plan -out=tfplan -no-color
log "Aplicando infraestrutura..."
terraform apply -auto-approve tfplan
log "Terraform concluído. Aguardando inicialização dos servidores..."
sleep 30
cd - > /dev/null
# ── Etapa 2: Geração do inventory ────────────────────
log "--- Etapa 2: Gerando inventory Ansible ---"
bash terraform/scripts/gerar-inventory.sh "$AMBIENTE"
# ── Etapa 3: Verificação de conectividade ─────────────
log "--- Etapa 3: Verificando conectividade SSH ---"
ansible all \
-i "ansible/inventory/$AMBIENTE/hosts.yml" \
-m wait_for_connection \
--timeout=120
# ── Etapa 4: Configuração base com Ansible ────────────
log "--- Etapa 4: Configuração base dos servidores ---"
ansible-playbook \
-i "ansible/inventory/$AMBIENTE/hosts.yml" \
ansible/playbooks/site.yml \
--vault-password-file .vault-password \
-e "ambiente=$AMBIENTE"
# ── Etapa 5: Deploy da aplicação ──────────────────────
log "--- Etapa 5: Deploy da aplicação ---"
ansible-playbook \
-i "ansible/inventory/aws_ec2.yml" \
ansible/playbooks/deploy.yml \
--vault-password-file .vault-password \
-e "image=ghcr.io/minha-empresa/minha-api:$VERSAO_APP" \
-e "ambiente=$AMBIENTE"
# ── Etapa 6: Verificação final ────────────────────────
log "--- Etapa 6: Verificação final ---"
BASE_URL="https://$AMBIENTE.minha-api.com"
verificar_endpoint() {
local endpoint=$1
local esperado=$2
local status
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$endpoint")
if [ "$status" != "$esperado" ]; then
erro "$endpoint retornou $status, esperado $esperado"
fi
log "OK: $endpoint ($status)"
}
verificar_endpoint "/health" "200"
verificar_endpoint "/api/v1/status" "200"
log "=== Pipeline concluído com sucesso ==="
log "Aplicação versão $VERSAO_APP em execução em $AMBIENTE"
Integrando ao GitHub Actions
O script de pipeline é invocado pelo workflow de CI/CD:
# .github/workflows/iac-pipeline.yml
name: Pipeline IaC
on:
workflow_dispatch:
inputs:
ambiente:
description: 'Ambiente de destino'
required: true
type: choice
options: [staging, production]
versao_app:
description: 'Versão da aplicação (SHA do commit)'
required: true
jobs:
pipeline-iac:
runs-on: ubuntu-latest
environment: ${{ inputs.ambiente }}
steps:
- uses: actions/checkout@v4
- name: Configura credenciais AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Instala Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Instala Ansible e dependências
run: |
pip install ansible boto3 botocore --break-system-packages
ansible-galaxy collection install \
amazon.aws \
community.docker \
community.general
- name: Configura chave SSH para os servidores
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Configura Ansible Vault
run: |
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault-password
chmod 600 .vault-password
- name: Executa pipeline completo
run: |
bash scripts/pipeline-completo.sh \
${{ inputs.ambiente }} \
${{ inputs.versao_app }}
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
ANSIBLE_HOST_KEY_CHECKING: "false"
- name: Limpeza de arquivos sensíveis
if: always()
run: |
rm -f ~/.ssh/id_ed25519
rm -f .vault-password
Encerrando o Módulo 5
Com este artigo conclui-se mais uma etapa — Infraestrutura como Código. Foram cobertos os fundamentos do Terraform, a criação de recursos AWS, organização em módulos, gerenciamento de state e a configuração de servidores com Ansible. O encerramento natural foi a integração das duas ferramentas em um pipeline unificado, que vai do zero — nenhuma infraestrutura existente — até servidores configurados e a aplicação em execução.
Na sequência, entramos no território do monitoramento e observabilidade — como saber que a aplicação está saudável, como identificar problemas antes que os usuários os reportem e como diagnosticar incidentes com rapidez.
Referências para Aprofundamento
Integração Terraform e Ansible - Terraform Output — developer.hashicorp.com — Documentação completa de outputs do Terraform, essencial para passar informações entre Terraform e Ansible. - Ansible Dynamic Inventory — docs.ansible.com — Guia completo de inventories dinâmicos, incluindo o plugin AWS EC2 usado neste artigo.
Coleções Ansible para AWS e Docker - amazon.aws Collection — docs.ansible.com — Documentação da coleção oficial Amazon AWS para Ansible, com referência de todos os módulos disponíveis. - community.docker Collection — docs.ansible.com — Documentação da coleção Docker para Ansible, cobrindo gerenciamento de containers, imagens e redes.
Segurança e hardening - dev-sec/ansible-collection-hardening — GitHub — Coleção de roles de hardening mantidas pela comunidade Dev-Sec, amplamente usada em ambientes de produção. Cobre hardening de SSH, OS e nginx seguindo benchmarks CIS.