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;
planis 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(notcount) 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).