Our Pick Terraform — Terraform's declarative model and state management make it the right tool for cloud infrastructure provisioning. Ansible excels at configuration management and application deployment where Terraform isn't designed to operate. Most mature infrastructure teams use both.
Ansible vs Terraform

import ComparisonTable from ’../../components/ComparisonTable.astro’;

Ansible and Terraform are both infrastructure automation tools, but they solve different problems. Terraform provisions infrastructure (create servers, networks, databases). Ansible configures what’s already running (install software, manage config files, deploy applications).

Quick Verdict

Choose Terraform if: You’re creating cloud resources (EC2 instances, VPCs, RDS databases, Kubernetes clusters) and need to track and manage their state over time.

Choose Ansible if: You’re configuring servers, deploying applications, or orchestrating multi-step tasks on existing infrastructure.

Use both: Terraform creates the infrastructure; Ansible configures it. This is the most common production pattern.


Core Philosophy Difference

<ComparisonTable headers={[“Dimension”, “Terraform”, “Ansible”]} rows={[ [“Primary use”, “Infrastructure provisioning”, “Configuration management”], [“Approach”, “Declarative (desired state)”, “Procedural (step by step)”], [“State management”, “State file (terraform.tfstate)”, “Stateless (idempotent tasks)”], [“Language”, “HCL (HashiCorp Config Language)”, “YAML (playbooks)”], [“Agentless”, “Yes”, “Yes (SSH)”], [“Cloud-native”, “Yes (providers for all clouds)”, “Good (cloud modules)”], [“OS config”, “Not designed for this”, “Excellent”], [“Application deploy”, “Limited”, “Excellent”], [“Idempotency”, “Built-in (plan/apply model)”, “Module-dependent”], [“Community”, “Large (Registry)”, “Very large (Galaxy)”], [“Enterprise”, “HCP Terraform / Terraform Enterprise”, “Ansible Automation Platform”], ]} />


Terraform: Infrastructure Provisioning

Terraform describes the desired final state of your infrastructure:

main.tf — AWS infrastructure:

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.aws_region
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  
  tags = {
    Name        = "${var.project}-vpc"
    Environment = var.environment
  }
}

# Public subnets
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index}.0/24"
  availability_zone = var.availability_zones[count.index]
  
  map_public_ip_on_launch = true
  
  tags = {
    Name = "${var.project}-public-${count.index + 1}"
  }
}

# RDS Database
resource "aws_db_instance" "postgres" {
  identifier        = "${var.project}-db"
  engine            = "postgres"
  engine_version    = "16.1"
  instance_class    = "db.t3.medium"
  allocated_storage = 20
  storage_type      = "gp3"
  
  db_name  = var.db_name
  username = var.db_username
  password = var.db_password
  
  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name
  
  backup_retention_period = 7
  skip_final_snapshot     = false
  
  tags = {
    Environment = var.environment
  }
}

# ECS Service (application)
resource "aws_ecs_service" "app" {
  name            = "${var.project}-app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.app_count
  launch_type     = "FARGATE"
  
  network_configuration {
    subnets          = aws_subnet.private[*].id
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }
  
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }
}

Terraform workflow:

# Initialize (download providers)
terraform init

# Preview changes (no infrastructure modified)
terraform plan -var-file=production.tfvars

# Apply changes
terraform apply -var-file=production.tfvars

# Show current state
terraform show

# Destroy all resources
terraform destroy

# Import existing resource into state
terraform import aws_s3_bucket.example my-existing-bucket

# Check what will be affected by a change
terraform plan -target=aws_ecs_service.app

State management:

# terraform.tfstate tracks current reality
# Running `terraform plan` compares:
# - Desired state (your .tf files)
# - Actual state (tfstate file)
# - Real infrastructure (API calls)

# If someone manually deleted a resource:
# terraform plan shows: "will be created" (drift detected)

# Remote state (team collaboration)
backend "s3" {
  bucket         = "my-terraform-state"
  key            = "production/terraform.tfstate"
  region         = "us-east-1"
  dynamodb_table = "terraform-state-lock"  # Prevent concurrent applies
  encrypt        = true
}

Ansible: Configuration Management

Ansible describes tasks to execute on servers:

inventory.yml — what servers to manage:

