Intermediate

DevOps Automation and Infrastructure as Code

IaC with Terraform and Docker — managing resources, GitOps in CI/CD and core Terraform commands.

Primary tool: Terraform (HashiCorp) with Docker as the demonstration provider


Table of Contents

  1. Overview: Infrastructure as Code (IaC)
  2. Introduction to Terraform
  3. Managing Multiple Resources
  4. Reference Architecture
  5. GitOps and IaC in a CI/CD Pipeline
  6. Code File Reference
  7. Key Concepts and Best Practices
  8. Essential Terraform Commands

1. Overview: Infrastructure as Code (IaC)

What is IaC?

Infrastructure as Code (IaC) is the practice of managing and provisioning IT infrastructure (servers, networks, databases, containers, etc.) through machine-readable and human-readable configuration files, rather than through manual processes or graphical interfaces.

Core benefits of IaC:

  • Reproducibility: The same code produces the same infrastructure every time — eliminates “snowflake” environments
  • Versioning: Infrastructure is managed with Git, bringing history, code review, and rollback
  • Automation: Integrates into CI/CD pipelines for human-free provisioning
  • Collaboration: Teams share and review infrastructure changes like application code
  • Idempotence: Applying the same configuration multiple times always produces the same result

Imperative vs Declarative Approaches

flowchart LR
    subgraph IMP["IMPERATIVE — How to do it"]
        direction TB
        I1["Shell Script / Ansible\nAWS CLI / Azure CLI"]
        I2["Step 1: Create VPC\nStep 2: Create subnet\nStep 3: Create EC2\nStep 4: Install nginx\nStep 5: Configure nginx"]
        I3["Developer responsibility:\nManage operation order,\nhandle errors, manage state"]
        I1 --> I2 --> I3
    end

    subgraph DEC["DECLARATIVE — What to achieve"]
        direction TB
        D1["Terraform / CloudFormation\nPulumi / Bicep"]
        D2["Desired state:\nresource vpc {...}\nresource subnet {...}\nresource ec2 {...}\nresource nginx {...}"]
        D3["Tool responsibility:\nDetermine actions,\nmanage order, manage state"]
        D1 --> D2 --> D3
    end

    IMP --> COMP["Result: Provisioned Infrastructure"]
    DEC --> COMP
CriteriaImperativeDeclarative
ParadigmDefines execution stepsDefines desired final state
IdempotenceRequires manual managementBuilt-in natively
State managementScript’s responsibilityManaged by tool
Learning curveFamiliar for devs/sysadminsRequires learning the DSL
Logical flexibilityVery high (native conditions, loops)Limited to tool constructs
ExamplesBash, Python, Ansible playbooks, AWS CLITerraform, CloudFormation, Pulumi, Bicep

IaC Tool Comparison

ToolPublisherApproachLanguageScopeState MgmtCloud
TerraformHashiCorpDeclarativeHCL (DSL)Multi-cloudLocal or remote state fileAll (800+ providers)
OpenTofuOpenTofu FoundationDeclarativeHCLMulti-cloudTerraform-compatibleAll
PulumiPulumi CorpDeclarativeTypeScript, Python, Go, C#Multi-cloudPulumi Cloud or self-hostedAll
AWS CloudFormationAWSDeclarativeJSON / YAMLAWS onlyManaged by AWSAWS
Azure BicepMicrosoftDeclarativeBicep (DSL)Azure onlyManaged by AzureAzure
AnsibleRed HatImperative (procedural)YAML (playbooks)Config mgmt + provisioningStateless (idempotent by convention)Multi-cloud
CrossplaneCNCFDeclarativeYAML (K8s CRDs)Multi-cloudKubernetes etcdAll

2. Introduction to Terraform

What is Terraform?

Terraform is an Infrastructure as Code (IaC) tool developed by HashiCorp that allows defining cloud and on-premise resources in human-readable configuration files. These files can be versioned, reused, and shared between teams.

Key characteristics:

  • Declarative syntax in HCL (HashiCorp Configuration Language)
  • Supports all major cloud providers via a provider system
  • Providers act as intermediaries between the Terraform executable and the target platform’s API
  • All public providers available on the HashiCorp Registry (official + community providers)

The Provider System

