Create an Azure DevOps Self-Hosted Agent Pool using Terraform
When studying Azure DevOps, I found that using IaC for building and tearing down resources is useful. This post will show you how to create an Azure DevOps self-hosted agent pool using Terraform.

Introduction
Infrastructure as Code (IaC) is a great way to manage resources in the cloud. It allows you to create, update, and delete resources in a programmatic way. While studying for my AZ-400 certification, I found that building Azure resources manually each time running exercises was time-consuming. I decided to use Terraform to automate the process.
In this post, I'll share what I learned, creating the necessary Azure resources to run a self-hosted agent pool using Terraform. This will allow you to run your CI/CD pipelines on your own infrastructure.
Prerequisites
Before you start, you need to have the following:
- Azure subscription
- Azure DevOps account
- Azure DevOps organization
- Azure DevOps project
- Azure DevOps PAT (Personal Access Token)
- Azure DevOps agent pool
- Terraform installed on your machine, in a docker container or on a VM
Create a Service Principal
To create resources in Azure, you need to create a service principal. Terraform needs the following rights:
- Contributor role on the subscription
Azure CLI command to create a service principal:
az ad sp create-for-rbac --name tfporvider --role="Contributor" --scopes="/subscriptions/<subscription_id>" --sdk-auth
This script will output a JSON object with the service principal credentials. Save this JSON object safely.
{
"clientId": "",
"clientSecret": "",
"subscriptionId": "",
"tenantId": "",
"activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
"resourceManagerEndpointUrl": "https://management.azure.com/",
"activeDirectoryGraphResourceId": "https://graph.windows.net/",
"sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
"galleryEndpointUrl": "https://gallery.azure.com/",
"managementEndpointUrl": "https://management.core.windows.net/"
}
Some of the values, you need for your own terraform.tfvars later on.
Create a Terraform Configuration
There are many Azure resources involved in automating the creation of a VM to run a self-hosted agent. Here is a list of the resources:
- Resource Group
- Virtual Network
- Subnet
- Network Security Group
- Public IP Address
- Network Interface
- Virtual Machine
- Azure DevOps Agent
I learned that putting everything in one file like main.tf is not a good practice. It's better to split the configuration into multiple files. Here is an example of how to structure the files:
.
├── variables.tf
├── main.tf
├── net.tf
├── vm.tf
├── blob.tf
├── shagent.tf
variables.tf
It is a good practice to put all the variables in a separate file. This makes it easier to manage and update the variables. Here are my variables:
variable "rg_name" {
type = string
default = "app-grp"
description = "Resource Group name."
}
variable "location" {
type = string
default = "norwayeast"
description = "Location of all resources deployed."
}
variable "subscription_id" {
description = "Azure Subscription ID"
type = string
}
variable "client_id" {
description = "Azure Client ID"
type = string
}
variable "client_secret" {
description = "Azure Client Secret"
type = string
sensitive = true
}
variable "tenant_id" {
description = "Azure Tenant ID"
type = string
}
variable "vnet_name" {
type = string
default = "vnet"
description = "The Virtual Network name."
}
variable "vnet_range" {
type = string
default = "10.0.0.0/24"
description = "The Virtual Network range."
}
variable "subnet_name" {
type = string
default = "subnet-vm"
description = "The Virtual Network Subnet for Virtual Machine name."
}
variable "subnet_range" {
type = string
default = "10.0.0.0/26"
description = "The Virtual Network Subnet for Virtual Machine range."
}
variable "vm_nic_name" {
type = string
default = "vm-nic"
description = "The Virtual Machine Network interface name."
}
variable "vm_name" {
type = string
default = "app-vm"
description = "Virtual Machine name."
}
variable "vm_size" {
type = string
default = "Standard_B2s"
description = "Virtual Machine size."
}
variable "admin_username" {
type = string
description = "Admin username for the Virtual Machine."
sensitive = true
}
variable "admin_password" {
type = string
description = "Admin password for the Virtual Machine."
sensitive = true
}
variable "agent-name" {
type = string
default = "self-hosted-agent"
description = "The name of the agent."
}
variable "url" {
type = string
description = "The URL of the Azure DevOps."
}
variable "pat" {
type = string
description = "The Personal Access Token of the Azure DevOps."
}
variable "pool" {
type = string
description = "The Pool name of the Azure DevOps."
}
variable "agent_version" {
description = "Agent version (default: latest)"
type = string
default = ""
}
main.tf
This file is the entry point for Terraform. It contains the provider and module blocks. I also put in the azure Resource Group in this file. The highlighted lines are the parameters for the service principal.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.8.0"
}
}
}
provider "azurerm" {
subscription_id = var.subscription_id
client_id = var.client_id
client_secret = var.client_secret
tenant_id = var.tenant_id
features {}
}
resource "azurerm_resource_group" "rg" {
name = var.rg_name
location = var.location
}
net.tf
Security warning: You are exposing port 3389 to the Internet. It is OK for a lab, but be aware on this for production environments
Before we can create a VM, we need to make sure it will be accessible. This code creates a Virtual Network, Subnet, and Network Security Group, allowing RDP traffic.
resource "azurerm_network_security_group" "app_network_nsg" {
name = "${var.subnet_name}-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "allowRDP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "3389"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "azurerm_virtual_network" "app_network" {
name = var.vnet_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
address_space = [var.vnet_range]
}
resource "azurerm_subnet" "SubnetA" {
name = var.subnet_name
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.app_network.name
address_prefixes = [var.subnet_range]
}
resource "azurerm_subnet_network_security_group_association" "SubnetA_nsg_link" {
subnet_id = azurerm_subnet.SubnetA.id
network_security_group_id = azurerm_network_security_group.app_network_nsg.id
}
vm.tf
This file builds the Azure VM for us. It creates a Public IP Address, Network Interface, and the Virtual Machine running Windows Server 2022. The size "Standard_B2s" is a small VM size, but it's enough for a self-hosted agent.
resource "azurerm_network_interface" "vm_nic" {
name = var.vm_nic_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.SubnetA.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.app_public_ip.id
}
}
resource "azurerm_windows_virtual_machine" "vm" {
name = var.vm_name
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
size = "Standard_B2s"
admin_username = var.admin_username
admin_password = var.admin_password
network_interface_ids = [azurerm_network_interface.vm_nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "MicrosoftWindowsServer"
offer = "WindowsServer"
sku = "2022-Datacenter"
version = "latest"
}
identity {
type = "SystemAssigned"
}
}
resource "azurerm_public_ip" "app_public_ip" {
name = "${var.vm_name}-pip"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
allocation_method = "Static"
sku = "Standard"
}
blob.tf
We need a place to store the script used to install the Azure DevOps agent. This code creates a Storage Account, storage container and a storage blob. The highlighted line is defining the source of the script we need to store online.
resource "azurerm_storage_account" "eshoponweb" {
name = "eshoponweb987987"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_storage_container" "data" {
name = "data"
storage_account_name = azurerm_storage_account.eshoponweb.name
container_access_type = "blob"
}
resource "azurerm_storage_blob" "windows-agent-install" {
name = "windows-agent-install.ps1"
storage_account_name = azurerm_storage_account.eshoponweb.name
storage_container_name = azurerm_storage_container.data.name
type = "Block"
source = "./windows-agent-install.ps1"
}
shagent.tf
This file creates the Azure DevOps agent. It uses the azurerm_virtual_machine_extension resource to run a script on the VM. The script installs the Azure DevOps agent. The highlighted lines are the block downloading the script for installing the agent and running the script.
# Powershell Script to Install Azure DevOps Agent
data "local_file" "windows_agent_script" {
filename = "windows-agent-install.ps1"
}
resource "azurerm_virtual_machine_extension" "install_devops_agent" {
name = "install-devops-agent"
virtual_machine_id = azurerm_windows_virtual_machine.vm.id
publisher = "Microsoft.Compute"
type = "CustomScriptExtension"
type_handler_version = "1.10"
depends_on = [azurerm_storage_blob.windows-agent-install]
# Pass the necessary parameters to the Powershell script
settings = <<SETTINGS
{
"fileUris": ["https://${azurerm_storage_account.eshoponweb.name}.blob.core.windows.net/data/windows-agent-install.ps1"],
"commandToExecute": "powershell.exe -ExecutionPolicy Unrestricted -File ./windows-agent-install.ps1 -URL ${var.url} -PAT ${var.pat} -POOL ${var.pool} -AGENT ${var.agent-name}",
"timestamp" : "12"
}
SETTINGS
}
Powershell Script: windows-agent-install.ps1
This script installs the Azure DevOps agent on the VM. It takes the URL, PAT, Pool, and Agent name as parameters from the settings block highlighted in the shagent.tf file. I found it here: https://melcher.dev/2019/03/self-hosted-azure-devops-build/release-agent-with-terraform-windows-edition/
param (
[string]$URL,
[string]$PAT,
[string]$POOL,
[string]$AGENT
)
Start-Transcript
Write-Host "start"
#test if an old installation exists, if so, delete the folder
if (test-path "c:\agent")
{
Remove-Item -Path "c:\agent" -Force -Confirm:$false -Recurse
}
#create a new folder
new-item -ItemType Directory -Force -Path "c:\agent"
set-location "c:\agent"
$env:VSTS_AGENT_HTTPTRACE = $true
#github requires tls 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
#get the latest build agent version
$wr = Invoke-WebRequest https://api.github.com/repos/Microsoft/azure-pipelines-agent/releases/latest -UseBasicParsing
$tag = ($wr | ConvertFrom-Json)[0].tag_name
$tag = $tag.Substring(1)
write-host "$tag is the latest version"
#build the url
$download = "https://vstsagentpackage.azureedge.net/agent/$tag/vsts-agent-win-x64-$tag.zip"
#download the agent
Invoke-WebRequest $download -Out agent.zip
#expand the zip
Expand-Archive -Path agent.zip -DestinationPath $PWD
#run the config script of the build agent
.\config.cmd --unattended --url "$URL" --auth pat --token "$PAT" --pool "$POOL" --agent "$AGENT" --acceptTeeEula --runAsService
#exit
Stop-Transcript
exit 0
terraform.tfvars
Security warning: Keep sensitive information out of your repo.
This file contains the values for the variables. Keep this out of your repo for security and privacy reasons. .gitignore this file.
Fill in the values from the JSON object you saved earlier, as well as the Azure DevOps URL, PAT, and Pool name. You can set other variables as well, according to your needs.
subscription_id = ""
client_id = ""
client_secret = ""
tenant_id = ""
rg_name = "rg-eshoponweb-agentpool"
vm_name = "eshoponweb-vm"
admin_username = "sysadmin"
url = "https://dev.azure.com/{YOUR_ORGANIZATION}/"
pat = ""
pool = "eShopOnWebSelfPool"
Running Terraform
After you have created the files, you can run Terraform. Here are the steps:
- Initialize Terraform
terraform init
- Plan the changes
terraform plan --out=main.tfplan
-
Review the plan
-
Apply the changes
terraform apply "main.tfplan"
Verify
After the Terraform run is complete, you can verify the resources in the Azure portal. You should see the Resource Group, Virtual Machine, and the Azure DevOps agent in the Azure DevOps portal.

Tear down the resources
When you are done with the resources, you can tear them down using Terraform.
terraform destroy
Repository
You are free to use the code in your own projects. You can find the code in my GitHub repository: https://github.com/Jihillestad/terraform-selfhostedagentpool
Conclusion
It was a lot of work to create the Terraform configuration, but it was worth it. I learned a lot about Azure resources and how to automate the creation of resources. I hope this post helps you to move on with your IaC and DevOps journey.
In this experience, I only scratched the surface of what you can do with Terraform. There are many more resources and possibilities to explore. The next steps for me are to learn creating a CI/CD pipeline in Azure DevOps or GitHub Actions to deploy the resources automatically. I also need to work on setting Terraform variables securely in my repos, as well as finding a more secure way to store TFSTATE files.
For me, it was a great learning experience. I hope you enjoyed it too.