all:
  children:
    webservers:
      hosts:
        web1:
          ansible_host: 10.0.1.10
        web2:
          ansible_host: 10.0.1.11
      vars:
        ansible_user: ubuntu
        ansible_ssh_private_key_file: ~/.ssh/prod.pem
    databases:
      hosts:
        db1:
          ansible_host: 10.0.2.10

playbooks/web-server.yml — configure web servers:

---
- name: Configure web servers
  hosts: webservers
  become: true  # sudo
  vars:
    app_user: appuser
    app_dir: /opt/myapp
    nginx_port: 80

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install required packages
      ansible.builtin.apt:
        name:
          - nginx
          - python3
          - python3-pip
        state: present

    - name: Create application user
      ansible.builtin.user:
        name: "{{ app_user }}"
        shell: /bin/bash
        create_home: true

    - name: Create application directory
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ app_user }}"
        mode: '0755'

    - name: Deploy application from S3
      amazon.aws.aws_s3:
        bucket: my-app-artifacts
        object: "app-{{ app_version }}.tar.gz"
        dest: "/tmp/app.tar.gz"
        mode: get

    - name: Extract application
      ansible.builtin.unarchive:
        src: "/tmp/app.tar.gz"
        dest: "{{ app_dir }}"
        remote_src: true
        owner: "{{ app_user }}"

    - name: Install Python dependencies
      ansible.builtin.pip:
        requirements: "{{ app_dir }}/requirements.txt"
        virtualenv: "{{ app_dir }}/venv"

    - name: Configure nginx
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/myapp
        mode: '0644'
      notify: Restart nginx

    - name: Enable nginx site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/myapp
        dest: /etc/nginx/sites-enabled/myapp
        state: link

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

Running Ansible:

# Run playbook on all web servers
ansible-playbook -i inventory.yml playbooks/web-server.yml

# Dry run (check mode — no changes made)
ansible-playbook -i inventory.yml playbooks/web-server.yml --check

# Run only specific tasks (by tag)
ansible-playbook -i inventory.yml playbooks/web-server.yml --tags nginx

# Run on specific host
ansible-playbook -i inventory.yml playbooks/web-server.yml --limit web1

# Ad-hoc command (no playbook needed)
ansible webservers -i inventory.yml -m service -a "name=nginx state=restarted"

# Check disk usage on all servers
ansible all -i inventory.yml -m command -a "df -h"

Using Both Together

The most common production pattern:

Step 1: Terraform creates infrastructure
  - VPC, subnets, security groups
  - EC2 instances (running Ubuntu)
  - RDS database
  - Load balancer
  - Outputs: IP addresses, endpoint URLs

Step 2: Ansible configures infrastructure
  - Installs application dependencies on EC2 instances
  - Deploys application code
  - Configures nginx, systemd services
  - Sets up monitoring agents (Prometheus node_exporter)
  - Applies security hardening (fail2ban, ufw rules)

Terraform → Ansible handoff:

# Terraform output — pass to Ansible
output "web_server_ips" {
  value = aws_instance.web[*].public_ip
}

output "db_endpoint" {
  value = aws_db_instance.postgres.endpoint
}
# Generate Ansible inventory from Terraform output
terraform output -json web_server_ips | \
  python3 scripts/generate_inventory.py > inventory/production.yml

# Run Ansible with Terraform-generated inventory
ansible-playbook -i inventory/production.yml playbooks/deploy.yml \
  -e "db_host=$(terraform output -raw db_endpoint)"

When to Choose Each

Use Terraform for:

  • Creating AWS/GCP/Azure resources
  • Managing Kubernetes clusters (EKS, GKE, AKS)
  • Database instances, object storage, networking
  • Any infrastructure that needs state tracking
  • Multi-cloud environments

Use Ansible for:

  • Installing and configuring software on servers
  • Deploying application code
  • Managing configuration files and templates
  • Orchestrating deployment workflows
  • Server patching and security hardening
  • Database migrations
  • Any task-based automation across many servers

Use both: Most production infrastructure uses Terraform for provisioning and Ansible for configuration.


Bottom Line

Ansible and Terraform aren’t competitors — they’re complements. Terraform’s declarative state model is ideal for managing cloud resources that persist over time. Ansible’s procedural playbooks excel at configuring servers and deploying applications. The most resilient infrastructure teams use Terraform to define what resources exist and Ansible to define how those resources are configured.