Imperative Tools #

Infrastructure as Code has two main paradigms: imperative and declarative. Terraform chose the declarative path — defining what you want and letting the tool figure out how to get there. But before declarative became popular, and even today, many tools rely on the imperative approach: you write execution steps in sequence, from first to last. Bash scripts, Ansible, Chef, Puppet, even the AWS CLI commands you run in your terminal — all of them operate on the imperative model.

Understanding how imperative tools work isn’t just historical knowledge. Many teams still use a combination of Terraform and imperative tools, and knowing when the imperative approach is appropriate — and when it should be avoided — is an essential skill for any engineer working with infrastructure.

What the Imperative Approach Means #

The imperative approach means you tell the computer how to do something, step by step. The analogy: if you want to go from home to the office, you write instructions like “turn left at the traffic light, go straight for 500 meters, turn right at the roundabout, third building on the left”. If a road gets closed, you have to rewrite the instructions.

flowchart TD
    A["Start"] --> B["Step 1:\nCreate VPC"]
    B --> C{"Succeeded?"}
    C -->|"Yes"| D["Step 2:\nCreate Subnet"]
    C -->|"No"| E["Handle Error\n(written manually)"]
    E --> B
    D --> F{"Succeeded?"}
    F -->|"Yes"| G["Step 3:\nCreate Security Group"]
    F -->|"No"| H["Handle Error\n(written manually)"]
    H --> D
    G --> I["Step 4:\nCreate EC2 Instance"]
    I --> J["Done"]

    style A fill:#e3f2fd,stroke:#1565c0
    style J fill:#e8f5e9,stroke:#2e7d32
    style E fill:#ffebee,stroke:#c62828
    style H fill:#ffebee,stroke:#c62828

Notice how every step must be defined explicitly along with its error handling. If a new step needs to be inserted in the middle (say, create an Internet Gateway before creating the Subnet), you have to rewrite the entire script sequence. If the second step fails, you have to write the retry logic manually. The more steps there are, the more complex and fragile the script becomes.

The Fundamental Difference from Declarative #

The difference between imperative and declarative isn’t just syntax — it’s about who’s responsible for the execution logic:

// DECLARATIVE (Terraform)  you define the END STATE
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

// Terraform decides:
// - Creation order (based on dependencies)
// - Error handling and retry
// - What needs to change vs what's already correct
# IMPERATIVE (bash) — you define the EXECUTION STEPS
# You decide:
# - Creation order (manually)
# - Error handling (manually)
# - What to check before creating

VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --query 'Vpc.VpcId' --output text)

# If the command above fails, what happens?
# The script just moves on to the next step!
# You have to add your own error handling:

if [ -z "$VPC_ID" ]; then
  echo "Failed to create VPC!"
  exit 1
fi

SUBNET_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.1.0/24 \
  --query 'Subnet.SubnetId' --output text)

The most significant difference: with the declarative approach, you define the goal and the tool decides the path. With imperative, you define the path and hope the goal is reached.

Bash Scripts: The Most Basic Imperative Tool #

Bash scripts are the most primitive form of imperative Infrastructure as Code. Many teams start their IaC journey by writing bash scripts that run CLI commands in sequence. It looks simple and straightforward, but hides many problems that only surface months later.

Example of a Bash Provisioning Script #

The following script is a real-world example of how someone provisions a complete infrastructure using bash. Notice how complexity grows as resources are added:

#!/bin/bash
# provision-infra.sh — Example of provisioning infrastructure with bash

set -e  # Stop on any error

echo "=== Starting infrastructure provisioning ==="

# Create VPC
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --query 'Vpc.VpcId' --output text)
aws ec2 create-tags --resources $VPC_ID \
  --tags Key=Name,Value=main-vpc
echo "VPC created: $VPC_ID"

# Create Subnet
SUBNET_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.1.0/24 --availability-zone ap-southeast-1a \
  --query 'Subnet.SubnetId' --output text)
