Sca­ling Infra­struc­ture Deploy­ments with Ter­ra­form – From Manual Deploy­ments to a Self-Ser­vice Platform



Inastruc­ture manage­ment has chan­ged signi­fi­cantly over the past decade. What was once mana­ged manu­ally through cloud con­so­les, scripts, and web UIs is incre­asingly beco­ming part of the soft­ware deve­lo­p­ment life­cy­cle. Infra­struc­ture as Code (IaC) is at the heart of this trans­for­ma­tion. Ins­tead of con­fi­gu­ring infra­struc­ture manu­ally, infra­struc­ture is defi­ned in reusable tem­pla­tes, which are deve­lo­ped, ver­sio­ned and applied by the plat­form team.

One of the most widely adopted tools for this approach is Ter­ra­form. This article pro­vi­des an intro­duc­tion to the basics of Ter­ra­form and explo­res how orga­niza­ti­ons can move from manual infra­struc­ture pro­vi­sio­ning to shared cus­tom modu­les and ulti­m­ately to a self-ser­vice approach, enab­ling non-plat­form engi­neers while still gua­ran­te­e­ing com­pli­ance with orga­niza­tio­nal policies.

Why Infra­struc­ture as Code?

Before loo­king at Ter­ra­form its­elf, it is worth under­stan­ding the pro­blem Infra­struc­ture as Code is try­ing to solve:

Pre­dic­ta­ble and repro­du­ci­ble deployments

Tra­di­tio­nally, infra­struc­ture was often crea­ted manu­ally. An admi­nis­tra­tor would log into a cloud con­sole or web UI, create a vir­tual net­work, pro­vi­sion a data­base, con­fi­gure access rules, and repeat the pro­cess for every envi­ron­ment. This approach may work for a small orga­niza­tion or a PoC. As infra­struc­ture grows, howe­ver, manual resource manage­ment beco­mes incre­asingly dif­fi­cult and error-prone:

Dif­fe­rent envi­ron­ments may have slightly dif­fe­rent con­fi­gu­ra­ti­ons. Resour­ces can be for­got­ten or con­fi­gu­red incor­rectly. Chan­ges may not be docu­men­ted pro­perly, and repro­du­cing an envi­ron­ment can become a time-con­sum­ing task.

With IaC, the afo­re­men­tio­ned admi­nis­tra­tor needs to define what the vir­tual net­work, data­base, and access rules should look like in a para­me­ter­i­zed tem­p­late and apply the cor­rect para­me­ter set during deploy­ment. This approach makes infra­struc­ture deploy­ment more pre­dic­ta­ble and repro­du­ci­ble, while chan­ges can be tra­cked through ver­sion control.

With IaC the before men­tio­ned admi­nis­tra­tor needs to define how the vir­tual net­work, data­base and access rules should look like in a para­me­tri­zed tem­p­late and apply the cor­rect para­me­ter set during the deploy­ment. This approach makes infra­struc­ture deploy­ment more pre­dic­ta­ble and repro­du­ci­ble, while chan­ges can be tra­cked through ver­sion control.

Stan­dar­diza­tion, secu­rity and policy compliance

App­ly­ing IaC and estab­li­shing com­pany-wide tem­pla­tes and modu­les allows orga­niza­ti­ons to estab­lish com­mon infra­struc­ture pat­terns. Naming con­ven­ti­ons, tag­ging, net­wor­king con­fi­gu­ra­ti­ons, as well as secu­rity and policy requi­re­ments can be met con­sis­t­ently by encap­su­la­ting them in cus­tom modu­les (see “Ter­ra­form Modu­les as Buil­ding Blocks”).

Ter­ra­form Basics and Core Concepts

Decla­ra­tive configuration

Ter­ra­form is an Infra­struc­ture as Code tool by Hash­i­Corp that allows infra­struc­ture to be defi­ned and mana­ged through decla­ra­tive con­fi­gu­ra­tion. This means that ins­tead of scrip­ting every indi­vi­dual step requi­red to create the infra­struc­ture resour­ces in sequence, you describe the desi­red end state. Ter­ra­form then deter­mi­nes how to reach that state: it crea­tes a direc­ted acy­clic graph (DAG) that defi­nes the order of deploy­ment and resol­ves depen­den­cies bet­ween the defi­ned resources.

