Skip to content

Adding CI/CD

In modern software development, you continuously build, test and deploy iterative code changes. This process helps reduce the chance that you develop new code based on buggy or failed previous versions. These principles can also be applied to network infrastructure configuration. Creating a Continuous Integration / Continuous Development (CI/CD) process for network configuration lowers the amount of human intervention, and minimizes risk associated with changes.

Consider the limitations imposed in a production environment, by following the simple or comprehensive example. Storing the code locally allows only a single user to make modifications. Making the code available in a Git repository would not fully address problem as the Terraform state is still stored locally. Only the user that ran terraform apply would be aware of the latest state of the configuration.

This section of Network-as-Code helps the user create a basic CI/CD environment using GitLab. It serves as an example. GitLab offers powerful features such as the ability to create Git repositories, create teams, manage Terraform state, and CI/CD.

This guide helps you to set up a pipeline with the following stages:

  • Validate
  • Build
  • Deploy
  • Cleanup

Step 1: Getting started with Gitlab

Sign up for a GitLab SaaS account at GitLab. If you prefer self-managing your own GitLab instance you can download the packages here. This guide uses the SaaS version of GitLab, but the same principles apply.

After creating your account, sign in to GitLab. After your first login you are prompted with a few questions. You can either create a new project from here, or create a project from the GitLab dashboard.

Select Import Project.

Under Import Project from select Repository by URL. Set the GIT repository URL to https://github.com/netascode/nac-aci-simple-example.git.

Give the project a Name, Description, and make sure to keep the repository private. Click on Create project once you are satisfied with your configuration.

Step 2: Setting up a Runner

The environment must typically be prepared with a Runner. A Runner is a process that picks up and executes CI/CD jobs for GitLab projects. As it is unlikely for the Application Policy Infrastructure Controller (APIC) to be reachable from the internet, a (local) runner may be used to access it. Runners can be installed using Linux, macOS, FreeBSD, and Windows. It can be installed:

  • In a container.
  • By downloading a binary manually.
  • By using a repository for rpm/deb packages.

This guide assumes installation on Linux through rpm/deb packages. It also assumes that Docker Engine is installed as the Runner will be set up as Docker Executor. This allows the Runner to connect to Docker Engine to run and build each build in a separate and isolated container using a predefined image that will be configured later. For instructions on how to install Docker Engine, see: Docker Engine Installation. It is advised to install the Runner on a dedicated (virtual) machine instead of locally on your computer.

To install GitLab Runner:

  1. Add the official GitLab repository:

For Debian/Ubuntu/Mint:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

For RHEL/CentOS/Fedora:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
  1. Install the latest version of GitLab Runner: For Debian/Ubuntu/Mint:
sudo apt-get install gitlab-runner

For RHEL/CentOS/Fedora:

sudo yum install gitlab-runner
  1. Create a new Runner:

Within the imported project, navigate to Settings > CI/CD, and expand the Runners section. Click on Create project runner to add a new runner for this project. Select the operating system of choice and make sure to check run untagged jobs. Optionally add a description for the runner.

Disable using Instance runners for this project. Note that if this is not disabled, available shared runners will start picking-up CI/CD jobs. It is unlikely these have access to the APIC controllers. Hence this option should be disabled.

  1. Register the runner: On the machine where the runner is installed, run the provided command:
gitlab-runner register --url https://gitlab.com --token glrt-xxxx

and provide the following configuration:

  • URL: https://gitlab.com (note that this is a different URL when you use self-managed GitLab)
  • Name: Optionally provide a name
  • Executor: docker
  • Default Docker image: docker

Completed output (not that your output may look different depending on the operating system):

gitlab-runner register --url https://gitlab.com --token glrt-xxxx
Enter the GitLab instance URL (for example, https://gitlab.com/):
[https://gitlab.com]:
Verifying runner... is valid correlation_id=ee674944d1ac4090a5ec961320c70e8b runner=Py8QHuPeT
Enter a name for the runner. This is stored only in the local config.toml file:
[centos-stream10-runners]: cicd-nac-runner
Enter an executor: virtualbox, docker-windows, docker+machine, kubernetes, ssh, docker, docker-autoscaler, instance, custom, shell, parallels:
docker
Enter the default Docker image (for example, ruby:3.3):
docker
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
Configuration (with the authentication token) was saved in "/home/<user>/.gitlab-runner/config.toml"

Make sure that your Runner is listed as available in GitLab:

For issues with the GitLab Runner installation please see: Runner FAQ. You can verify whether gitlab-runner is running and can contact GitLab with gitlab-runner status and gitlab-runner verify. In case the runner seems active but is not picking up jobs in the pipeline in a later step, you may also try to use the sudo gitlab-runner run& to run the process in the background.

Completed output (note that your output may look different depending on your local machine):

[root@localhost ~]# gitlab-runner status
Runtime platform arch=amd64 os=linux pid=1598 revision=9ba718cd version=18.3.0
gitlab-runner: Service is running
[root@localhost ~]# gitlab-runner verify
Runtime platform arch=amd64 os=linux pid=1606 revision=9ba718cd version=18.3.0
WARNING: Running in user-mode.
WARNING: The user-mode requires you to manually start builds processing:
WARNING: $ gitlab-runner run
WARNING: Use sudo for system-mode:
WARNING: $ sudo gitlab-runner...
Verifying runner... is valid correlation_id=8f7a6ca9a3794e5aa31d8af0aac9f0b4 runner=Py8QHuPeT

Step 3: Creating a pipeline

In the newly created GitLab project main page, click the clone button and copy the URL displayed under Clone with HTTPS.

When using Visual Studio Code, navigate to view -> Command palette..., type clone and select the Git: Clone option. Provide the repository URL from the GitLab project.

A prompt will appear to select a folder where the cloned repository should be stored. Select a path and continue with Select as Repository Destination. When prompted to open the cloned repository, select open.

Alternatively, the repository can be cloned from the command-line interface.

~/Documents/coding > git clone https://gitlab.com/your-org/your-project.git
Cloning into 'cicd-nac'...
remote: Enumerating objects: 121, done.
remote: Total 121 (delta 0), reused 0 (delta 0), pack-reused 121 (from 1)
Receiving objects: 100% (121/121), 24.29 KiB | 4.86 MiB/s, done.
Resolving deltas: 100% (59/59), done.

Create a new file .gitlab-ci.yml in the root of this folder and add the following code:

image:
name: hashicorp/terraform:latest
entrypoint: [""]
variables:
TF_ROOT: ${CI_PROJECT_DIR}
TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_PROJECT_NAME}
before_script:
- export TF_HTTP_USERNAME="gitlab-ci-token"
- export TF_HTTP_PASSWORD="${CI_JOB_TOKEN}"
- export TF_HTTP_LOCK_METHOD="POST"
- export TF_HTTP_UNLOCK_METHOD="DELETE"
- export TF_HTTP_RETRY_WAIT_MIN="5"
- cd ${TF_ROOT}
- |
terraform init -reconfigure \
-backend-config="address=${TF_ADDRESS}" \
-backend-config="lock_address=${TF_ADDRESS}/lock" \
-backend-config="unlock_address=${TF_ADDRESS}/lock"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- ${TF_ROOT}/.terraform
stages: [validate, build, deploy, cleanup]
fmt:
stage: validate
script:
- terraform fmt -check
allow_failure: false
validate:
stage: validate
script:
- terraform validate
allow_failure: false
build:
stage: build
script:
- terraform plan -out=plan.tfplan
artifacts:
name: "Terraform Plan: ${CI_COMMIT_REF_SLUG}"
paths:
- ${TF_ROOT}/plan.tfplan
deploy:
stage: deploy
script:
- terraform apply -input=false plan.tfplan
dependencies:
- build
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
- when: never
cleanup:
stage: cleanup
script:
- terraform destroy -auto-approve
dependencies:
- deploy
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

Save the file and continue with the next step.

For more information about .gitlab-ci.yml and other examples, see: .gitlab-ci.yml

Step 4: Changing Terraform Backend

When using Runners it becomes even more important to use a remote backend. The reason is that each job in the pipeline is ran by a new, immutable container which runs a specific task and completes. The Terraform state would be lost forever without a remote backend. Navigate to main.tf and add a remote backend section for terraform. This instructs Terraform to make use of a remote backend.

terraform {
backend "http" {
}
}

The beginning of your main.tf file should now look like this:

terraform {
required_providers {
aci = {
source = "CiscoDevNet/aci"
}
}
}
terraform {
backend "http" {
}
}
~output omitted~

Save the updated main.tf file and continue with the next step.

Note that there are multiple options for remote backends such as AWS S3, Consul or Terraform Cloud. This guide leverages the built-in Terraform backend provided by GitLab.

Step 5: Managing variables

As this code will be stored centrally in a GitLab repository, it is a good practice to replace any credentials or sensitive data with variables. These variables can be stored securely in GitLab and passed to your Runner as environment variables when executing different jobs. Note that this only happens when your branch is set to protected, which is the default setting. This setting can be overruled per variable setting.

In the provider "aci" block you can remove username, password and url, as those will be passed as environment variables.

provider "aci" {
}

Keep in mind the use of a username and password is easiest, but the use of signature-based authentication is preferred. For more information see Terraform Provider Documentation.

The final main.tf file should look like this:

terraform {
required_providers {
aci = {
source = "CiscoDevNet/aci"
}
}
}
terraform {
backend "http" {
}
}
provider "aci" {
}
module "aci" {
source = "netascode/nac-aci/aci"
version = "1.x.x"
yaml_directories = ["data"]
manage_access_policies = false
manage_fabric_policies = false
manage_pod_policies = false
manage_node_policies = false
manage_interface_policies = false
manage_tenants = true
}

Save the updated main.tf file.

Now that these values have been replaced by variables you have to provide their values in GitLab. Open your project in GitLab, and navigate to Settings > CI/CD, and expand Variables:

Click Add variable to add the variables used for authentication against APIC:

Complete this step for ACI_USERNAME, ACI_PASSWORD, and ACI_URL. Uncheck Protect variable for each variable.

Once your variables have been added, continue with the next step.

Step 6: Pushing the code

The pipeline definition has been added to the new file gitlab-ci.yml and main.tf has been modified to make use of a remote backend in step 4, and any sensitive data is replaced with GitLab variables in step 5. The next step is to add the new file to staging, and commit the changes.

Before committing changes, the Git username and Email address must be provided. This is how changes can be tracked to an individual user.

git config --global user.name "First Last"
git config --global user.email "first@example.com"

When using Visual Studio Code select the Source Control tab on the left hand side. Stage the changes by clicking on the + button next to Changes. After adding a commit Message, changes can be committed to the local copy of the repository:

Followed by a push operation:

Alternatively this can be done from the command-line interface. Navigate to the cloned folder and run:

git add .
git commit -m "adding pipeline"
git push

The GitLab repository now contains your code and will trigger the pipeline as described in .gitlab-ci.yml.

Step 7: Deploying the configuration

This pipeline assumes a Continuous Delivery step whereby the code is checked automatically, but requires human intervention to manually trigger the deployment of the changes. Open the project in GitLab and navigate to Build > Pipelines. The pipeline triggered by the push in step 6 should be visible here.

If previous steps were executed correctly, the pipeline will have completed three steps successfully and will be in a blocked state, meaning human intervention is required to proceed.

Before clicking deploy it is recommended to navigate to the pipeline and verify the output of the individual jobs by clicking on each completed stage. The output of the build stage will provide an overview of the planned actions.

If any of these steps have failed, the detailed stage view should also provide you the output of any jobs within that stage, providing details to help further troubleshoot.

The build job generated a plan to add 23 resources as shown by the output:

When satisfied with the output of the build job, you can manually trigger the deploy job by clicking play. This will trigger a Terraform apply action and push the configuration to APIC. If you set up the variables in step 5 correctly, this step should successfully complete:

Navigate to APIC to verify that the configuration was deployed successfully:

Optionally, if you wish to automatically deploy your configuration in a truly Continuous Deployment (CD) fashion, you can modify the deploy block in the .gitlab-ci.yml file to always run:

deploy:
stage: deploy
script:
- terraform apply -input=false plan.tfplan
dependencies:
- build
rules:
when: always

The cleanup stage is skipped as this would trigger a Terraform destroy, which would remove the deployed configuration.

Step 8: Adding configuration

Now that the initial configuration is pushed to the repository and the terraform state is available centrally in GitLab, you or someone else in your team can commit new configuration, which would trigger a new pipeline when pushed the repository.

Open data/tenant_DEV.nac.yaml in your preferred editor / IDE and add the following section:

- name: 10.1.203.0_24
vrf: DEV.DEV-VRF
subnets:
- ip: 10.1.203.1/24

The tenant_DEV.nac.yaml file should now look like this:

---
apic:
tenants:
- name: DEV
vrfs:
- name: DEV.DEV-VRF
bridge_domains:
- name: 10.1.200.0_24
vrf: DEV.DEV-VRF
subnets:
- ip: 10.1.200.1/24
- name: 10.1.201.0_24
vrf: DEV.DEV-VRF
subnets:
- ip: 10.1.201.1/24
- name: 10.1.202.0_24
vrf: DEV.DEV-VRF
subnets:
- ip: 10.1.202.1/24
- name: 10.1.203.0_24
vrf: DEV.DEV-VRF
subnets:
- ip: 10.1.203.1/24
application_profiles:
- name: VLANS
endpoint_groups:
- name: VLAN200
bridge_domain: 10.1.200.0_24
- name: VLAN201
bridge_domain: 10.1.201.0_24
- name: VLAN202
bridge_domain: 10.1.202.0_24

Save the file, commit and push your changes. Either via Visual Studio Code or locally via command-line interface:

git add data/tenant_DEV.nac.yaml
git commit -m "adding new bd"
git push

This triggers a new iteration of the pipeline that will be in blocked state, waiting for human intervention.

Open the pipeline and expand the build job. Note that Terraform calculated that 3 new resources are to be added.

Once satisfied with the proposed changes, deployment can be triggered by clicking the deploy stage play button.

Navigate to APIC to verify that the new Bridge Domain has been added:

Step 9: Restoring a previous commit

Imagine that the Bridge Domain added in step 8 resulted in an outage and you must restore the configuration to an earlier time. Because this change is tracked individually it is easy to revert to to an earlier commit.

Get a list of previous commits with git log in a terminal. Note that Visual Studio Code has a built-in terminal which can be added to the workspace by navigating to Terminal and New Terminal.

git log --pretty=format:"%h%x09%an%x09%ad%x09%s"
a565a7a Rob van der Kind Mon Aug 25 13:00:43 2025 +0200 adding new bd
1dfcfe5 Rob van der Kind Mon Aug 25 12:48:15 2025 +0200 adding pipeline
~output omitted~

Note that the output of the git log command also shows any previous commits made against the cloned repository, followed by the two commits made in this example.

By reverting the last commit it is possible to undo any changes associated with that commit.

git revert <your last commit id such as a565a7a>

Save the commit message from the terminal (!wq) and push the code:

git push

This is just one way to revert your change. Although this is the preferred way as now you have the revert action added on top of your commit history. This allows you to track changes or even undo the revert action itself.

Navigate to your project in Gitlab and open the most recent pipeline:

Expand the build job and verify that this Terraform plan will destroy 3 resources:

Once satisfied with your changes you can trigger deployment by clicking the deploy stage play button.

Navigate to APIC to verify that the previously added Bridge Domain has been removed.

Step 10: Cleaning up

Congratulations. You can now work on your code with multiple team members, start expanding the configuration or even include additional modules such as those shown in the Comprehensive Example section. This CI/CD guide used the code provided in the Simple Example section and is meant to get the reader familiar with some basic automation principles and how to set up a solid foundation for CI/CD.

As a final step you can clean up the configuration by manually triggering the cleanup job. Navigate to CI/CD > Pipelines in your project and click on the cleanup play button:

Navigate to APIC to verify that tenant DEV has been removed.