aws ec2 create-tags --resources $SUBNET_ID \
  --tags Key=Name,Value=public-subnet
echo "Subnet created: $SUBNET_ID"

# Create Internet Gateway
IGW_ID=$(aws ec2 create-internet-gateway \
  --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID \
  --vpc-id $VPC_ID
echo "IGW created: $IGW_ID"

# Create Route Table
RT_ID=$(aws ec2 create-route-table --vpc-id $VPC_ID \
  --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $RT_ID \
  --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $RT_ID \
  --subnet-id $SUBNET_ID
echo "Route table created: $RT_ID"

# Create Security Group
SG_ID=$(aws ec2 create-security-group --group-name web-sg \
  --description "Web SG" --vpc-id $VPC_ID \
  --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --protocol tcp --port 80 --cidr 0.0.0.0/0
echo "Security group created: $SG_ID"

# Launch Instance
INSTANCE_ID=$(aws ec2 run-instances \
  --image-id ami-0abcdef1234567890 \
  --instance-type t3.medium \
  --subnet-id $SUBNET_ID \
  --security-group-ids $SG_ID \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=web-server}]' \
  --query 'Instances[0].InstanceId' --output text)
echo "Instance launched: $INSTANCE_ID"

echo "=== Provisioning complete ==="

Problems with Bash Scripts #

The script above looks functional, but has many problems that surface over time:

Not idempotent. If you run this script twice, you’ll have two VPCs, two subnets, two instances — everything duplicated. There’s no mechanism checking “does this VPC already exist?” before creating a new one. Adding a check to every step makes the script extremely complex.

No state tracking. This script doesn’t know which resources were already created. If it fails midway (say, creating the security group fails), you don’t know which resources were created successfully and which weren’t. You have to check manually in the console.

Minimal error handling. set -e only stops the script on error. There’s no rollback — resources created before the error remain and are unmanaged. You can end up with orphaned resources that keep adding to your bill.

No plan. You can’t see “what’s going to happen” before running the script. The only way to know the outcome is to run it — and if something goes wrong, it’s already too late.

flowchart LR
    subgraph Bash["Bash Script"]
        B1["Run Script"] --> B2{"Failed\nMidway?"}
        B2 -->|"Yes"| B3["Some Resources\nExist, Some Don't"]
        B3 --> B4["Manual Cleanup\nin Console"]
        B4 --> B5["Fix Script"]
        B5 --> B1
        B2 -->|"No"| B6["Success\n(But Duplicated?)"]
    end

    subgraph Terraform["Terraform"]
        T1["terraform plan"] --> T2["Review\nChanges"]
        T2 --> T3["terraform apply"]
        T3 --> T4{"Failed?"}
        T4 -->|"Yes"| T5["State Recorded\nRollback Possible"]
        T4 -->|"No"| T6["Success\nState Consistent"]
    end

    style B3 fill:#ffebee,stroke:#c62828
    style B4 fill:#ffebee,stroke:#c62828
    style T5 fill:#fff3e0,stroke:#e65100
    style T6 fill:#e8f5e9,stroke:#2e7d32

Ansible: Imperative with Structure #

Ansible takes the imperative approach but adds a more organized structure than bash scripts. Using YAML as its definition language, Ansible defines tasks in sequence that must be run on target hosts. Ansible is popular for server configuration, but can also be used for provisioning cloud resources through its available modules.

How Ansible Works #

Ansible works on an agentless model — no agent needs to be installed on the target host. All communication happens over SSH (for Linux) or WinRM (for Windows). Ansible sends modules to the target, runs them, collects the results, then cleans up the modules it sent.

# playbook.yml — Ansible playbook for provisioning EC2
---
- name: Provision web server infrastructure
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Create VPC
      amazon.aws.ec2_vpc_net:
        name: main-vpc
        cidr_block: 10.0.0.0/16
        region: ap-southeast-1
        tags:
          Environment: production
          ManagedBy: ansible
      register: vpc_result

    - name: Create public subnet
      amazon.aws.ec2_vpc_subnet:
        vpc_id: "{{ vpc_result.vpc.id }}"
        cidr: 10.0.1.0/24
        az: ap-southeast-1a
        tags:
          Name: public-subnet
      register: subnet_result

    - name: Create security group
      amazon.aws.ec2_security_group:
        name: web-sg
        description: Web security group
        vpc_id: "{{ vpc_result.vpc.id }}"
        rules:
          - proto: tcp
            ports:
              - 80
              - 443
            cidr_ip: 0.0.0.0/0
      register: sg_result

    - name: Launch EC2 instance
      amazon.aws.ec2_instance:
        name: web-server
        image_id: ami-0abcdef1234567890
        instance_type: t3.medium
        subnet_id: "{{ subnet_result.subnet.id }}"
        security_groups:
          - "{{ sg_result.group_id }}"
        tags:
          Environment: production
          ManagedBy: ansible

Ansible’s Strengths #

Ansible has several advantages that keep it relevant, especially for server configuration tasks:

# Server configuration with Ansible — its strongest area
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
        update_cache: yes

    - name: Copy nginx configuration
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        mode: '0644'
      notify: restart nginx

    - name: Ensure nginx is running
      systemd:
        name: nginx
        state: started
        enabled: yes

  handlers:
    - name: restart nginx
      systemd:
        name: nginx
        state: restarted

Ansible’s main advantages: agentless (nothing to install on the target), easy to learn (readable YAML), rich module ecosystem (thousands of modules for various platforms), and great for server configuration (install packages, manage services, copy files).

Ansible’s Weaknesses for Provisioning #

Although Ansible can be used for provisioning cloud resources, there are fundamental weaknesses that make it less ideal than Terraform for this task:

Idempotency problems. Some Ansible modules are idempotent (like apt and systemd), but modules for cloud resources aren’t always. The amazon.aws.ec2_instance module can create duplicate instances if the parameters you use aren’t specific enough to identify an existing instance.

No strong dependency graph. Ansible runs tasks sequentially from top to bottom. If task 3 depends on the output of task 5, you have to reorder them manually. Terraform builds a dependency graph automatically from references between resources.

Limited state management. Ansible has no concept of a state file. It only knows what it last did based on the tasks it ran. If someone changes a resource manually in the console, Ansible has no idea that change happened.

flowchart TD
    A["Ansible: Dependencies Must Be Defined Manually"] --> B["Task 1\nCreate VPC"]
    B --> C["Task 2\nCreate Subnet\n(must know VPC ID)"]
    C --> D["Task 3\nCreate Security Group\n(must know VPC ID)"]
    D --> E["Task 4\nCreate Instance\n(must know Subnet ID + SG ID)"]

    F["Terraform: Dependencies Built Automatically"] --> G["resource vpc\n(ref: none)"]
    F --> H["resource subnet\n(ref: vpc.id → automatically\nwaits for vpc)"]
    F --> I["resource sg\n(ref: vpc.id → automatically\nruns parallel with subnet)"]
    F --> J["resource instance\n(ref: subnet.id, sg.id →\nautomatically waits for both)"]

    style A fill:#fff3e0,stroke:#e65100
    style F fill:#e3f2fd,stroke:#1565c0

Chef: Ruby-Based Imperative #

Chef uses Ruby as its main language for defining infrastructure configuration. Unlike Ansible which is agentless, Chef requires an agent (chef-client) running on every target node. This agent pulls configuration from the Chef Server and applies it periodically.

Chef’s Architecture Model #

Chef uses a client-server architecture where:

  1. Chef Server stores all recipes and configuration data
  2. Chef Client runs on every node and pulls configuration from the server
  3. Workstation is where developers write and upload recipes
# web_server.rb — Chef recipe for configuring a web server

# Install nginx
package 'nginx' do
  action :install
end

# Ensure the nginx service is running
service 'nginx' do
  action [:enable, :start]
end

# Deploy nginx configuration
template '/etc/nginx/nginx.conf' do
  source 'nginx.conf.erb'
  owner 'root'
  group 'root'
  mode '0644'
  variables(
    server_name: node['web']['server_name'],
    upstream_servers: node['web']['upstream']
  )
  notifies :restart, 'service[nginx]', :delayed
end

# Deploy the website
deploy '/var/www/app' do
  repo node['app']['repo_url']
  revision node['app']['branch']
  action :deploy
  notifies :restart, 'service[nginx]', :immediately
end

Chef’s Strengths and Weaknesses #

Chef is very powerful for complex, repetitive server configuration. The cookbook and recipe concepts enable high modularity, and Ruby integration gives full flexibility for complex configuration logic.

However, Chef has several significant weaknesses:

High learning curve. You need to master Ruby, Chef concepts (cookbook, recipe, resource, provider, data bags, environments), and how to manage the Chef Server. This is far more complex than Ansible’s YAML or Terraform’s HCL.

Infrastructure overhead. Chef requires a Chef Server that must be deployed, maintained, and scaled yourself. That’s one more piece of infrastructure you have to manage just to manage your other infrastructure.

Not ideal for provisioning. Like Ansible, Chef is designed for server configuration (installing packages, managing services, deploying applications), not for provisioning cloud resources. It can be done, but it’s not its strength.

# ANTI-PATTERN: Using Chef to provision infrastructure
# Chef isn't the right tool for creating VPCs, subnets, etc.

# Using the Chef provisioner for EC2 — possible, but not its strongest area
aws_instance 'web-server' do
  image_id 'ami-0abcdef1234567890'
  instance_type 't3.medium'
  key_name 'my-key'
  security_groups ['web-sg']
  action :create
end

# BETTER: Use Terraform for provisioning,
# and Chef for server configuration once the server is up.

Puppet: Declarative, Often Mistaken for Imperative #

Puppet is interesting because it actually uses a declarative approach — even though it’s often grouped with imperative tools. Puppet defines the desired state of resources, not the steps to achieve it. The Puppet Agent on the target node decides what changes need to be made.

How Puppet Works #

# web_server.pp — Puppet manifest for a web server

class profile::web_server {
  package { 'nginx':
    ensure => installed,
  }

  service { 'nginx':
    ensure => running,
    enable => true,
    require => Package['nginx'],
  }

  file { '/etc/nginx/nginx.conf':
    ensure  => file,
    content => template('profile/nginx.conf.erb'),
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    notify  => Service['nginx'],
  }

  file { '/var/www/html':
    ensure => directory,
    owner  => 'www-data',
    group  => 'www-data',
  }
}

Notice that Puppet uses a different syntax from Ansible. Instead of defining tasks in sequence, Puppet defines resources along with their desired state. Puppet determines the execution order based on dependencies defined with require and notify.

Puppet’s Strengths #

  • Declarative model that’s powerful for server configuration
  • Built-in compliance reporting that’s very good
  • Mature module ecosystem on Puppet Forge
  • Facter for gathering information about target nodes

Puppet’s Weaknesses #

  • Requires a Puppet Server — additional infrastructure to manage
  • Agent-based — must install the Puppet Agent on every node
  • Complex syntax — Ruby DSL or Puppet DSL that takes time to master
  • Less flexible for provisioning cloud resources compared to Terraform

Comprehensive Comparison of Imperative Tools #

Each tool has strengths in different areas. The following table compares all the tools discussed across various important aspects:

AspectBash ScriptAnsibleChefPuppetTerraform
ParadigmPurely imperativeImperativeImperativeDeclarativeDeclarative
LanguageBashYAMLRubyPuppet DSLHCL
AgentNoNoYesYesNo
State ManagementNoneLimitedYesYesYes (state file)
IdempotencyNoPartialPartialYesYes
Dependency GraphManualManualPartialYesYes (automatic)
Plan/PreviewNoYes (–check)Yes (dry-run)Yes (–noop)Yes (plan)
Best forOne-off tasksConfig managementConfig managementConfig managementInfrastructure provisioning
flowchart TD
    subgraph Provisioning["Cloud Resource Provisioning"]
        P1["VPC, Subnet, EC2,\nRDS, S3, Load Balancer"]
    end

    subgraph ConfigMgmt["Configuration Management"]
        C1["Install Packages,\nDeploy Apps, Manage\nServices, Copy Files"]
    end

    Provisioning --> TF["✅ Terraform\n(most appropriate)"]
    Provisioning --> ANS1["⚠️ Ansible\n(possible, but not its strength)"]
    Provisioning --> BASH1["❌ Bash Script\n(avoid for production)"]

    ConfigMgmt --> ANS2["✅ Ansible\n(best for this)"]
    ConfigMgmt --> CHEF["✅ Chef\n(powerful, but complex)"]
    ConfigMgmt --> PUP["✅ Puppet\n(powerful, but needs an agent)"]

    style TF fill:#e8f5e9,stroke:#2e7d32
    style ANS2 fill:#e8f5e9,stroke:#2e7d32
    style CHEF fill:#e8f5e9,stroke:#2e7d32
    style PUP fill:#e8f5e9,stroke:#2e7d32
    style ANS1 fill:#fff3e0,stroke:#e65100
    style BASH1 fill:#ffebee,stroke:#c62828

The Idempotency Problem in Imperative Tools #

Idempotency is the concept where running the same operation multiple times produces the same result as running it once. This is critical for Infrastructure as Code because you’ll definitely run the same tool many times — whether for updates, scaling, or recovery.

# ANTI-PATTERN: A bash script that is NOT idempotent
#!/bin/bash

# Running once: creates a new VPC ✓
# Running again: creates a DUPLICATE VPC ✗
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --query 'Vpc.VpcId' --output text)

# Running once: creates an S3 bucket ✓
# Running again: ERROR "BucketAlreadyExists" ✗
aws s3 mb s3://my-app-bucket
# CORRECT: A bash script with manual idempotency
#!/bin/bash

# Check whether the VPC already exists
EXISTING_VPC=$(aws ec2 describe-vpcs \
  --filters "Name=tag:Name,Values=main-vpc" \
  --query 'Vpcs[0].VpcId' --output text)

if [ "$EXISTING_VPC" = "None" ]; then
  VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
    --query 'Vpc.VpcId' --output text)
  aws ec2 create-tags --resources $VPC_ID \
    --tags Key=Name,Value=main-vpc
  echo "VPC created: $VPC_ID"