Ter­ra­form state

To deter­mine the deploy­ment steps nee­ded to bring the cloud envi­ron­ment into the desi­red state defi­ned in the tem­p­late, Ter­ra­form does not only compare the cur­rent state of the remote resour­ces with their coun­ter­parts in the tem­p­late. It also relies on the Ter­ra­form state file. This file con­ta­ins a 1:1 map­ping of the resour­ces defi­ned in the tem­p­late to their real coun­ter­parts, as well as addi­tio­nal meta­data, such as depen­den­cies as of the last deploy­ment. This is espe­ci­ally important when pre­viously defi­ned resour­ces are decommissioned.

By default, the state is stored in a file in the local working direc­tory. Howe­ver, it is recom­men­ded to store the state in a remote loca­tion. With a remote state setup, Ter­ra­form can use state locking, which pre­vents mul­ti­ple users from app­ly­ing chan­ges to the same pro­ject at the same time.

Pro­vi­ders

Unlike some other IaC tools, Ter­ra­form does not rely on one spe­ci­fic cloud pro­vi­der or ser­vice. It uses pro­vi­ders to inter­act with dif­fe­rent cloud ser­vices, such as AWS, Azure, or Google Cloud, but is not limi­ted to those. While this article focu­ses on using Ter­ra­form with Azure, Ter­ra­form also offers pro­vi­ders for Kuber­netes, VMware vSphere, and various other ser­vices and plat­forms, some of which can also run on-premises. In essence, a Ter­ra­form pro­vi­der ser­ves as a trans­la­tor bet­ween Ter­ra­form code and the API calls of the under­ly­ing platform.

Modu­les

Modu­les allow Ter­ra­form resour­ces and con­fi­gu­ra­ti­ons to be packa­ged into reusable buil­ding blocks. For exam­ple, a module can con­tain an Azure SQL Ser­ver resource, as well as an encryp­tion key resource and a private end­point resource. Addi­tio­nally, while cer­tain pro­per­ties of the SQL Ser­ver might still be expo­sed as input para­me­ters for the module (more on that later), values of other pro­per­ties, such as public access, can be enforced by hard­co­ding them in the module. This way, modu­les can help stan­dar­dize infra­struc­ture set­ups across mul­ti­ple appli­ca­ti­ons and enforce secu­rity and com­pany policies.

Ter­ra­form Modu­les as Buil­ding Blocks

While Ter­ra­form tech­ni­cally does not rely on a given fol­der struc­ture or naming con­ven­tion, a typi­cal setup of a Ter­ra­form module will con­tain the fol­lo­wing files:

  • provider.tf
  • versions.tf
  • input.tf
  • output.tf
  • main.tf
  • README.md

In the fol­lo­wing sub­sec­tions, we will define a module for the pre­viously dis­cus­sed exam­ple of the plat­form admi­nis­tra­tor tas­ked with deploy­ing a SQL Ser­ver. The fol­lo­wing Azure resour­ces will need to be deployed by the module:

  • MS SQL Server
  • Private end­point to MS SQL Server
  • Cus­to­mer-Mana­ged Encryp­tion Key (CMK)
  • Role assign­ment for the mana­ged iden­tity of the SQL Ser­ver in the Key Vault hol­ding the CMK

provider.tf

The provider.tf file defi­nes the pro­vi­ders the module needs to func­tion. Addi­tio­nally, the fea­tures block enables deve­lo­pers to define cer­tain resource attri­bu­tes at the pro­vi­der level.

provider "azurerm" {
  features {}
}

versions.tf

The versions.tf file defi­nes the requi­red pro­vi­der ver­si­ons and pre­vents ver­sion drift bet­ween the pro­vi­der used during module deve­lo­p­ment and the one used in production.

terraform {
  required_version = ">= 1.16"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=> 5.9"
    }
  }
}

input.tf

Defi­nes the varia­bles used in the module. The spe­ci­fic values are pas­sed in a module call in a Ter­ra­form pro­ject and are used to para­me­trize the resour­ces that are defi­ned in the main.tf file.

variable "sqlServerName" {
    description = "Name of the Azure SQL Server to be deployed."
    type = string
}

