InfrastructureTerraformAnsibleDevOps

Infrastructure as Code: Terraform and Ansible for Production

·Hilmall Cloud

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 --check mode

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 .tfvars files 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 gitleaks or trufflehog in 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.