Sca­ling infra­struc­ture deploy­ments with Ter­ra­form – From manual deploy­ments to self-ser­vice platform



Infra­struc­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 desi­red infra­struc­ture is descri­bed in tem­pla­tes rather than being crea­ted manu­ally. Deploy­ing the same infra­struc­ture in dif­fe­rent envi­ron­ments thus beco­mes a mat­ter of app­ly­ing the cor­rect set of para­me­ters to the tem­pla­tes during the deploy­ment process.

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 tem­pla­tes and modu­les that are used com­pany wide, 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 chap­ter 3).

Ter­ra­form basics and core Concepts

Decla­ra­tive configuration:

Ter­ra­form is an open source  Infra­struc­ture as Code tool pro­vi­ded 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: Ter­ra­form crea­tes a DAG (Direc­ted Acy­clic Graph), defi­ning the order of deploy­ment and thus resol­ving found depen­den­cies bet­ween the defi­ned resources.

Ter­ra­form state:

While Ter­ra­form deter­mi­nes the nee­ded deploy­ment steps in order to con­fi­gure the pro­vi­ded cloud envi­ron­ment accor­ding to the desi­red state defi­ned in the tem­p­late, it 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 state file con­ta­ins a 1:1 map­ping of the defi­ned resour­ces 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 in case of decom­mis­sion of pre­viously defi­ned resources.)

Per default the state is stored in a file in the local working direc­tory, though, it is recom­men­ded to use a remote loca­tion to store the state. With a remote state setup, Ter­ra­form can use remote locking, ther­eby pre­ven­ting mul­ti­ple users to apply chan­ges to the same pro­ject at the same time.

Pro­vi­ders:

In com­pa­ri­son to other IaC tools, Ter­ra­form is not rely­ing 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 the usage of Ter­ra­form with Azure, note that Ter­ra­form also has pro­vi­ders for Kuber­netes, VMWare, vSphere and various other ser­vices and plat­forms, which can also be run in an on pre­mise envi­ron­ment. Thus, a Ter­ra­form pro­vi­der just ser­ves as a trans­la­tor bet­ween Ter­ra­form code and API calls to 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­di­zing infra­struc­ture set­ups across mul­ti­ple appli­ca­ti­ons, as well as 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­chap­ters, we will define a module for the pre­viously dis­cus­sed exam­ple of the plat­form admi­nis­tra­tor being tas­ked to deploy 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 Keyv­ault hol­ding the CMK 

provider.tf

In the provider.tf file, the nee­ded pro­vi­ders for the module to func­tion are defi­ned. Addi­tio­nally, the fea­tures block enables the deve­lo­pers to define cer­tain resource attri­bu­tes on 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 is nee­ded to pre­vent ver­sion drift bet­ween the pro­vi­der used during module deve­lo­p­ment and actual usage in a pro­duc­tive environment.

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­ved mul­ti­ple goals:

  • Encap­su­la­tes com­ple­xity of infra­struc­ture setup. The neces­sary setup needs to be defi­ned once and not be reinven­ted by other teams and/or pro­jects again.
  • 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 AAD authen­ti­ca­tion only 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 in 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 pro­vide a clear and struc­tu­red docu­men­ta­tion. The input varia­bles i.e. inter­face, as well as the module its­elf should be docu­men­ted in a way, that no han­do­ver from the deve­lo­per to con­su­mer is necessary.
  • The module should be desi­gned as fle­xi­ble as pos­si­ble. A gene­ral approach can be: in case 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 module varia­ble. This approach pro­vi­des fle­xi­bi­lity for the module usage and har­dens the inter­face against brea­king changes.
  • Avoid fea­ture bloat. While fle­xi­bi­lity in its appli­ance to dif­fe­rent use cases was pre­viously men­tio­ned as a design goal, in order to main­tain a module, a clear scope of its func­tion­a­lity needs to be 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 pro­vi­ders 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 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­lo­gue 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 point in the fol­lo­wing chapter.

Let us revi­sit the exam­ple of the plat­form admi­nis­tra­tor being reques­ted 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 resour­ces nee­ded 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 as well as net­wor­king setup and encap­su­la­ted this 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 directly. 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 from the plat­form team and alle­via­tes poten­tial bot­t­len­ecks for the appli­ca­tion team. The plat­form team the­r­e­fore 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 ope­ra­tio­nal over­head of the plat­form team over time, there are some con­side­ra­ti­ons to be made 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, mana­ging the role assign­ments on the respec­tive resour­ces are tasks, which can be gover­ned by self-ser­vice with a low risk of caus­ing big­ger issues. On the other hand, set­ting up a new sub­scrip­tion, set­ting a bud­get, inte­gra­ting a new Vnet in the exis­ting net­work infra­struc­ture or mana­ging an MS Entra ten­ant are tasks, where 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 networking/security specialists.

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

Before pro­vi­ding access to the sub­scrip­tion repo­si­to­ries as well as the shared modu­les, a clear pro­cess, as well as guar­drails and pos­si­ble sup­port work­flows need to be defi­ned. Here are a few ques­ti­ons to guide the pro­cess, that need to have clear ans­wers before start­ing the self-ser­vice project:

  • 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 to resour­ces are made, that are out of bound 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 on the sub­scrip­tion repositories?
  • Are there ways to cir­cum­vent the out­lined pro­cess? If so, how can they be solved?
  • Is the docu­men­ta­tion in place and com­pre­hen­sive, so that appli­ca­tion teams can start and dis­co­ver the pro­cess autonomously?
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 should also be ver­sio­ned accor­din­gly. This directly ties to the usage of the shared modu­les: when a module is cal­led in a ter­ra­form tem­p­late, a source to the module defi­ni­tion needs to 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 advan­cing adop­tion of Infra­struc­ture as Code (with focus on Ter­ra­form) and what pos­si­bi­li­ties it pro­vi­des in terms of stan­dar­diza­tion and sca­la­bi­lity. It is also important to notice, that the out­lined deve­lo­p­ment from manual deploy­ments in the web UI to mana­ged self-ser­vice plat­form with stan­dar­di­zed modu­les for every usecase, is only an out­look on how sca­la­bi­lity in infra­struc­ture manage­ment can be achie­ved and not a solu­tion that fits every com­pany. While for smal­ler com­pa­nies with only a few appli­ca­tion teams, having a small plat­form team main­tai­ning the sub­scrip­tion and module repo­si­to­ries them­sel­ves might alre­ady be suf­fi­ci­ent, big­ger orga­niza­ti­ons will need to con­sider scalability.