else
  VPC_ID=$EXISTING_VPC
  echo "VPC already exists: $VPC_ID"
fi

The problem with the solution above: every resource requires different check logic. A VPC needs to be checked by tag, an S3 bucket by name, a security group by name and VPC ID. A script that was once simple becomes extremely complex just to ensure idempotency.

Terraform handles this automatically through the state file. Before creating a new resource, Terraform always checks the state file to know which resources already exist and what needs to change. That’s why terraform apply can be run repeatedly without any risk of duplication.

When Imperative Tools Are Still the Right Choice #

Although Terraform excels at infrastructure provisioning, there are scenarios where imperative tools are still more appropriate:

Scenario 1: Server Configuration #

After Terraform creates an EC2 instance, you need to configure that server — install nginx, deploy the application, set up OS-level firewalls. This is the area where Ansible, Chef, or Puppet are far stronger than Terraform.

# Ansible is more appropriate for this task
- name: Configure application server
  hosts: app_servers
  become: true

  tasks:
    - name: Install dependencies
      apt:
        name:
          - nginx
          - python3
          - postgresql-client
        state: present
        update_cache: yes

    - name: Deploy application
      git:
        repo: https://github.com/company/app.git
        dest: /var/www/app
        version: main

    - name: Configure nginx
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      notify: Restart nginx

