IaC Bazaar

Infrastructure as Code Examples: Terraform, OpenTofu and Ansible Walkthroughs

IaC Bazaar·
Professional header image for educational tutorial: Infrastructure as Code Examples: Terraform, OpenTofu and ...

Most teams reach a point where clicking through cloud consoles stops scaling. Deployments become inconsistent, rollbacks turn into guesswork, and nobody can answer the question: "What exactly is running in production, and why?" This is the problem that a well-structured infrastructure as code example solves, not just in theory, but in the day-to-day mechanics of how your team ships and maintains systems.

This tutorial walks through hands-on walkthroughs using Terraform, OpenTofu, and Ansible, three tools that together cover provisioning, open-source flexibility, and post-provisioning configuration management. You will see how version control anchors every infrastructure definition, how state management and secrets handling prevent the failures that quietly break production environments, and how CI/CD pipelines turn IaC from a local practice into an organization-wide discipline. The post also covers when to build your own modules versus adopting verified ones, a decision that carries real consequences at scale. By the end, you will have a concrete implementation checklist and enough working examples to move from understanding IaC conceptually to applying it with the rigor modern infrastructure demands.

What Infrastructure as Code Actually Means in Practice

What Infrastructure as Code Actually Means in Practice

Infrastructure as code means treating every infrastructure definition as a source file: committed to Git, reviewed via pull request, and merged through the same workflow your team uses for application code. Every change carries a commit message, a diff, and a reviewer's approval. That makes infrastructure history auditable and every change reversible with a git revert.

The operational shift this creates is significant. Infrastructure stops being a ticket submitted to an ops team and becomes a specification that a machine executes identically across development, staging, and production. Human interpretation is removed from the provisioning loop. The environment you test in matches the environment you ship to, because both are generated from the same committed files.

Declarative vs. Imperative: A Meaningful Distinction

Not all IaC tools work the same way, and the difference matters when choosing your toolchain.

Declarative tools such as Terraform and OpenTofu accept a description of the desired end state. You define what infrastructure should exist; the tool calculates what actions are required to reach that state from wherever things currently stand. You do not script the sequence of operations.

Imperative tools such as Ansible accept a sequence of instructions. You define the steps to execute in order. The tool carries them out as written. This gives you precise control over execution order, which is why Ansible suits configuration management tasks where sequence matters.

The two approaches complement each other rather than compete. Later sections of this tutorial show both in practice.

IaC in 2026: Discipline Over Automation

Early IaC adoption focused on eliminating manual clicks. By 2026 the bar is higher. Teams that treat IaC as an architectural discipline version-control every module, run security scans as part of CI, enforce policy-as-code gates, and test infrastructure changes before they reach production. The goal is engineering velocity at scale, not merely avoiding the AWS console.

The Scripting Misconception

A common and costly misunderstanding: a Bash script that calls AWS CLI commands is automation, but it is not IaC. The distinction is technical and precise.

True IaC has three properties that scripting lacks:

  • Idempotency: running the same configuration ten times produces the same result as running it once

  • State awareness: the tool tracks what it has deployed and reconciles future runs against that record

  • Drift detection: the tool can identify when real infrastructure diverges from the declared specification and flag or correct the gap

These properties are built into tools like Terraform, OpenTofu, and Ansible. They are not properties you can bolt onto a shell script. For frequently asked questions on production-grade IaC patterns, that distinction between scripting and genuine IaC is the first concept worth internalising before writing a single configuration file.

Terraform Example: Provisioning an AWS VPC and EC2 Instance

Terraform's file structure is the first thing to get right before writing a single resource block. Split your configuration across four files:

  • providers.tf declares the AWS provider and its version constraint

  • variables.tf defines all input variables with types and defaults

  • outputs.tf exposes values you need after apply (VPC ID, instance IP)

  • main.tf contains the actual resource definitions

Provider Version Pinning

Most tutorials skip this, and teams pay for it later. Pin the AWS provider to a minor-version range in providers.tf:

terraform {
 required_providers {
 aws = {
 source = "hashicorp/aws"
 version = "~> 5.0" # Accepts 5.x, rejects 6.0+ breaking changes
 }
 }
 required_version = ">= 1.6.0"
}