# Terraform variables can also contain a validation block in the definition. The value provided during 
# module call will be validated by the provided condition. If it evaluates to ´true´, the module call 
# will continue. If the condition evaluates to ´false´ the terraform plan/apply will fail with the 
#custom error message.
variable "environment" {
    description = "Environment for deployment. Possible values are: dev, uat, sit, prd."
    type = string
    validation {
        condition     = contains(["dev", "uat", "sit", "prd"],lower(var.environment))
        error_message = "Value for variable ´environment´ is not available. Possible values are: dev, uat, sit, prd."
  }
}

# Terraform variables can have a defined default value. If a default value is defined and no value for the 
# respective variable is passed during module call, the module call will continue with the provided 
# default value.
variable "sqlServerVersion" {
    description = "Version of the SQL Server. Possible values are: 2.0 (for v11 Server) and 12.0 (for v12 Server). Default is set to '12.0'."
    type = string
    default ="12.0"
}

variable "azureAdAdminName" {
    type = string
    description = "Azure AD Entity name set as Administrator to enable AD authentication only on SQL Server."
}

variable "azureAdAdminId" {
    type = string
    description = "Azure AD Entity object ID set as Administrator to enable AD authentication only on SQL Server."
}

variable "privateEndpointSubnetId" {
    description = "VNet Subnet ID for private endpoint deployment."
    type = string
}

variable "privateLinkServiceId" {
    description = "Private Link Service ID for private endpoint deployment."
    type = string
}

variable "encryptionKeyKvId" {
    description = "Azure Keyvault object to store SQL Server encryption key."
    type = string
}

# Module variables can also be defined as an object with predefined attributes. In this example a 
# ´azurerm_resource_group´ object will be passed during the module call and its attributes ´name´ 
# and ´location´ will be used during module execution. 
# NOTE: It is not needed to add every attribute of the intended input object (´azurerm_resource_group´ 
#       in this example) to the variable. It is sufficient to define just those attributes that are 
#       referenced in the module.
variable "resourceGroup" {
    description = "Resoure Group object where the SQL Server will be deployed."
    type = object({
        name = string
        location = string
    })
}

main.tf

Con­ta­ins the resour­ces pro­vi­sio­ned by the module. As can be seen in this exam­ple, defi­ning a cus­tom module for the MS SQL Ser­ver achie­ves mul­ti­ple goals:

  • Encap­su­la­tion: The com­ple­xity of the infra­struc­ture setup is encap­su­la­ted. The setup only needs to be defi­ned once and does not have to be reinven­ted by other teams or projects.
  • Stan­dar­diza­tion: The SQL Ser­ver setup of Team A will look simi­lar to the setup of Team B.
  • Secu­rity and com­pany poli­cies: Secu­rity poli­cies, such as rest­ric­ting public access and allo­wing only Micro­soft Entra ID authen­ti­ca­tion (as in the exam­ple), can be enforced within the module.
resource "azurerm_mssql_server" "msSqlSrv" {
  name                          = "${var.sqlServerName}-${var.environment}"
  resource_group_name           = var.resourceGroup.name
  location                      = var.resourceGroup.location
  version                       = var.sqlServerVersion
  minimum_tls_version           = "1.2"
  public_network_access_enabled = false

  identity {
    type         = "SystemAssigned"
  }

  azuread_administrator {
    azuread_authentication_only = true
    login_username              = var.azureAdAdminName
    object_id                   = var.azureAdAdminId
  }

  transparent_data_encryption_key_vault_key_id = azurerm_key_vault_key.cmk.id
}

resource "azurerm_role_assignment" "kvCryptoUserSqlSrv" {
  scope                = var.encryptionKeyKvId
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = azurerm_mssql_server.msSqlSrv.identity[0].principal_id
}

resource "azurerm_key_vault_key" "cmk" {

  name         = "${var.sqlServerName}-key"
  key_vault_id = var.encryptionKeyKvId
  key_type     = "RSA"
  key_size     = 2048

  key_opts = ["unwrapKey", "wrapKey"]
}

