Tutorial: GitLab CI/CD + NetBox-driven Ansible GitOps for datacenter network configs

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)

  1. Engineer opens MR changing intent (e.g., VLAN definition, template, policy).
  2. CI runs:
  3. lint + unit-style checks
  4. inventory generation from NetBox
  5. dry-run (--check) + diff
  6. config validation (parse/canonicalize)
  7. MR approval gates deploy.
  8. Deploy job runs on protected branch/tag, with canary rollout.
  9. 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:

code
.
├── 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:

  1. Use the NetBox Ansible inventory plugin (netbox.netbox.nb_inventory)
    Good default if you want minimal code.

  2. 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.)

python
#!/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 forks carefully (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 MR
  • feature/*: intent changes, templates, roles
  • release/* (optional): staged rollouts for large changes
  • Tags (optional): deploy-dc1-2026-01-27 for 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)

yaml
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: manual for 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 artifacts to 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)

  1. GitLab CI Variables (masked + protected)
  2. Store NETBOX_TOKEN, ANSIBLE_USER, ANSIBLE_PASSWORD / SSH keys
  3. Mark as protected so only protected branches/tags can access them
  4. Ansible Vault
  5. Encrypt group_vars like group_vars/all/vault.yml
  6. Provide vault password via CI variable ANSIBLE_VAULT_PASSWORD (masked/protected)
  7. External secret manager (recommended at scale)
  8. HashiCorp Vault, CyberArk, AWS Secrets Manager, etc.
  9. 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 run diffs 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 forks and use serial.
  • 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.