Scenario 2: One-off Operational Tasks #

Sometimes you need to run a one-time task — like backing up a database before a migration, running a migration script, or sending bulk notifications. For tasks like this, bash scripts or Ansible ad-hoc commands are far more practical than writing a Terraform module.

# Ansible ad-hoc command for a one-off task
# Running a security patch update on all servers
$ ansible webservers -m apt -a "upgrade=dist" --become

# Backing up a database before migration
$ ansible db_servers -m shell \
  -a "pg_dump -Fc mydb > /backup/mydb_$(date +%Y%m%d).dump" \
  --become

Scenario 3: Application Deployment Pipelines #

The application deployment process — build, test, push artifacts, update services — is an imperative sequential process. The CI/CD pipelines that handle this (Jenkins, GitLab CI, GitHub Actions) are essentially imperative tools.

# GitLab CI — deployment pipeline (imperative)
deploy_production:
  stage: deploy
  script:
    - docker build -t app:$CI_COMMIT_SHA .
    - docker push registry.company.com/app:$CI_COMMIT_SHA
    - kubectl set image deployment/app app=registry.company.com/app:$CI_COMMIT_SHA
    - kubectl rollout status deployment/app --timeout=300s
  only:
    - main
  environment:
    name: production

Combining Terraform with Imperative Tools #

