Terraform Module Design, Remote State, and Ansible Fundamentals

This post covers two topics: Terraform module design with GCS remote state, and Ansible’s core structure with idempotency verification. The two are complementary — Terraform provisions infrastructure, Ansible configures servers internally.

Factoring Reusable Infrastructure Units

Avoiding Copy-Paste Across Environments

Without modules, the same VPC logic is written separately for dev and prod in Terraform. Change one, forget the other, and problems arise. Modules work like functions: define once, use with different parameters.

Target structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
terraform/
├── modules/
│   └── vpc/
│       ├── variables.tf    # inputs
│       ├── main.tf         # resource definitions
│       └── outputs.tf      # outputs
└── environments/
    ├── dev/
    │   ├── backend.tf
    │   ├── variables.tf
    │   ├── terraform.tfvars
    │   └── main.tf
    └── prod/
        └── ...

modules/ defines reusable infra units. environments/ handles actual deployment, passing in different variable values.

Input Declarations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-east1"
}

variable "vpc_name" {
  type = string
}

variable "subnet_name" {
  type = string
}

variable "subnet_cidr" {
  type = string
}

Resource Definitions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
resource "google_compute_network" "vpc" {
  name                    = var.vpc_name
  auto_create_subnetworks = false
  project                 = var.project_id
}

resource "google_compute_subnetwork" "subnet" {
  name          = var.subnet_name
  network       = google_compute_network.vpc.id
  region        = var.region
  ip_cidr_range = var.subnet_cidr
  project       = var.project_id
}

resource "google_compute_firewall" "allow_internal" {
  name    = "${var.vpc_name}-allow-internal"
  network = google_compute_network.vpc.name
  project = var.project_id

  allow {
    protocol = "tcp"
  }
  allow {
    protocol = "udp"
  }
  allow {
    protocol = "icmp"
  }

  source_ranges = [var.subnet_cidr]
}

resource "google_compute_firewall" "allow_ssh" {
  name    = "${var.vpc_name}-allow-ssh"
  network = google_compute_network.vpc.name
  project = var.project_id

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  source_ranges = ["0.0.0.0/0"]
}

Exported Values

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
output "vpc_id" {
  value = google_compute_network.vpc.id
}

output "vpc_name" {
  value = google_compute_network.vpc.name
}

output "subnet_id" {
  value = google_compute_subnetwork.subnet.id
}

Non-Production Workspace Instantiation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
provider "google" {
  project = var.project_id
  region  = var.region
}

module "vpc" {
  source      = "../../modules/vpc"
  project_id  = var.project_id
  region      = var.region
  vpc_name    = "devops-vpc"
  subnet_name = "devops-subnet"
  subnet_cidr = "10.0.1.0/24"
}

Concrete Variable Assignments

1
2
project_id = "devops-lab-lou-2026"
region     = "us-east1"

The prod environment calls the same module, just passing different vpc_name and subnet_cidr. The module itself remains unchanged.

Distributed Snapshot Backend and Write-Lock Guard

On-Disk Snapshots Cannot Serve Multiple Contributors

Terraform’s state file records “what currently exists on GCP.” Without remote state, the state only exists locally:

  • Local machine lost = all infrastructure information gone
  • Multi-person collaboration = each person has their own state copy, conflicting with each other

GCS backend stores state centrally in a GCS bucket, shared by everyone.

Preventing Simultaneous Apply Races

If two people run terraform apply simultaneously, both read the same state, each calculates a diff, and each applies changes — unpredictable results.

State locking means the first apply locks the state after starting, and the second person must wait for the lock to be released. This corresponds to the Consistency in CAP Theorem: in a distributed system, two operations cannot simultaneously modify the same thing.

1
2
3
4
5
6
7
# environments/dev/backend.tf
terraform {
  backend "gcs" {
    bucket = "devops-lab-lou-2026-tfstate"
    prefix = "environments/dev"
  }
}

GCS backend natively supports locking, implemented via GCS object’s generation mechanism. No additional configuration needed.

Initialization:

1
2
3
terraform init
terraform plan
terraform apply

The reason plan and apply should be separate: plan shows “what will happen,” apply actually executes. In production environments, plan results are typically reviewed before applying.

Querying and Reordering Snapshot Entries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# list all resources tracked in state
terraform state list

# show detail of a specific resource
terraform state show module.vpc.google_compute_network.vpc

# move resource to a different address (e.g. refactor module path)
terraform state mv old_address new_address

# remove resource from state without destroying it
terraform state rm module.vpc.google_compute_network.vpc

state rm does not delete the resource on GCP — it only makes Terraform stop tracking it. On the next apply, Terraform thinks the resource doesn’t exist and may attempt to recreate it. So state rm is typically paired with terraform import: first remove, then re-import to a new address.

Host Configuration via Agent Playbooks

Provisioning Tool Versus Configuration Tool

Terraform Ansible
Purpose Provision infrastructure Configure servers internally
Language HCL (declarative) YAML playbook
State State file No state, re-checks each time
Connection method API call SSH (agentless)
Idempotency Naturally idempotent Requires correct playbook writing

Ansible agentless means: target machines don’t need any Ansible agent installed, only SSH and Python. Ansible runs on the control machine (your laptop or CI runner), connecting to target machines via SSH to execute operations.

In modern K8s environments, Ansible’s role has diminished because K8s YAML replaces much of the configuration work. But VM and on-premise environments are still very common.

Inventory, Module, Task, Playbook, Role

Inventory: Which machines you manage, grouped for organization.

1
2
3
4
5
6
7
8
9
[local]
localhost ansible_connection=local

[webservers]
web1.example.com
web2.example.com

[databases]
db1.example.com ansible_user=ubuntu

Module: Ansible’s built-in operation units, e.g., file, copy, apt, service. Modules are the foundation of idempotency — each module checks target state before executing. If state already matches, it does nothing.

Task: One step, calling one module.

Playbook: A combination of one or more tasks, defining what to execute on which hosts.

Role: A collection of multiple tasks, reusable, similar to Terraform modules. Suitable for encapsulating reusable logic like nginx installation or prometheus configuration.

Minimal YAML Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
---
- name: localhost practice
  hosts: local
  tasks:
    - name: create practice directory
      file:
        path: /tmp/ansible-practice
        state: directory
        mode: "0755"

    - name: write config file
      copy:
        dest: /tmp/ansible-practice/config.txt
        content: |
          env=dev
          version=1.0

    - name: show file content
      command: cat /tmp/ansible-practice/config.txt
      register: file_content

    - name: print result
      debug:
        msg: "{{ file_content.stdout }}"
1
2
3
4
5
# check connectivity
ansible local -i inventory.ini -m ping

# run playbook
ansible-playbook -i inventory.ini playbook.yml

Confirming No-Op on Re-Run

First run:

1
2
3
4
TASK [create practice directory] ... changed
TASK [write config file]         ... changed
TASK [show file content]         ... changed
TASK [print result]              ... ok

Second run (without changing anything):

1
2
3
4
TASK [create practice directory] ... ok      # already exists, no action
TASK [write config file]         ... ok      # content unchanged, no action
TASK [show file content]         ... changed # command module always runs
TASK [print result]              ... ok

file and copy modules are idempotent. The second run showing ok means no changes were made. command module is not idempotent — it always executes. This is a design point requiring attention. Idempotent command operations need creates or when condition controls.

References