graph TD
    TF["Terraform Core\nmain executable"]
    REG["HashiCorp Registry\nregistry.terraform.io\n800+ providers"]

    TF -->|"1. terraform init\ndownload"| P

    subgraph P["Provider System"]
        direction LR
        PD["kreuzwerker/docker\nv3.0.2"]
        PA["hashicorp/aws\n~5.0"]
        PAZ["hashicorp/azurerm\n~3.0"]
        PG["hashicorp/google\n~5.0"]
    end

    PD -->|"API calls"| D["Docker API\nlocalhost"]
    PA -->|"API calls"| A["AWS API\naws.amazon.com"]
    PAZ -->|"API calls"| AZ["Azure API\nazure.microsoft.com"]
    PG -->|"API calls"| G["GCP API\ngcloud.google.com"]

    REG -.->|"provider source"| P
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "3.0.2"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"    # ~> = compatible with 5.x
    }
  }
}

The Terraform Lifecycle

flowchart LR
    INIT["terraform init\nDownloads declared\nproviders"]
    PLAN["terraform plan\nCompares config vs state\nShows changes"]
    APPLY["terraform apply\nExecutes the plan\nCreate/Modify/Destroy"]
    DESTROY["terraform destroy\nDismantles all\ninfrastructure"]
    STATE[("State File\nterraform.tfstate\nKnown real state")]

    INIT --> PLAN
    PLAN --> APPLY
    APPLY -->|"writes state"| STATE
    STATE -->|"compares"| PLAN
    APPLY -->|"optional"| DESTROY
CommandRoleNotes
terraform initLocates, downloads and prepares providersRe-run after adding new providers
terraform planCompares .tf files against state file, shows planned changes+ = create, ~ = update, - = destroy
terraform applyExecutes the plan and makes API calls to modify resourcesAsks for interactive confirmation
terraform destroyDismantles all infrastructure defined in the projectEquivalent to terraform apply -destroy

Demo: Basic Terraform Configuration

my-project/
├── main.tf                 # main configuration file
├── variables.tf            # variable declarations (optional)
├── outputs.tf              # values to export (optional)
├── .terraform/             # created by terraform init (don't commit)
├── .terraform.lock.hcl     # provider version lock file (commit recommended)
└── terraform.tfstate       # infrastructure state (don't commit)

The three fundamental blocks:

# 1. terraform block — global project configuration
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "3.0.2"
    }
  }
}

# 2. provider block — provider configuration
provider "docker" {
  # host = "unix:///var/run/docker.sock"  # default on Linux/macOS
}

# 3. resource blocks — resources managed by Terraform
resource "docker_image" "myapp" {
  name = "nginx:latest"
}

resource "docker_container" "myapp_container" {
  name  = "web-server"
  image = docker_image.myapp.image_id  # cross-resource reference

  ports {
    internal = 80
    external = 8080
  }
}

Cross-resource reference: Terraform automatically builds the dependency graph and creates resources in the correct order.


3. Managing Multiple Resources

Resources vs Data Sources

TypeKeywordLifecycleUsage
ResourceresourceFully managed by Terraform (creation → destruction)Infrastructure Terraform provisions and manages entirely
Data SourcedataRead-only, not managed by TerraformExisting resources whose properties to read without taking management
graph LR
    subgraph TF_MANAGED["Managed by Terraform\nterraform destroy = removes"]
        R1["resource\ndocker_image.app"]
        R2["resource\ndocker_container.app_container x3"]
        R3["resource\ndocker_network.app_net"]
        R4["resource\ndocker_container.proxy"]
    end

    subgraph EXISTING["Existing, read-only\nterraform destroy = keeps"]
        D1["data\ndocker_image.nginx\npulled via docker pull nginx"]
    end

    R1 -->|"image_id"| R2
    D1 -->|"id"| R4
    R3 -->|"id networks_advanced"| R2
    R3 -->|"id networks_advanced"| R4

Local Variables and Command-Line Variables

Local variables (locals):

locals {
  http_port      = 80
  container_port = 8080
  instance_count = var.environment == "prod" ? 5 : 2
}

resource "docker_container" "proxy" {
  ports {
    internal = local.http_port
    external = local.http_port
  }
}

Command-line variables (variable):

variable "environment" {
  type        = string
  description = "Target environment (dev, staging, prod)"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }

  sensitive = false
  default   = "dev"
}

resource "docker_container" "app" {
  count = var.environment == "prod" ? 5 : 1
  name  = "app-${var.environment}-${count.index}"
}

Ways to pass variables to Terraform:

# 1. Interactively (Terraform prompts)
terraform apply

# 2. Via command-line flag
terraform apply -var="environment=prod"

# 3. Via .tfvars file
terraform apply -var-file="prod.tfvars"

# 4. Via environment variable
export TF_VAR_environment=prod
terraform apply

The count Meta-Argument

resource "docker_container" "app_container" {
  count        = 3
  name         = "web-api-${count.index}"  # web-api-0, web-api-1, web-api-2
  image        = docker_image.app.image_id
  network_mode = "bridge"

  networks_advanced {
    name = docker_network.app_net.id
  }
}

Resources are referenced as docker_container.app_container[0], [1], [2]. If you modify an attribute that can only be set at creation time, Terraform will destroy and recreate the resource (shown as forces replacement in the plan).


4. Reference Architecture

Flask API + NGINX + Docker Network

graph TB
    CLIENT["HTTP Client"]

    CLIENT -->|"HTTP port 80"| NGINX

    subgraph DOCKER["Docker Network: appnet"]
        NGINX["nginx\nReverse Proxy / Load Balancer\ninternal port 80\nhost port 80"]

        NGINX -->|"round-robin to /ping"| F0["web-api-0\nApp API\nport 8080"]
        NGINX -->|"round-robin to /ping"| F1["web-api-1\nApp API\nport 8080"]
        NGINX -->|"round-robin to /ping"| F2["web-api-2\nApp API\nport 8080"]
    end

    subgraph IMAGES["Docker Images"]
        IMG1["app image\nTerraform resource\nmanaged and destroyed by TF"]
        IMG2["nginx:latest\nTerraform data source\nread-only, preserved"]
    end

    IMG1 -.->|"image_id"| F0
    IMG1 -.->|"image_id"| F1
    IMG1 -.->|"image_id"| F2
    IMG2 -.->|"id"| NGINX

Terraform Lifecycle Sequence

sequenceDiagram
    actor Dev as Developer
    participant CLI as Terraform CLI
    participant STATE as State File tfstate
    participant DOCKER as Docker API

    Dev->>CLI: terraform init
    CLI->>CLI: Downloads provider
    CLI-->>Dev: Terraform initialized

    Dev->>CLI: terraform plan
    CLI->>STATE: Reads current state (empty on first run)
    CLI->>CLI: Compares config vs state
    CLI-->>Dev: Plan: +6 to add, 0 to change, 0 to destroy

    Dev->>CLI: terraform apply
    CLI-->>Dev: Shows plan and asks for confirmation
    Dev->>CLI: yes
    CLI->>DOCKER: Creates docker_network.app_net
    CLI->>DOCKER: Creates docker_image.app (pull image)
    CLI->>DOCKER: Creates docker_container.app_container[0]
    CLI->>DOCKER: Creates docker_container.app_container[1]
    CLI->>DOCKER: Creates docker_container.app_container[2]
    CLI->>DOCKER: Creates docker_container.proxy
    CLI->>STATE: Writes new state
    CLI-->>Dev: Apply complete! Resources: 6 added

    Dev->>CLI: terraform destroy
    Dev->>CLI: yes
    CLI->>DOCKER: Destroys containers and network
    Note right of DOCKER: docker_image.nginx data source NOT destroyed
    CLI->>STATE: Clears state file
    CLI-->>Dev: Destroy complete!

5. GitOps and IaC in a CI/CD Pipeline

GitOps Workflow

flowchart TD
    DEV["Developer\nModifies .tf files"] -->|"git commit + push\nbranch: feature/update"| PR

    subgraph GIT["Git Repository"]
        PR["Pull Request\nCode Review"]
        PR -->|"Approved"| MAIN["Branch: main\nSingle source of truth"]
        PR -->|"Rejected"| DEV
    end

    MAIN -->|"trigger push to main"| PIPELINE

    subgraph PIPELINE["CI/CD Pipeline"]
        direction TB
        V["terraform validate\nSyntax check"]
        P["terraform plan\nShow changes"]
        APP["terraform apply\n--auto-approve"]
        V --> P --> APP
    end

    APP -->|"provisions"| INFRA["Target Infrastructure"]
    APP -.->|"remote state"| S3["Remote State\nS3 / Azure Blob\nTerraform Cloud"]

Terraform Pipeline in GitHub Actions

# .github/workflows/terraform.yml
name: Terraform IaC Pipeline

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

jobs:
  terraform:
    name: Terraform Plan & Apply
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Terraform Init
        run: terraform init
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -no-color
        if: github.event_name == 'pull_request'

      - name: Terraform Apply
        run: terraform apply --auto-approve
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'

6. Code File Reference

Basic Configuration (main.tf)

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "3.0.2"
    }
  }
}

provider "docker" {}

resource "docker_image" "app" {
  name = "nginx:alpine"
}

resource "docker_container" "app_container" {
  name  = "web-server"
  image = docker_image.app.image_id

  ports {
    internal = 80
    external = 8080
  }
}

Advanced Configuration (main.tf with load balancer)

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "3.0.2"
    }
  }
}

provider "docker" {}

locals {
  http_port      = 80
  container_port = 8080
}

resource "docker_image" "app" {
  name = "node:18-alpine"
}

data "docker_image" "nginx" {
  name = "nginx"
}

resource "docker_network" "app_net" {
  name = "appnet"
}

resource "docker_container" "app_container" {
  count        = 3
  name         = "web-api-${count.index}"
  image        = docker_image.app.image_id
  network_mode = "bridge"

  networks_advanced {
    name = docker_network.app_net.id
  }
}

resource "docker_container" "proxy" {
  name  = "nginx-proxy"
  image = data.docker_image.nginx.id

  ports {
    internal = local.http_port
    external = local.http_port
  }

  networks_advanced {
    name = docker_network.app_net.id
  }

  volumes {
    host_path      = "/path/to/nginx.conf"
    container_path = "/etc/nginx/nginx.conf"
  }
}

NGINX Reverse Proxy Configuration (nginx.conf)

events {}

http {
    upstream backend {
        server web-api-0:8080;
        server web-api-1:8080;
        server web-api-2:8080;
    }

    server {
        listen 80;

        location /ping {
            proxy_pass http://backend/ping;
        }

        location / {
            proxy_pass http://backend/;
        }
    }
}

7. Key Concepts and Best Practices

ConceptBest Practice
State fileNever commit terraform.tfstate to Git; use remote state (S3, Azure Blob, Terraform Cloud)
Lock fileCommit .terraform.lock.hcl to ensure consistent provider versions
Provider versionsAlways pin versions: version = "3.0.2" or version = "~> 5.0"
Variable secretsUse sensitive = true for passwords and tokens; never hardcode in .tf files
Resource namingUse consistent, descriptive names: <type>_<purpose>
Remote stateUse remote state for teams — avoids conflicts and enables collaboration
terraform fmtRun regularly to keep HCL formatting consistent
terraform validateRun in CI pipeline before every plan
Data sourcesUse for existing resources you don’t want Terraform to manage
count vs for_eachPrefer for_each over count when order matters

8. Essential Terraform Commands

# Project initialization
terraform init

# Format .tf files
terraform fmt

# Validate syntax
terraform validate

# Preview changes
terraform plan

# Apply changes
terraform apply

# Apply without confirmation prompt (CI/CD)
terraform apply --auto-approve

# Destroy all infrastructure
terraform destroy

# Destroy without confirmation prompt
terraform destroy --auto-approve

# State inspection
terraform state list
terraform state show docker_container.proxy

# Useful Docker commands for demos
docker pull nginx
docker ps
docker images
curl http://localhost/ping

Typical Development Cycle

flowchart TD
    A["Write/modify\n.tf files"] --> FMT["terraform fmt\nHCL formatting"]
    FMT --> VAL["terraform validate\nSyntax check"]
    VAL --> B["terraform plan\nSee changes"]
    B --> C{Changes\ncorrect?}
    C -->|No| A
    C -->|Yes| D["terraform apply\nApply changes"]
    D --> E["Verify\ninfrastructure"]
    E --> F{New\nmodifications?}
    F -->|Yes| A
    F -->|"No, temp env"| G["terraform destroy\nClean up"]
    G -->|"Rebuild if needed"| B

Search Terms

devops · automation · infrastructure · ci/cd · terraform · configuration · iac · gitops · lifecycle · main.tf · nginx · pipeline · reference · resources

Interested in this course?

Contact us to book it or get a custom training plan for your team.