The best approach in real production isn’t choosing one or the other, but combining both. Terraform for infrastructure provisioning, imperative tools for server configuration and operations.

# Terraform: Provision the infrastructure
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.private.id

  vpc_security_group_ids = [aws_sg.web.id]

  tags = {
    Name = "web-server"
  }
}

# After the instance is up, run Ansible for configuration
resource "null_resource" "configure_server" {
  triggers = {
    instance_id = aws_instance.web.id
  }

  provisioner "local-exec" {
    command = <<-EOT
      ansible-playbook -i '${aws_instance.web.public_ip},' \
        configure-server.yml \
        --extra-vars "db_host=${aws_db_instance.main.address}"
    EOT
  }
}
ANTI-PATTERN vs CORRECT:

ANTI-PATTERN:
  ✗ Using Terraform for server configuration
    (remote-exec, file provisioners — fragile and not idempotent)
  ✗ Using Ansible to provision VPCs, RDS, EKS
    (possible, but no state management or plan)
  ✗ Using bash scripts for everything
    (not idempotent, no plan, no state)

CORRECT:
  ✓ Terraform for provisioning (VPC, EC2, RDS, S3, IAM)
  ✓ Ansible/Chef/Puppet for server configuration
  ✓ Bash scripts for one-off tasks and prototyping
  ✓ CI/CD pipelines for application deployment

Summary #

  • The imperative approach defines how infrastructure is built, step by step, unlike declarative which defines what you want.
  • Bash scripts are the most basic form of imperative IaC — fast for prototyping but not idempotent, no state tracking, and highly error-prone.
  • Ansible adds structure to the imperative approach with YAML-based playbooks — excellent for server configuration but less ideal for provisioning cloud resources.
  • Chef and Puppet are mature server configuration tools — powerful for compliance and config management but require additional infrastructure (Chef Server, Puppet Server).
  • The idempotency problem is the biggest weakness of imperative tools for provisioning — running the same operation can produce duplicate resources.
  • The best combination in production: Terraform for infrastructure provisioning, Ansible/Chef/Puppet for server configuration, bash scripts for one-off tasks.
  • Don’t force one tool to do everything — each tool has a domain where it’s strongest, and using the right combination produces a more robust system.

← Previous: Manual Infrastructure   Next: Terraform Alternatives →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact