Tutorial: GitLab CI/CD + NetBox-driven Ansible GitOps for datacenter network configs
Overview and architecture: how NetBox, Git repos, GitLab runners (Docker), and Ansible fit together for a GitOps flow in datacenter networking
This tutorial builds a practical GitOps pipeline where NetBox is the source of truth for device metadata (sites, roles, IPs, platforms, tags), Git stores automation code and intent, and GitLab CI/CD executes Ansible to validate and safely deploy configs.
What “GitOps for network configs” means here
- Desired state lives in Git (templates, vars, policies, generated configs).
- NetBox provides dynamic inventory and authoritative metadata.
- Pipelines run deterministic checks (lint, parse, validate), then controlled deploys.
- Merge Requests become the change control process (review + approvals + audit trail).
Reference architecture
- NetBox
- Device inventory (name, role, site, platform, primary IP, custom fields).
- Optional webhooks on device changes to trigger pipelines.
- GitLab Repo
- Ansible playbooks/roles, Jinja templates, validation scripts.
- CI configuration (
.gitlab-ci.yml). - GitLab Runner (Docker executor)
- Runs Ansible in a pinned container image for reproducibility.
- Uses environment variables for NetBox tokens and device credentials (or Vault).
- Ansible
- Uses NetBox to build inventory dynamically.
- Generates and/or pushes configs via
network_cli/httpapi. - Runs pre-flight validation (syntax, parsing, policy checks) before any deploy.
End-to-end flow (typical)
- Engineer opens MR changing intent (e.g., VLAN definition, template, policy).
- CI runs:
- lint + unit-style checks
- inventory generation from NetBox
- dry-run (
--check) + diff - config validation (parse/canonicalize)
- MR approval gates deploy.
- Deploy job runs on protected branch/tag, with canary rollout.
- Post-deploy verification and optional rollback hook.
Repo layout and inventory: modeling devices in NetBox, generating Ansible inventories via NetBox API with a Python helper, and recommended branching strategy
NetBox modeling recommendations (datacenter-focused)
Use NetBox fields consistently so inventory grouping is predictable:
Devices
- name: stable hostname
- site: DC1 / DC2
- device_role: leaf / spine / border / oob
- platform: cisco_ios, arista_eos, juniper_junos, etc.
- primary_ip4 or primary_ip: required for connectivity
- tags: canary, maintenance-window, no-deploy, golden, etc.
- Custom fields (optional but useful):
- ansible_network_os (if platform mapping is not enough)
- bgp_asn, mlag_domain, fabric, etc.
IPAM
- Prefixes and VLANs can be treated as intent inputs (generate config from NetBox).
- Keep device interface IPs in NetBox if you want stronger reconciliation checks.
Suggested repo layout
Keep playbooks and validation code in the same repo so CI can run everything locally:
.
├── ansible.cfg
├── inventories/
│ ├── netbox_inventory.py
│ └── group_vars/
│ ├── all.yml
│ ├── dc1.yml
│ └── leaf.yml
├── playbooks/
│ ├── build.yml
│ ├── deploy.yml
│ └── validate.yml
├── roles/
│ ├── fabric_base/
│ └── platform_ios/
├── templates/
│ └── ios/
│ └── leaf.j2
├── tests/
│ └── parse_ios_config.py
└── .gitlab-ci.yml
Inventory generation options
You have two common approaches:
-
Use the NetBox Ansible inventory plugin (
netbox.netbox.nb_inventory)
Good default if you want minimal code. -
Use a small Python helper
Useful when you need custom grouping, strict filtering, or deterministic hostvars.
Below is a minimal (but practical) Python dynamic inventory script using the NetBox REST API. It:
- Filters devices to “active”
- Requires primary_ip
- Groups by site.slug, role.slug, platform.slug
- Exposes useful hostvars (ansible_host, device_role, site, etc.)
#!/usr/bin/env python3
import os
import sys
import requests
NETBOX_URL = os.environ.get("NETBOX_URL", "https://netbox.example/api")
NETBOX_TOKEN = os.environ.get("NETBOX_TOKEN")
TIMEOUT = 20
def nb_get(path, params=None):
if not NETBOX_TOKEN:
raise SystemExit("NETBOX_TOKEN is required")
url = f"{NETBOX_URL.rstrip('/')}/{path.lstrip('/')}"
headers = {"Authorization": f"Token {NETBOX_TOKEN}"}
r = requests.get(url, headers=headers, params=params, timeout=TIMEOUT)
r.raise_for_status()
return r.json()
def main():
# Pagination handling
params = {"status": "active", "limit": 200}
devices = []
next_url = "dcim/devices/"
while next_url:
data = nb_get(next_url, params=params if next_url == "dcim/devices/" else None)
devices.extend(data["results"])
next_url = data["next"].replace(NETBOX_URL.rstrip("/") + "/", "") if data["next"] else None
inv = {"_meta": {"hostvars": {}}, "all": {"children": []}}
def ensure_group(g):
if g not in inv:
inv[g] = {"hosts": []}
inv["all"]["children"].append(g)
for d in devices:
name = d["name"]
primary_ip = d.get("primary_ip4") or d.get("primary_ip")
if not primary_ip:
continue
ansible_host = primary_ip["address"].split("/")[0]
site = (d.get("site") or {}).get("slug", "unknown_site")
role = (d.get("role") or {}).get("slug", "unknown_role")
platform = (d.get("platform") or {}).get("slug", "unknown_platform")
# groups
for g in (f"site_{site}", f"role_{role}", f"platform_{platform}"):
ensure_group(g)
inv[g]["hosts"].append(name)
inv["_meta"]["hostvars"][name] = {
"ansible_host": ansible_host,
"netbox_id": d["id"],
"device_role": role,
"site": site,
"platform": platform,
}
import json
print(json.dumps(inv, indent=2))
if __name__ == "__main__":
main()
Usage with Ansible:
- Make it executable: chmod +x inventories/netbox_inventory.py
- Run: ansible-inventory -i inventories/netbox_inventory.py --list
ansible.cfg baseline for network automation
Keep CI runs consistent and avoid interactive prompts:
- Disable host key checking for ephemeral runners (or manage known_hosts properly).
- Use YAML stdout callback for readable logs.
- Set
forkscarefully (network devices can be fragile under concurrency).
Example (conceptual):
- stdout_callback = yaml
- host_key_checking = False
- forks = 10
Branching strategy for network GitOps
A simple strategy that maps cleanly to approvals and protected deploys:
main: protected, deployable, only merged via MRfeature/*: intent changes, templates, rolesrelease/*(optional): staged rollouts for large changes- Tags (optional):
deploy-dc1-2026-01-27for auditable production deployments
Practical rules:
- Only deploy from protected refs (main or signed tags).
- Require MR approvals for changes touching templates/roles.
- Use CODEOWNERS for platform-specific reviews (e.g., IOS, EOS, Junos).
GitLab CI examples: .gitlab-ci.yml jobs for linting, dry-run (ansible --check), config validation (cisco/ios parsing), and integration tests running in Docker runners
Container image strategy
Use a pinned image with known-good versions:
- ansible-core
- netbox.netbox collection (if used)
- pyats / genie or at least a lightweight parser
- ansible-lint, yamllint, jmespath, requests
Build and publish it to GitLab Container Registry to avoid dependency drift in CI.
Example .gitlab-ci.yml (practical baseline)
This example shows:
- linting
- generating inventory
- dry-run --check
- config parsing/validation for IOS-like configs
- integration test stage (stubbed)
stages:
- lint
- validate
- test
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
default:
image: registry.gitlab.example/netauto/ansible-netbox:1.3.0
cache:
paths:
- .cache/pip
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
lint:
stage: lint
script:
- yamllint .
- ansible-lint -v
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
inventory:
stage: validate
script:
- python3 --version
- test -n "$NETBOX_URL"
- test -n "$NETBOX_TOKEN"
- ansible-inventory -i inventories/netbox_inventory.py --list > inventory.json
- jq '. | keys' inventory.json
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
dry_run_check:
stage: validate
script:
- ansible-playbook -i inventories/netbox_inventory.py playbooks/deploy.yml --check --diff
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
validate_ios_configs:
stage: validate
script:
# Example: parse candidate configs produced by a build step
- python3 tests/parse_ios_config.py build/ios/*.cfg
artifacts:
when: always
paths:
- build/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
integration_tests:
stage: test
script:
# Replace with containerlab/VRNetlab or a dedicated lab runner
- echo "Run integration tests against lab fabric"
- ./scripts/run_lab_tests.sh
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy_canary:
stage: deploy
script:
# Deploy only to canary-tagged devices first
- ansible-playbook -i inventories/netbox_inventory.py playbooks/deploy.yml
--limit "tag_canary:&platform_cisco_ios"
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: false
deploy_batch:
stage: deploy
script:
# Then the rest, excluding canaries if you want strict stepwise rollout
- ansible-playbook -i inventories/netbox_inventory.py playbooks/deploy.yml
--limit "platform_cisco_ios:!tag_no_deploy"
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: false
Notes on the CI design
when: manualfor deploy jobs is a common “safe by default” posture.- Canary targeting uses inventory groups derived from NetBox tags (you can implement
tag_*groups in the helper). - Use
artifactsto retain generated configs and diffs for audit/review.
Secrets, approvals and safe deploys: managing vaults/credentials, protected branches, merge request gating, canary/device-group rollouts and automated rollback hooks
Secrets management patterns (pick one and standardize)
- GitLab CI Variables (masked + protected)
- Store
NETBOX_TOKEN,ANSIBLE_USER,ANSIBLE_PASSWORD/ SSH keys - Mark as protected so only protected branches/tags can access them
- Ansible Vault
- Encrypt group_vars like
group_vars/all/vault.yml - Provide vault password via CI variable
ANSIBLE_VAULT_PASSWORD(masked/protected) - External secret manager (recommended at scale)
- HashiCorp Vault, CyberArk, AWS Secrets Manager, etc.
- Short-lived credentials reduce blast radius
Operational tip: for network devices, prefer per-environment credentials (lab vs prod), and keep write credentials unavailable to MR pipelines unless explicitly allowed.
Approvals and merge request gating
Use GitLab features to make “merge == approved change”:
- Protect main branch
- Require approvals (e.g., 1 platform owner + 1 peer reviewer)
- Use CODEOWNERS:
- templates/ios/* requires IOS owner approval
- roles/fabric_base/* requires fabric team approval
- Require pipeline success before merge
Canary and device-group rollouts
Model rollout cohorts in NetBox:
- Tag a small set as canary
- Use site-based waves: site_dc1 then site_dc2
- Use role-based waves: border then spine then leaf
In practice:
- Deploy to tag_canary first
- Validate (reachability, BGP neighbors, interface errors, config commit)
- Proceed to next cohort only if validation passes
Automated rollback hooks (keep it realistic)
Rollback depends on platform capabilities:
- IOS/IOS-XE: “archive” / replace config may help if preconfigured
- NX-OS, EOS, Junos: commit/rollback features vary and can be leveraged more safely
Practical rollback approach for CLI-based devices:
- Save pre-change “known good” config snapshot (device-side archive or fetched show run)
- If post-check fails:
- Apply rollback config or revert last change set
- Open an incident issue automatically with artifacts attached (diffs, logs)
At minimum, implement:
- pre-check: capture baseline state
- post-check: validate intended invariants
- failure hook: stop further rollout + alert + attach evidence
Scaling and observability: how to add drift detection, tie NetBox webhooks to pipelines, measure deployment latency, and run periodic reconciliation to enforce desired state
Drift detection (continuous assurance)
Two common models:
Model A: state from device vs generated intent
- Generate “candidate” config from templates + vars
- Retrieve running config (show run)
- Normalize both (strip timestamps, banners, dynamic lines)
- Compare and report drift (do not auto-fix initially)
Model B: invariants/policies
- Check “must-have” lines or structured facts (neighbors, VLANs, NTP, SNMP)
- Less brittle than full config diffs
Implementation ideas:
- Nightly scheduled pipeline that runs:
- gather facts
- render intent
- compare
- create an MR proposing fixes (optional) or open an issue
NetBox webhooks to trigger pipelines
Use NetBox webhooks so inventory changes can trigger validation:
- Device created/updated
- Primary IP changed
- Tag added/removed (e.g., canary, no-deploy)
- Interface/IP changes (if you treat NetBox IPAM as intent)
Flow:
1. NetBox sends webhook to GitLab trigger endpoint (or an intermediary)
2. Pipeline runs “inventory + validation” jobs
3. If new device is missing required metadata, pipeline fails with actionable errors
Tip: keep webhook-triggered pipelines read-only (no deploy) unless explicitly authorized.
Measure deployment latency and success rate
Add lightweight telemetry:
- Record start/end timestamps per device (Ansible callbacks can emit JSON)
- Publish artifacts: deployment_report.json
- Track:
- time to render configs
- time to connect/authenticate
- time to push changes
- failure categories (auth, timeout, parse, post-check)
Even without a full observability stack, GitLab job durations + structured artifacts can highlight systemic issues (e.g., authentication slowness, specific site latency).
Periodic reconciliation to enforce desired state
A pragmatic maturity path:
1. Detect drift only (report)
2. Auto-open MR with proposed remediations
3. Auto-remediate for safe, low-risk changes (NTP/SNMP/syslog)
4. Full reconcile for standardized roles once confidence is high
To avoid outages:
- Exclude devices tagged no-deploy
- Require maintenance windows (NetBox custom field or tag)
- Limit concurrency (serial: in playbooks, forks in Ansible)
Key takeaways
- NetBox is most effective when treated as authoritative metadata and grouping logic for automation, not just documentation.
- A reproducible CI environment (pinned Docker image) makes network pipelines far more reliable than ad-hoc runner installs.
- Combine lint + dry-run + parse/validate + staged deploy to catch problems before touching production.
- Protect deploy credentials using protected variables/branches and keep MR pipelines mostly read-only.
- Scaling safely means adding drift detection, webhook-triggered validation, and rollout waves (canary → cohorts) with clear stop conditions.
Common pitfalls
- Inconsistent NetBox data: missing primary IPs, inconsistent platform slugs, or roles not standardized leads to broken grouping and flaky pipelines.
- Deploying from unprotected refs: if feature branches can access write credentials, a compromised branch can become a production incident.
- Overly strict full-config diffs: raw
show rundiffs are noisy (dynamic lines, ordering). Normalize or use invariant checks. - Too much concurrency: high forks against control-plane-limited devices can cause timeouts and false failures; tune
forksand useserial. - No post-deploy verification: “pushed config” is not the same as “network is healthy.” Add neighbor/session/interface sanity checks.
- Skipping artifact retention: without saved rendered configs/diffs/logs, troubleshooting and audits become slow and speculative.