provider "aws" {
 region = var.aws_region
}

The ~> operator accepts patch updates within the major version while rejecting major-version bumps, check the HashiCorp provider versioning docs for the precise constraint semantics.

VPC and Subnet Resources

# main.tf

resource "aws_vpc" "main" {
 cidr_block = var.vpc_cidr # e.g. "10.0.0.0/16" for up to 65k hosts
 enable_dns_hostnames = true

 tags = {
 Name = "${var.environment}-vpc" # Prefix with env for multi-account clarity
 Environment = var.environment
 ManagedBy = "terraform" # Signals IaC ownership to the team
 }
}

resource "aws_subnet" "public" {
 vpc_id = aws_vpc.main.id
 cidr_block = var.subnet_cidr # e.g. "10.0.1.0/24"
 availability_zone = var.availability_zone # Parameterised, not hardcoded to us-east-1a

 tags = {
 Name = "${var.environment}-public-subnet"
 Environment = var.environment
 }
}

EC2 Instance with Implicit Dependency

resource "aws_instance" "app" {
 ami = var.ami_id
 instance_type = var.instance_type
 subnet_id = aws_subnet.public.id # Resource reference, not a hardcoded subnet ID

 tags = {
 Name = "${var.environment}-app-server"
 Environment = var.environment
 }
}

Using aws_subnet.public.id instead of a hardcoded string tells Terraform the instance depends on the subnet. Terraform infers resource ordering from these references, consult the Terraform documentation on resource dependencies for the full graph resolution rules.

The Init, Plan, Apply Sequence

terraform init # Downloads the AWS provider plugin declared in providers.tf
terraform plan # Calculates the diff between desired state and current state
terraform apply # Executes the changes after confirmation

Read the plan output before every apply. The plan output uses symbol prefixes (+, ~, -/+) to distinguish creates, updates, and destroy-and-recreate operations, verify the exact notation in the current terraform plan documentation. A -/+ on a VPC or subnet means downstream resources will also be destroyed; catch it here, not in production.

Environment Parity via .tfvars

# variables.tf
variable "environment" { type = string }
variable "vpc_cidr" { type = string }
variable "instance_type" { type = string }

Create one file per environment:

environments/dev.tfvars # instance_type = "t3.micro", vpc_cidr = "10.0.0.0/16"
environments/staging.tfvars # instance_type = "t3.small", vpc_cidr = "10.1.0.0/16"
environments/production.tfvars # instance_type = "t3.medium", vpc_cidr = "10.2.0.0/16"

Deploy with: terraform apply -var-file="environments/production.tfvars"

The same configuration, zero code duplication, three environments with distinct sizing. This pattern directly eliminates configuration drift between environments. For teams scaling this pattern across AWS, EKS, and GKE, the scalable GCP Terraform architecture reference shows how the same variable-driven approach extends to more complex multi-service topologies.

OpenTofu Example: The Open-Source Path and Terraform Compatibility

The same VPC and EC2 configuration you just built runs unmodified on OpenTofu. No syntax changes, no provider block rewrites, no variable file adjustments. That compatibility is intentional, and understanding why requires a brief look at where OpenTofu came from.

Why OpenTofu Exists

On 10 August 2023, HashiCorp switched Terraform from the Mozilla Public License 2.0 to the Business Source License 1.1 (BSL). The OpenTofu manifesto documents the community's concern: the BSL contains vague "competitive use" restrictions, and as the manifesto notes, "What if HashiCorp changes how they interpret 'competitive'?", creating legal exposure for commercial operations building on Terraform. The Linux Foundation formally launched OpenTofu on 20 September 2023 as a fully open-source, community-governed alternative. By 2026, adoption has accelerated significantly among organisations with open-source supply chain requirements, particularly in regulated industries where licence ambiguity creates compliance risk.

Drop-in Compatibility in Practice

OpenTofu maintains HCL syntax compatibility with Terraform. The providers.tf, main.tf, variables.tf, and outputs.tf structure from the previous section works without modification. The tooling commands mirror each other directly:

# Terraform workflow
terraform init && terraform plan && terraform apply

# Equivalent OpenTofu workflow
tofu init && tofu plan && tofu apply

OpenTofu supports over 3,900 providers and 23,600 modules, so the AWS provider and VPC/EC2 resource types behave identically.

Where OpenTofu Diverges

OpenTofu is not simply a renamed binary. Version 1.9 introduced capabilities including the -exclude flag, which lets you selectively exclude specific resources from a plan or apply without commenting out configuration, and for_each support on provider blocks, which eliminates repetitive provider declarations across multi-region or multi-account deployments. Client-side state encryption, covered in the next section, is another material divergence.

Migrating an Existing Terraform Project

Migration is straightforward for most codebases:

# Install OpenTofu, then from your existing Terraform project directory:
tofu init # Re-initialises with OpenTofu's provider registry
tofu plan # Verify no unintended diff against current state

The existing .terraform.lock.hcl and state file are consumed directly. Before switching a production workload, verify three things: confirm provider version constraints resolve cleanly under OpenTofu's registry, run tofu validate against all modules in the dependency tree, and execute a plan against a non-production environment first to catch any provider behaviour differences.

Module Compatibility

Terraform Registry modules work in OpenTofu without modification. However, modules sourced from a generic public registry carry no guarantee about which engine versions they have been tested against. One module. Both engines. IaC Bazaar's verified modules explicitly declare Terraform and OpenTofu compatibility, meaning teams can switch engines without repeating validation work or second-guessing whether a module's behaviour is engine-specific.

Ansible Example: Configuration Management After Provisioning

Ansible Example: Configuration Management After Provisioning

Terraform and OpenTofu handle the provisioning layer: VMs, VPCs, subnets, databases. Once those resources exist, Ansible takes over the configuration layer: installing packages, managing users, deploying application configs, and controlling service state. The two tools are complementary by design. Ansible does not provision cloud resources well; Terraform does not configure what is running inside them.

Playbook Targeting the Provisioned EC2 Instance

The following playbook runs against the EC2 instance created in the Terraform section, installs Nginx, deploys a configuration template, and ensures the service starts on boot:

---
- name: Configure web server
 hosts: web
 become: true

 vars:
 nginx_port: 80

 tasks:
 - name: Install Nginx # apt module is idempotent; skips if already installed
 ansible.builtin.apt:
 name: nginx
 state: present
 update_cache: true

 - name: Deploy Nginx config # template renders Jinja2 and writes only on change
 ansible.builtin.template:
 src: nginx.conf.j2
 dest: /etc/nginx/nginx.conf
 mode: "0644"
 notify: Restart Nginx

 - name: Ensure Nginx is running and enabled
 ansible.builtin.service:
 name: nginx
 state: started
 enabled: true # persists across reboots

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

Inventory Patterns: Static vs. Dynamic

For a single fixed instance, a static inventory is sufficient:

[web]
18.185.42.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa

As Terraform scales the fleet, hardcoded IPs break immediately. The aws_ec2 dynamic inventory plugin solves this by querying AWS and grouping instances by tag:

# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
 - eu-west-1
filters:
 tag:Role: web
keyed_groups:
 - key: tags.Environment
 prefix: env

Run with ansible-inventory -i inventory/aws_ec2.yml --list to verify discovered hosts. The playbook host pattern hosts: web matches the tag automatically, keeping Ansible in sync as Terraform adds or removes instances.

Idempotency and --check Mode

The apt, template, and service modules all check current system state before acting. If Nginx is already installed at the correct version, apt reports ok and makes no change. If the rendered template matches the file on disk, template skips the write and the handler never fires, idempotency, as introduced earlier.

Before applying to a production host, verify with dry-run mode:

ansible-playbook -i inventory/aws_ec2.yml site.yml --check --diff

--check simulates changes without executing them; --diff shows exact file changes. Any task that would change state appears in the output.

Role-Based Structure

Flatten playbooks into roles for reuse across projects:

roles/nginx/
 tasks/main.yml
 handlers/main.yml
 templates/nginx.conf.j2
 defaults/main.yml # default variable values, overridable per environment

A role is the configuration-layer equivalent of a Terraform module: a self-contained, shareable unit with a predictable interface. Call it from any playbook with roles: [nginx]. Teams installing modules from a verified catalogue follow the same principle: consume a tested, structured unit rather than rebuilding common patterns from scratch.

State Management and Secrets Handling: Two Gaps That Break Production IaC

Once your provisioning and configuration layers are in place, two operational gaps consistently break production environments: state mismanagement and secrets exposure.

Remote State with S3 and DynamoDB

Because local state has no visibility for collaborators or concurrent-run protection, the fix is a remote backend with locking enabled:

terraform {
 backend "s3" {
 bucket = "my-org-tfstate"
 key = "prod/vpc/terraform.tfstate"
 region = "me-central-1"
 encrypt = true
 dynamodb_table = "terraform-lock"
 }
}

The encrypt = true parameter enables server-side encryption at rest; dynamodb_table activates distributed locking, consult the Terraform S3 backend documentation for the full configuration reference.

How State Locking Prevents Corruption

When Engineer A runs terraform apply, Terraform writes a lock record to DynamoDB resembling this structure:

{
 "LockID": "my-org-tfstate/prod/vpc/terraform.tfstate",
 "Operation": "OperationTypeApply",
 "Who": "engineer-a@hostname",
 "Created": "2025-01-15T10:23:00Z"
}

If Engineer B runs apply simultaneously, Terraform reads the existing lock record and returns an Error acquiring the state lock message, refusing to proceed. Without this, both processes write conflicting state versions, producing resource records that no longer reflect reality.

Secrets Must Never Enter .tf Files

Hardcoding credentials in .tf files or passing them as plain variable values causes them to appear in state files in plaintext. Instead, use an AWS Secrets Manager data source to retrieve credentials at plan time:

data "aws_secretsmanager_secret_version" "db_password" {
 secret_id = "prod/db/password"
}

resource "aws_db_instance" "main" {
 password = data.aws_secretsmanager_secret_version.db_password.secret_string
}

The credential is fetched during plan and apply but is never written into .tf files or committed to version control. For GCP equivalents, patterns using GCP Secret Manager follow the same data-source model.

CI/CD Injection via TF_VAR_ Environment Variables

In pipelines, pass sensitive inputs as environment variables prefixed with TF_VAR_. Terraform and OpenTofu resolve TF_VAR_-prefixed environment variables at plan/apply time, verify pipeline secret-masking behavior in your CI platform's documentation:

export TF_VAR_db_username="admin"
export TF_VAR_db_password="$(vault kv get -field=password secret/db)"

Pipeline logs record the variable name, not the value, provided the CI system masks secrets correctly.

OpenTofu Client-Side State Encryption

According to OpenTofu's project documentation, OpenTofu adds native client-side encryption, which encrypts the state file before it leaves your machine, independent of S3 bucket policies:

terraform {
 encryption {
 key_provider "aws_kms" "state_key" {
 kms_key_id = "arn:aws:kms:me-central-1:123456789:key/abc-123"
 }
 method "aes_gcm" "default" {
 keys = key_provider.aws_kms.state_key
 }
 state {
 method = method.aes_gcm.default
 }
 }
}

Terraform relies entirely on S3 bucket-level encryption; a misconfigured bucket policy exposes state. OpenTofu's approach means the state object is unreadable even with direct S3 access, which is a concrete security improvement for teams handling sensitive infrastructure records. (verify current encryption configuration syntax in the OpenTofu 1.x release notes before using in production)

CI/CD Pipeline Integration Example with GitHub Actions

A well-structured GitHub Actions pipeline enforces every validation step, gates human approval, and applies infrastructure changes without long-lived credentials in the repository.

OIDC Authentication: No Stored Keys

Configure AWS to trust GitHub Actions as an OpenID Connect identity provider. The workflow calls aws-actions/configure-aws-credentials with a role ARN instead of AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. AWS issues a short-lived token scoped to that role for the duration of the job. AWS documents this pattern as the recommended approach for removing static credentials from CI. The IAM role trust policy restricts assumption to your specific repository and branch, preventing token reuse across forks.