resource "azurerm_private_endpoint" "privateEndpointSqlSrv" {
  name                = "pe-${var.sqlServerName}-${var.environment}-sql"
  resource_group_name = var.resourceGroup.name
  location            = var.resourceGroup.location
  subnet_id           = var.privateEndpointSubnetId

  private_service_connection {
    name                           = "pe-${var.sqlServerName}-${var.environment}-sql-privateserviceconnection"
    private_connection_resource_id = var.privateLinkServiceId
    is_manual_connection           = false
  }
}

output.tf

Defi­nes which resour­ces and/or respec­tive pro­per­ties are acces­si­ble in the Ter­ra­form pro­ject where the module is called.

output "msSqlSrv" {
    description = "MS SQL Server Object created in module."
    value = azurerm_mssql_server.msSqlSrv
}

In the pre­vious exam­ple, the plat­form admi­nis­tra­tor went from con­fi­gu­ring the VNet, SQL Ser­ver, role assign­ments, and private end­points manu­ally in the web UI to adding them to the Ter­ra­form tem­p­late for the tar­get envi­ron­ment. Now, once the module is defi­ned and ready to use, the plat­form admi­nis­tra­tor only needs to add one module call to the Ter­ra­form tem­p­late for any future SQL Ser­ver deployment:

Con­side­ra­ti­ons for Desig­ning Shared Modules

  • A good module should come with clear, struc­tu­red docu­men­ta­tion. The input varia­bles (i.e., the inter­face) as well as the module its­elf should be docu­men­ted in a way that no han­do­ver from deve­lo­per to con­su­mer is necessary.
  • The module should be desi­gned to be as fle­xi­ble as pos­si­ble. A gene­ral rule: if a pro­perty of the main resource is not rest­ric­ted by secu­rity or com­pany poli­cies, it should be expo­sed as a module varia­ble. This approach pro­vi­des fle­xi­bi­lity for module usage and har­dens the inter­face against brea­king changes.
  • Avoid fea­ture bloat. While fle­xi­bi­lity across dif­fe­rent use cases was men­tio­ned above as a design goal, a module can only be main­tai­ned if its scope is cle­arly defined.
  • Ver­sio­ning is important. Once a module is used by mul­ti­ple teams, chan­ges to the module can poten­ti­ally affect many appli­ca­ti­ons. A Ter­ra­form module should not sim­ply be a coll­ec­tion of Ter­ra­form code. It should be trea­ted as a reusable infra­struc­ture buil­ding block with a defi­ned inter­face, pur­pose and lifecycle.

Ter­ra­form Modu­les as a Self-Ser­vice Capability

Swit­ching from manual infra­struc­ture deploy­ments through the cloud provider’s web UI to using IaC is a major step towards stan­dar­diza­tion, not only bet­ween dif­fe­rent envi­ron­ments but also bet­ween dif­fe­rent teams and appli­ca­ti­ons. Using modu­les as reusable buil­ding blocks can also solve an orga­niza­tio­nal pro­blem: how can appli­ca­tion teams request infra­struc­ture wit­hout requi­ring the plat­form team to pro­vi­sion every reques­ted change?

A pos­si­ble solu­tion is enab­ling the appli­ca­tion teams to pro­vi­sion cer­tain infra­struc­ture parts them­sel­ves by pro­vi­ding a com­pre­hen­sive cata­log of Ter­ra­form modu­les. A module does not auto­ma­ti­cally have to become a self-ser­vice inter­face. An orga­niza­tion can con­ti­nue to use cer­tain modu­les only cen­trally within the plat­form team. More on that in the fol­lo­wing section.

Let us revi­sit the exam­ple of the plat­form admi­nis­tra­tor who was asked to pro­vi­sion a SQL Ser­ver in an Azure subscription:

We went from pro­vi­sio­ning and con­fi­gu­ring all requi­red resour­ces in the web UI to defi­ning them in a Ter­ra­form tem­p­late, and finally to defi­ning a stan­dar­di­zed set of ser­ver con­fi­gu­ra­ti­ons, role assign­ments, and net­wor­king setup, encap­su­la­ted in a cus­tom module that is cal­led in the tem­p­late. The next step is to involve the reques­t­ing team directly: ins­tead of crea­ting a ticket for the plat­form team, the appli­ca­tion team can create a pull request in the repo­si­tory gover­ning their sub­scrip­tion and include the module call with the desi­red spe­ci­fi­ca­ti­ons. The plat­form admi­nis­tra­tor can then review the pull request and approve it for deployment.

