Skip to content

Network Automation with Terraform

Terraform brings one idea Ansible deliberately downplays: state. This article covers how Terraform manages Cisco IOS XE — the init → plan → apply workflow, the state file that lets it detect drift, the CiscoDevNet providers, and the for_each, module, and lifecycle constructs you reach for on real deployments.

Declarative and stateful

You describe the infrastructure you want in HashiCorp Configuration Language (HCL). Terraform compares that description against a stored state file — its record of what already exists — and builds a plan: the minimal set of create, update, or delete actions needed to close the gap. Run HCL against an empty environment and Terraform creates everything; run it again and it does nothing, because reality already matches. The state file is what tells those two situations apart.

That is the core distinction between the two tools. Ansible is procedural and push-based — it runs tasks in order every time. Terraform is declarative and stateful.

Ansible Terraform
Model Procedural / push Declarative + stateful
State None — re-evaluates each run State file records reality
Sweet spot Config push to many CLI devices Lifecycle on controllers (ACI, Meraki, SD-WAN)
Language YAML HCL
Agent Agentless Agentless

In practice the two are complementary, not rivals. Terraform stands up and lifecycles controller-based platforms where drift detection matters; Ansible pushes day-to-day CLI changes across a large brownfield estate. Mature shops keep both in the same Git repo and CI/CD pipeline.

The workflow: init → plan → apply

Three commands cover the daily loop:

terraform init     # download providers, initialize the working dir and backend
terraform plan     # show the diff between desired and recorded state — applies nothing
terraform apply    # execute the plan after confirmation
# terraform destroy removes managed resources

plan is your safe dry run

terraform plan never touches the infrastructure — it only prints the proposed create/update/delete actions. When you need to preview a change without applying it, the answer is plan, not apply, init, or validate (which only checks syntax).

Providers: how Terraform talks to Cisco

Terraform itself knows nothing about networking. Providers are plugins that translate HCL resources into API calls, and Cisco publishes them under the CiscoDevNet namespace: iosxe, aci, nxos, meraki, sdwan, ise, and catalystcenter. You declare which ones you need in a required_providers block and pin a version so a later release can't change behaviour under you:

# providers.tf
terraform {
  required_providers {
    iosxe = {
      source  = "CiscoDevNet/iosxe"
      version = "~> 0.5"
    }
  }
}

provider "iosxe" {
  url      = var.iosxe_url        # https://192.0.2.11
  username = var.iosxe_user
  password = var.iosxe_password
  insecure = true                 # lab only; use valid certs in production
}

Transport has evolved across versions — pin and check the docs

One widely deployed generation of the IOS XE provider rides on RESTCONF (HTTPS, RFC 8040), so enabling RESTCONF on the device is a prerequisite. Newer releases of the provider have shifted toward NETCONF over SSH as the default transport, with configuration arguments (host, protocol) that differ from the url-based block above. Because argument names and the default protocol depend on the version you pin, always confirm against the provider documentation for your version before writing HCL.

Variables and secrets

Declare inputs with variable blocks and supply values from a terraform.tfvars file, environment variables (TF_VAR_*), or the CLI. Mark anything secret so it stays out of command output:

# variables.tf
variable "iosxe_url"      { type = string }
variable "iosxe_user"     { type = string }
variable "iosxe_password" {
  type      = string
  sensitive = true
}

sensitive = true redacts, it does not encrypt

Marking a variable sensitive keeps its value out of plan/apply output and logs — but the value can still land in the state file in clear text. Protect state itself with a remote backend, and never commit real secrets to terraform.tfvars.

Worked example: VLANs, interfaces, OSPF

A resource block declares one managed object. Terraform records its identity in state, so if someone deletes a VLAN out-of-band, the next plan shows drift and offers to recreate it. (Resource names vary by provider version — check the docs for the version you pinned.)

# vlans.tf
resource "iosxe_vlan" "sales" {
  vlan_id = 10
  name    = "SALES"
}

resource "iosxe_interface_ethernet" "uplink" {
  type        = "GigabitEthernet"
  name        = "2"
  description = "Uplink to core"
  shutdown    = false
}

resource "iosxe_ospf" "core" {
  process_id = 1
  router_id  = "1.1.1.1"
}

Real deployments rarely hard-code every object. A map variable plus for_each creates many resources from data, keeping configuration DRY:

# vlans_loop.tf
variable "vlans" {
  type = map(string)
  default = {
    "10" = "SALES"
    "20" = "VOICE"
    "99" = "MGMT"
  }
}

resource "iosxe_vlan" "all" {
  for_each = var.vlans
  vlan_id  = tonumber(each.key)
  name     = each.value
}

Prefer for_each over count here. count tracks resources by numeric index, so removing one VLAN from the middle of a list re-indexes — and can destroy and recreate — every resource after it. for_each tracks each resource by a stable key, so removing one item touches only that item (HashiCorp: count vs. for_each).

State, drift, and the dependency graph

The state file (terraform.tfstate) maps your HCL resources to real objects. Because it can hold sensitive values and must be shared by a team, production setups use a remote backend (an HTTP backend, an S3-compatible bucket, or Terraform Cloud) with state locking so two engineers can't apply at once.

Never edit state by hand

Hand-editing terraform.tfstate is how you corrupt a project. To move or remove a tracked object, use the terraform state subcommands (state mv, state rm) so Terraform keeps its bookkeeping consistent.

Because Terraform is declarative, you never specify an order of operations. It builds a dependency graph from the references between resources — if resource B interpolates A.id, Terraform knows A must come first — and parallelizes everything independent. This is why the order of blocks in your .tf files doesn't matter. When an ordering isn't expressed by a reference, state it with depends_on, and tune replacements with the lifecycle meta-argument:

resource "iosxe_interface_ethernet" "uplink" {
  name        = "1/0/1"
  description = "core-uplink"

  lifecycle {
    create_before_destroy = true          # no gap during replacement
    ignore_changes        = [description]  # the NMS owns this field
  }
}

Read the plan's replace symbols

A forced replacement shows as -/+ (destroy then create). create_before_destroy flips it to +/- (create then destroy) — the safer order for anything carrying traffic.

A scheduled plan in CI turns drift detection into a lightweight compliance check: a non-empty plan means something changed the network outside the pipeline, and teams alert on that before applying.

Modules and workspaces

A module is a reusable package of .tf files with inputs and outputs — a "branch-site" module might take a site ID and VLAN list and create every matching resource. Workspaces give one configuration multiple independent state files, so the same code runs against dev, test, and prod:

terraform workspace new prod
terraform workspace select dev

Workspaces isolate state, not credentials

Two workspaces can still point at the same real device if their variables say so. Workspaces separate state; your variables and provider credentials must also differ per environment, or a dev apply can reach into production hardware.

Key takeaways

  • Terraform is declarative + stateful; Ansible is procedural + agentless. State is the key differentiator.
  • The workflow is always init → plan → apply; plan is a safe dry run that also surfaces drift.
  • Providers do the platform work; the IOS XE provider commonly rides on RESTCONF, but pin a version and confirm the transport in its docs.
  • Protect the state file with a remote backend and locking; never hand-edit it.
  • Use for_each (not count) for many-from-data, modules for reusable designs, and workspaces for per-environment state.

Sources: CiscoDevNet/terraform-provider-iosxe, HashiCorp — count vs. for_each meta-argument, Cisco IOS XE & Terraform (Cisco Live DEVNET-2464).