Two-Stage Workflow YAML

name: terraform-pipeline

on:
 pull_request:
 branches: [main]
 push:
 branches: [main]

permissions:
 id-token: write # Required for OIDC
 contents: read
 pull-requests: write # Required to post PR comments

jobs:
 plan:
 runs-on: ubuntu-latest
 if: github.event_name == 'pull_request'
 steps:
 - uses: actions/checkout@v4

 - name: Configure AWS credentials (OIDC)
 uses: aws-actions/configure-aws-credentials@v4
 with:
 role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
 aws-region: me-south-1

 - name: Enforce formatting
 run: terraform fmt -check -recursive

 - name: Validate syntax
 run: |
 terraform init -backend=false
 terraform validate

 - name: Policy scan (Checkov)
 uses: bridgecrewio/checkov-action@v12
 with:
 directory: .
 halt_on_failure: true # Blocks the job on policy violation

 - name: Full init and plan
 run: |
 terraform init
 terraform plan -out=tfplan
 terraform show -json tfplan > plan.json

 - name: Post plan as PR comment
 uses: actions/github-script@v7
 with:
 script: |
 const fs = require('fs');
 const plan = fs.readFileSync('plan.json', 'utf8');
 github.rest.issues.createComment({
 issue_number: context.issue.number,
 owner: context.repo.owner,
 repo: context.repo.repo,
 body: '```json\n' + plan + '\n```'
 });

 apply:
 runs-on: ubuntu-latest
 needs: plan # Explicit dependency; skipped on direct push without plan
 if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 environment: production # Triggers required-reviewer gate
 steps:
 - uses: actions/checkout@v4

 - name: Configure AWS credentials (OIDC)
 uses: aws-actions/configure-aws-credentials@v4
 with:
 role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
 aws-region: me-south-1

 - name: Apply
 run: |
 terraform init
 terraform apply -auto-approve

Validation Gates

The three pre-plan checks, format enforcement, syntax validation, and policy scanning, are annotated inline in the YAML above; each exits non-zero on failure to block the job.

For format enforcement, terraform fmt -check exits with a non-zero code when formatting deviates from canonical style, which fails the CI step, see the Terraform CLI reference for flags. terraform validate performs local schema and reference checking, consult the CLI docs for what it does and does not verify. Checkov (or OPA via conftest) blocks configurations that expose S3 buckets publicly or lack encryption before a human reviews the diff.

Plan Output in Pull Requests

terraform show -json produces a machine-readable representation of the plan file, see the Terraform CLI reference for output schema details. The github-script action posts it as a pull request comment, giving reviewers a readable resource diff alongside the code change. Infrastructure reviews follow the same workflow as application code: comment, request changes, approve.

Environment Gates

In GitHub repository settings, create a production environment and add required reviewers. The apply job references environment: production, pausing execution until an approved reviewer clicks confirm. This implements formal change control using native GitHub features, with no additional tooling required. For GCP-based deployments, the same OIDC pattern applies; see the GCP service accounts IAM documentation for the equivalent trust configuration.

When to Write Your Own Modules vs. Using Verified Production-Ready Ones

With provisioning, configuration, and deployment automation in place, the remaining architectural decision is module sourcing.

The Real Cost of Building From Scratch

A production EKS cluster module is a useful test case. Done properly, it requires node group scaling policies, IAM Roles for Service Accounts (IRSA) configuration, VPC CNI settings, and security group rules that account for both control plane and data plane traffic. The implementation depth is substantial, node group scaling, IRSA, VPC CNI, and bi-directional security group rules each require careful validation before a module is production-worthy. After that, you carry ongoing maintenance as the AWS provider API evolves, deprecates arguments, or introduces new required fields.

That cost compounds. IaC vulnerabilities do not stay isolated; any flaw in a shared module propagates to every environment using it. A misconfigured security group rule written once gets deployed everywhere the module is called.

What "Verified" Actually Means

