Direct answer
Use them for different layers and give each object one writer. Ansible is the better fit for configuring the devices themselves (VLANs, the separate virtual LANs a switch carries, and BGP sessions, the connections over which routers exchange routes), because its network modules speak CLI or NETCONF, work on the device's current config, and offer check mode with a diff. Terraform is the better fit for objects that have an API with create, read, update and delete semantics, such as cloud VPCs (private networks inside a cloud account), transit gateways (cloud routers that join VPCs and on-premises networks) and controller-managed objects. They combine well in one pipeline: Terraform provisions the cloud side and emits outputs (peer IPs and ASNs, the autonomous system numbers that identify each network in BGP), and Ansible consumes them as variables to configure the device side.
How the two differ for network work
| Question | Terraform | Ansible network modules |
|---|
| Model | Declarative; a state file maps each resource in your code to a real object | Declarative resource modules with a state option; no state file, the device is the record |
| Preview | terraform plan; Terraform refreshes state from the real infrastructure first, so out-of-band changes show as drift | Check mode and diff where the module supports them (confirm that for the module you use); rendered shows the commands with no device contact |
| Device coverage | Only where a provider (a plugin that teaches Terraform one platform's API) exists (for example, Cisco publishes one for IOS-XE, which manages the device through its programmatic API instead of the CLI); coverage depends on the provider | Large per-platform module collections, plus raw CLI modules for the gaps |
| Removing a line of code | Plan proposes destroying the resource | merged leaves it alone. replaced removes only settings you dropped from an entry you still list, so an entry deleted from the code is not touched. overridden removes every resource not listed. deleted removes only what you list under it, but with no config at all it removes everything of that type (for ios_vlans, every non-default VLAN; for ios_bgp_global, all BGP settings while the router process stays), so it is only safe with an explicit list |
| Sensitive data | State files contain it, so use a remote backend with locking and access control | Credentials are inventory or vault variables |
What the Ansible side looks like
The resource modules share these states: merged (add to what exists), replaced (replace the listed subsection), overridden (replace the whole resource with what you list), deleted, gathered (read facts), rendered (produce device-native commands offline) and parsed (turn config text into data). Rendering is a safe way to review output, and executing it shows lines you did not write:
yaml
- hosts: eos
gather_facts: false
tasks:
- arista.eos.eos_vlans:
config:
- {vlan_id: 110, name: users}
- {vlan_id: 120, name: voice}
state: rendered
register: v
- ansible.builtin.debug: {var: v.rendered}
- hosts: ios
gather_facts: false
tasks:
- cisco.ios.ios_bgp_global:
config:
as_number: "65001"
neighbors:
- {neighbor_address: 192.0.2.1, remote_as: "65002"}
state: rendered
register: b
- ansible.builtin.debug: {var: b.rendered}
With an inventory that defines sw1 (connection ansible.netcommon.network_cli, network OS arista.eos.eos) and rtr1 (cisco.ios.ios), the output is:
"v.rendered": ["vlan 110", "name users", "vlan 120", "name voice"]
"b.rendered": ["router bgp 65001", "no bgp default ipv4-unicast", "no bgp default route-target filter", "neighbor 192.0.2.1 remote-as 65002"]
The neighbor 192.0.2.1 remote-as 65002 line declares a BGP neighbor (a peer router) in autonomous system 65002. The BGP module also added two no bgp default ... lines nobody wrote. no bgp default ipv4-unicast turns off the IOS behaviour of automatically exchanging IPv4 routes with every new neighbor, so each neighbor must be activated explicitly (Cisco command reference). no bgp default route-target filter turns off automatic filtering of VPN routes by route target; the module documents the option as "Control automatic VPN Route-Target filtering", on by default. They matter because they change how the router treats sessions beyond the one you wrote: a neighbor that relied on the automatic IPv4 activation would exchange no routes until it is activated. That is why the rendered or diff output is the review artifact, not the YAML.
Recommendation for this team
Run the VLANs and BGP sessions on the physical network with Ansible resource modules: merged for routine adds, replaced for one VLAN or one neighbor, and overridden only in a reviewed job (on VLANs it would remove every VLAN not listed, and Ansible's documentation warns that overriding can remove your access to the device, for example by overriding the management interface configuration, so a management VLAN left off the list is at risk). Use Terraform for the cloud network and for any controller or device that has a mature provider. Together: a pipeline stage runs Terraform, publishes its outputs (for example a cloud router's peer address and ASN), and the next stage templates them into the Ansible variables for the on-premises BGP neighbor.
What would flip it: if every device is managed through one controller that has a good Terraform provider, Terraform alone gives one workflow, one state, one plan.
Pitfalls
- Two tools writing the same object fight each other: Terraform sees Ansible's change as drift and reverts it, or the reverse. Assign each object to one tool.
- Terraform treats a deleted block as an instruction to delete the real object. On a production BGP session, put the plan output through human review and add a guard that blocks destroy on those resources.
- Ansible has no state file, so a change made by hand is only found when you gather facts or run with a diff; schedule a check-mode run to detect drift.
overridden is not the only state that can remove unlisted resources: deleted with an empty or missing config removes every resource of that type. Review any job that uses either state with a diff first.