Infrastructure as Code: Terraform and Ansible for Production
Infrastructure as Code (IaC) transforms how we manage servers. Instead of manual configuration, we define infrastructure in code — versioned, reviewable, and repeatable. This guide covers production-grade Terraform and Ansible.
Terraform: Infrastructure Provisioning
Terraform provisions cloud resources declaratively.
Project Structure
terraform/
├── environments/
│ ├── production/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── staging/
├── modules/
│ ├── vpc/
│ ├── compute/
│ └── database/
└── global/
Core Concepts
Providers — Cloud platforms:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.region
}
Resources — Infrastructure components:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
subnet_id = module.vpc.public_subnets[0]
tags = {
Name = "web-server"
Environment = var.environment
ManagedBy = "terraform"
}
}
Modules — Reusable components:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = false
}
Best Practices
State Management:
# Use remote state
terraform init -backend-config="bucket=my-state"
# Enable state locking
dynamodb_table = "terraform-locks"
Variable Management:
# variables.tf
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "Invalid instance type."
}
}
# terraform.tfvars (gitignored for secrets)
instance_type = "t3.small"
Workspaces for Environments:
terraform workspace new production
terraform workspace select production
terraform apply
Ansible: Configuration Management
Ansible configures servers after provisioning.
Inventory
# inventory/production.ini
[webservers]
web1.example.com
web2.example.com
[dbservers]
db1.example.com
Add group variables in a separate vars section:
# All hosts inherit these variables
# (the all-colon-vars group header)
ansible_user=deploy
ansible_ssh_private_key_file=~/.ssh/deploy_key
Define group variables in group_vars/all.yml instead of inline:
# group_vars/all.yml
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/deploy_key
Playbooks
# site.yml
---
- name: Configure web servers
hosts: webservers
become: yes
vars:
app_version: "1.2.3"
roles:
- common
- nginx
- app
- name: Configure database servers
hosts: dbservers
become: yes
roles:
- common
- postgresql
Roles
Roles organize tasks:
roles/
└── nginx/
├── tasks/
│ └── main.yml
├── handlers/
│ └── main.yml
├── templates/
│ └── nginx.conf.j2
└── vars/
└── main.yml
tasks/main.yml:
---
- name: Install nginx
apt:
name: nginx
state: present
update_cache: yes
- name: Configure nginx
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
handlers/main.yml:
---
- name: Restart nginx
service:
name: nginx
state: restarted
Templates
# templates/nginx.conf.j2
server {
listen 80;
server_name {{ server_name }};
location / {
proxy_pass http://localhost:{{ app_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Vault for Secrets
# Encrypt secrets
ansible-vault encrypt group_vars/all/vault.yml
# Edit encrypted file
ansible-vault edit group_vars/all/vault.yml
# Run playbook with vault
ansible-playbook site.yml --ask-vault-pass
Integration: Terraform + Ansible
Terraform provisions, Ansible configures:
# terraform/main.tf
resource "aws_instance" "web" {
# ... provisioning ...
provisioner "local-exec" {
command = <<-EOT
sleep 30
ansible-playbook -i '${self.public_ip},' \
--private-key ~/.ssh/deploy_key \
../ansible/webserver.yml
EOT
}
}
Or use dynamic inventory:
#!/usr/bin/env python3
# dynamic_inventory.py
import json
import subprocess
def get_inventory():
output = subprocess.check_output(
["terraform", "output", "-json"],
cwd="../terraform"
)
data = json.loads(output)
return {
"webservers": {
"hosts": data["web_ips"]["value"]
}
}
if __name__ == "__main__":
print(json.dumps(get_inventory()))
CI/CD Integration
GitHub Actions
# .github/workflows/infrastructure.yml
name: Infrastructure
on:
push:
branches: [main]
paths: ['terraform/**', 'ansible/**']
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
working-directory: terraform/environments/production
- name: Terraform Plan
run: terraform plan
working-directory: terraform/environments/production
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
working-directory: terraform/environments/production
ansible:
needs: terraform
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Ansible
run: |
ansible-playbook -i inventory/production.ini site.yml
working-directory: ansible
Best Practices Summary
Terraform:
- Use remote state with locking
- Modularize everything
- Use workspaces or directories for environments
- Never commit secrets
- Plan before applying
Ansible:
- Use roles for organization
- Encrypt secrets with Vault
- Make playbooks idempotent
- Use handlers for service restarts
- Test with
--checkmode
Both:
- Version control everything
- Code review all changes
- Automate testing
- Document your modules and roles
Testing Infrastructure Changes
Infrastructure code deserves the same testing rigor as application code. A Terraform misconfiguration can take down production just as surely as a code bug — arguably faster, and with a wider blast radius.
Validate before every apply:
terraform fmt -check -recursive # Formatting
terraform validate # Syntax and internal consistency
Static analysis for security issues with tools like tfsec or checkov:
checkov -d terraform/ --framework terraform
This catches open security groups, unencrypted storage, and overly permissive IAM policies before they exist. For Ansible, use --check mode (dry run) plus --diff to preview exactly what would change, and ansible-lint to catch playbook anti-patterns in CI.
For deeper confidence, tools like Terratest let you write automated tests that spin up real infrastructure, assert on its behavior, and tear it down. Reserve this for critical modules — it’s slower and costs real money — but for the VPC module everything depends on, it’s worth it.
Drift Detection and Remediation
Infrastructure drifts. Someone makes a manual change in the console during an incident, forgets to codify it, and now your Terraform state no longer matches reality. The next apply either reverts the emergency fix or fails confusingly.
Detect drift on a schedule:
terraform plan -detailed-exitcode
# Exit 0 = no changes, 2 = drift detected
Run this nightly in CI and alert on non-zero exits. When drift appears, decide deliberately: either import the manual change into code (if it was a legitimate fix) or re-apply to restore the declared state (if it wasn’t). The worst option is ignoring it — drift compounds, and eventually nobody knows what production actually runs. This is the same operational discipline we apply to servers in the Linux VPS guide: if it isn’t tracked, it isn’t managed.
Secrets in Infrastructure Code
The most common IaC mistake is committing secrets. A database password in terraform.tfvars pushed to a public repo is a breach announcement. The rules are non-negotiable:
- Never commit
.tfvarsfiles containing secrets — add them to.gitignore - Pass secrets via environment variables (
TF_VAR_db_password) or a secrets manager - Use Ansible Vault or SOPS for encrypted variables in playbooks
- Scan your repo for leaked secrets with
gitleaksortrufflehogin CI
If a secret ever lands in git history, treat it as compromised — rotate it immediately. Deleting the commit doesn’t help; the history is cloned and cached in places you can’t reach.
Conclusion
Infrastructure as Code transforms operations from manual toil to automated, repeatable processes. Terraform provisions, Ansible configures, and together they enable infrastructure that’s versioned, reviewable, and reliable.
Start small — automate one server, one environment. Build confidence, then expand. The goal is infrastructure you can rebuild from code in minutes, not days.