A module on a random GitHub repository may work today. Whether it is safe and correct is a separate question. In a rigorous context, "verified" means four distinct things:

  • Statically validated: no syntax errors, no schema violations against the provider

  • Security-scanned: Checkov and Trivy policy gates passed, not just run

  • Cosign-signed: cryptographic provenance confirming the artifact has not been tampered with between publication and download

  • Live API tested: validated against real cloud APIs, not just local syntax checks

Most public modules meet the first criterion. Few meet all four. Understanding how this differs from Azure Verified Modules is useful context if your organisation operates across cloud providers.

A Concrete Alternative

IaC Bazaar's catalog of Terraform and OpenTofu modules covers AWS, EKS, and GKE patterns. Modules are available individually with no subscription required, meaning you acquire exactly what you need rather than paying for a catalogue you partially use.

For teams standing up a full environment, the Vizier orchestrator composes modules from that verified catalog into coherent production-ready stacks. Rather than wiring networking, compute, IAM, and observability together module by module, Vizier handles the composition. That is the practical difference between having verified components and having a verified system.

The Decision Rule

Write custom modules when the infrastructure pattern is genuinely specific to your organisation's architecture. Buy verified modules when you are implementing a well-understood pattern such as a VPC, EKS cluster, or RDS instance, where the cost of getting security or scalability details wrong materially exceeds the module price.

For standard cloud patterns, that calculation almost always favours sourcing verified modules over rebuilding them.

Putting It All Together: Your IaC Implementation Checklist

With the build-vs-buy decision framework in hand, the following checklist consolidates every practice covered in this tutorial into a sequenced, prioritised action list.

1. Commit everything to Git first. Version control is not a feature you add later; it is the substrate everything else depends on. Every .tf, .tofu, and playbook file belongs in a repository before you write a single resource block. Every change travels through a pull request. Without this foundation, remote state, CI/CD, and module governance have nothing reliable to operate on.

2. Match tooling to the layer you are managing. Use Terraform or OpenTofu for cloud resource provisioning: VPCs, compute, managed databases, IAM. Use Ansible for the configuration layer on top of that compute: packages, users, service configuration, application deployment. For licensing or encryption reasons covered in the OpenTofu section, prefer OpenTofu as your default. The platforms that run your IaC vary in how well they support each toolchain; verify compatibility before committing to a backend.

3. Configure remote state with locking and secrets handling before leaving dev. Configure the S3 + DynamoDB backend described in the state management section from day one, retrofitting it in production is significantly more disruptive.

4. Wire IaC into CI/CD before scaling the team. Automated fmt, validate, and policy checks in pull requests catch formatting violations, schema errors, and security misconfigurations before any human reads the diff. Adding these gates after a team reaches five or more engineers means unwinding inconsistent patterns already embedded in the codebase.

5. Apply the build-vs-buy decision module by module. For standard patterns, VPC, EKS, RDS, apply the build-vs-buy rule from the previous section. Reserve custom module development for patterns that are genuinely unique to your organisation's requirements.

The sequence matters as much as the individual practices. Version control enables state management; state management enables safe CI/CD automation; CI/CD automation makes module governance enforceable at scale. Skipping or deferring any layer does not simplify the work; it relocates the cost to a point where fixing it is significantly more disruptive.

Conclusion

Infrastructure as Code is not a destination; it is a foundation. The walkthroughs in this post demonstrate that Terraform, OpenTofu, and Ansible each solve distinct problems, and combining them deliberately produces infrastructure that is repeatable, auditable, and safe to change at speed.

The core takeaways are straightforward: start with version control and remote state, enforce quality gates through CI/CD before your team scales, and apply the build-vs-buy decision honestly at the module level. Each layer you build correctly reduces the blast radius of every change that follows.

Your next step is concrete. Pick one resource from your current infrastructure, codify it using the patterns shown here, and run it through a pipeline with automated validation. A single working example builds more momentum than any amount of planning.

The cost of doing this right is low. The cost of doing it late is not.

Verified modules for this topic

Every module in the catalog is statically validated and checked before listing - live-tested (real apply→verify→destroy) where marked.

More from the blog