This approach redu­ces ope­ra­tio­nal over­head for the plat­form team and alle­via­tes poten­tial bot­t­len­ecks for the appli­ca­tion team. The plat­form team does not have to manu­ally exe­cute every infra­struc­ture request, while the appli­ca­tion team does not need to become an expert in the under­ly­ing infra­struc­ture implementation.

Gover­nance and Guardrails

While the self-ser­vice approach does reduce the ope­ra­tio­nal over­head for the plat­form team over time, some points need to be con­side­red in advance:

Defi­ning clear boun­da­ries for what can be gover­ned by self-ser­vice and what shouldn’t

Set­ting up a SQL Ser­ver, a sto­rage account, or a new con­tai­ner in an Azure sub­scrip­tion, as well as mana­ging role assign­ments on these resour­ces, are tasks that can be hand­led via self-ser­vice with a low risk of caus­ing major issues. On the other hand, set­ting up a new sub­scrip­tion, set­ting a bud­get, inte­gra­ting a new VNet into the exis­ting net­work infra­struc­ture, or mana­ging a Micro­soft Entra ten­ant are tasks for which a self-ser­vice approach is not advi­sa­ble. Such requests should still be imple­men­ted by the plat­form team or the respec­tive net­wor­king and secu­rity specialists.

Set­ting up guar­drails for appli­ca­tion teams

Before gran­ting access to the sub­scrip­tion repo­si­to­ries and shared modu­les, a clear pro­cess, guar­drails, and sup­port work­flows need to be defi­ned. The fol­lo­wing ques­ti­ons should have clear ans­wers before the self-ser­vice pro­ject starts:

  • What rules and con­ven­ti­ons are employed in the sub­scrip­tion repo­si­to­ries? How are they enforced?
  • How do you ensure that no chan­ges are made to resour­ces that are out of bounds for the self-ser­vice approach?
  • How do you pre­vent simul­ta­neous deploy­ments in the same sub­scrip­tion by mul­ti­ple users?
  • Is there a clear pro­cess for appli­ca­tion teams on how to con­tri­bute to the sub­scrip­tion repositories?
  • Are there ways to cir­cum­vent the out­lined pro­cess? If so, how can they be prevented?
  • Is com­pre­hen­sive docu­men­ta­tion in place so that appli­ca­tion teams can get star­ted and under­stand the pro­cess on their own?
Module Repo­si­to­ries and Versioning

A cen­tral module repo­si­tory can pro­vide a con­sis­tent place for teams to dis­co­ver and con­sume appro­ved shared modu­les. For a smal­ler num­ber of shared modu­les, this is a good approach. Each module can be con­tai­ned in its own direc­tory and can be refe­ren­ced accor­din­gly in the module call in the tem­p­late. With a rising num­ber of shared modu­les and col­la­bo­ra­tors, a switch to a dedi­ca­ted repo­si­tory per module is preferred.

The modu­les should fol­low a cle­arly defi­ned life­cy­cle and be ver­sio­ned accor­din­gly. This ties directly to how shared modu­les are used: when a module is cal­led in a Ter­ra­form tem­p­late, a source for the module defi­ni­tion must be pro­vi­ded. This can be a direc­tory, a repo­si­tory branch, or a repo­si­tory along with a tag. The lat­ter option should always be chosen.

Con­clu­sion

In this article, we have seen how mana­ging infra­struc­ture in a cloud envi­ron­ment has chan­ged with the incre­asing adop­tion of Infra­struc­ture as Code (with a focus on Ter­ra­form) and what pos­si­bi­li­ties it offers in terms of stan­dar­diza­tion and sca­la­bi­lity. It is also important to note that the out­lined pro­gres­sion from manual deploy­ments in the web UI to a mana­ged self-ser­vice plat­form with stan­dar­di­zed modu­les for every use case is only one way to achieve sca­la­bi­lity in infra­struc­ture manage­ment, not a solu­tion that fits every com­pany. For smal­ler com­pa­nies with only a few appli­ca­tion teams, a small plat­form team main­tai­ning the sub­scrip­tion and module repo­si­to­ries its­elf may be suf­fi­ci­ent, while lar­ger orga­niza­ti­ons will need to con­sider scalability.