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



Infra­struc­ture man­age­ment has changed sig­ni­fic­antly over the past dec­ade. What was once man­aged manu­ally through cloud con­soles, scripts, and web UIs is increas­ingly becom­ing part of the soft­ware devel­op­ment life­cycle. Infra­struc­ture as Code (IaC) is at the heart of this trans­form­a­tion. Instead of con­fig­ur­ing infra­struc­ture manu­ally, infra­struc­ture is defined in reusable tem­plates, which are developed, ver­sioned and applied by the plat­form team.

One of the most widely adop­ted tools for this approach is Ter­ra­form. This art­icle provides an intro­duc­tion to the basics of Ter­ra­form and explores how organ­iz­a­tions can move from manual infra­struc­ture pro­vi­sion­ing to shared cus­tom mod­ules and ulti­mately to a self-ser­vice approach, enabling non-plat­form engin­eers while still guar­an­tee­ing com­pli­ance with organ­iz­a­tional policies.

Why Infra­struc­ture as Code?

Before look­ing at Ter­ra­form itself, it is worth under­stand­ing the prob­lem Infra­struc­ture as Code is try­ing to solve:

Pre­dict­able and repro­du­cible deployments:

Tra­di­tion­ally, infra­struc­ture was often cre­ated manu­ally. An admin­is­trator would log into a cloud con­sole or web UI, cre­ate a vir­tual net­work, pro­vi­sion a data­base, con­fig­ure access rules, and repeat the pro­cess for every envir­on­ment. This approach may work for a small organ­iz­a­tion or a PoC. As infra­struc­ture grows, how­ever, manual resource man­age­ment becomes increas­ingly dif­fi­cult and error prone:

Dif­fer­ent envir­on­ments may have slightly dif­fer­ent con­fig­ur­a­tions. Resources can be for­got­ten or con­figured incor­rectly. Changes may not be doc­u­mented prop­erly, and repro­du­cing an envir­on­ment can become a time-con­sum­ing task.

With IaC, the desired infra­struc­ture is described in tem­plates rather than being cre­ated manu­ally. Deploy­ing the same infra­struc­ture in dif­fer­ent envir­on­ments thus becomes a mat­ter of apply­ing the cor­rect set of para­met­ers to the tem­plates dur­ing the deploy­ment process.

With IaC the before men­tioned admin­is­trator needs to define how the vir­tual net­work, data­base and access rules should look like in a para­met­rized tem­plate and apply the cor­rect para­meter set dur­ing the deploy­ment. This approach makes infra­struc­ture deploy­ment more pre­dict­able and repro­du­cible, while changes can be tracked through ver­sion control.

Stand­ard­iz­a­tion, secur­ity and policy compliance:

Apply­ing IaC and estab­lish­ing tem­plates and mod­ules that are used com­pany wide, allows organ­iz­a­tions to estab­lish com­mon infra­struc­ture pat­terns. Nam­ing con­ven­tions, tag­ging, net­work­ing con­fig­ur­a­tions as well as secur­ity and policy require­ments can be met con­sist­ently by encap­su­lat­ing them in cus­tom mod­ules (see chapter 3).

Ter­ra­form basics and core Concepts

Declar­at­ive configuration:

Ter­ra­form is an open source  Infra­struc­ture as Code tool provided by Hashicorp that allows infra­struc­ture to be defined and man­aged through declar­at­ive con­fig­ur­a­tion. This means, that instead of script­ing every indi­vidual step required to cre­ate the infra­struc­ture resources in sequence, you describe the desired end state. Ter­ra­form then determ­ines how to reach that state: Ter­ra­form cre­ates a DAG (Dir­ec­ted Acyc­lic Graph), defin­ing the order of deploy­ment and thus resolv­ing found depend­en­cies between the defined resources.

Ter­ra­form state:

While Ter­ra­form determ­ines the needed deploy­ment steps in order to con­fig­ure the provided cloud envir­on­ment accord­ing to the desired state defined in the tem­plate, it does not only com­pare the cur­rent state of the remote resources with their coun­ter­parts in the tem­plate. It also relies on the Ter­ra­form state file. This state file con­tains a 1:1 map­ping of the defined resources in the tem­plate to their real coun­ter­parts, as well as addi­tional metadata, such as depend­en­cies as of the last deploy­ment. (This is espe­cially import­ant in case of decom­mis­sion of pre­vi­ously defined resources.)

Per default the state is stored in a file in the local work­ing dir­ect­ory, though, it is recom­men­ded to use a remote loc­a­tion to store the state. With a remote state setup, Ter­ra­form can use remote lock­ing, thereby pre­vent­ing mul­tiple users to apply changes to the same pro­ject at the same time.

Pro­viders:

In com­par­ison to other IaC tools, Ter­ra­form is not rely­ing on one spe­cific cloud pro­vider or ser­vice. It uses pro­viders to inter­act with dif­fer­ent cloud ser­vices, such as AWS, Azure or Google Cloud, but is not lim­ited to those. While this art­icle focuses on the usage of Ter­ra­form with Azure, note that Ter­ra­form also has pro­viders for Kuber­netes, VMWare, vSphere and vari­ous other ser­vices and plat­forms, which can also be run in an on premise envir­on­ment. Thus, a Ter­ra­form pro­vider just serves as a trans­lator between Ter­ra­form code and API calls to the under­ly­ing platform.

Mod­ules:

Mod­ules allow Ter­ra­form resources and con­fig­ur­a­tions to be pack­aged into reusable build­ing blocks. For example, a mod­ule can con­tain an Azure SQL Server resource, as well as an encryp­tion key resource and a private end­point resource. Addi­tion­ally, while cer­tain prop­er­ties of the SQL Server might still be exposed as input para­met­ers for the mod­ule (more on that later), val­ues of other prop­er­ties, such as pub­lic access, can be enforced by hard­cod­ing them in the mod­ule. This way, mod­ules can help stand­ard­iz­ing infra­struc­ture setups across mul­tiple applic­a­tions, as well as enforce secur­ity and com­pany policies.

Ter­ra­form Mod­ules as Build­ing Blocks

While Ter­ra­form tech­nic­ally does not rely on a given folder struc­ture or nam­ing con­ven­tion, a typ­ical setup of a Ter­ra­form mod­ule will con­tain the fol­low­ing files:

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

In the fol­low­ing subchapters, we will define a mod­ule for the pre­vi­ously dis­cussed example of the plat­form admin­is­trator being tasked to deploy a SQL Server. The fol­low­ing Azure resources will need to be deployed by the module:

  • MS SQL Server
  • private end­point to MS SQL Server
  • Cus­tomer Man­aged Encryp­tion Key (CMK)
  • Role assign­ment for the man­aged iden­tity of the SQL Server in the Keyvault hold­ing the CMK 

provider.tf

In the provider.tf file, the needed pro­viders for the mod­ule to func­tion are defined. Addi­tion­ally, the fea­tures block enables the developers to define cer­tain resource attrib­utes on pro­vider level. 

provider "azurerm" {
  features {}
}

versions.tf

The versions.tf file defines the required pro­vider ver­sions and is needed to pre­vent ver­sion drift between the pro­vider used dur­ing mod­ule devel­op­ment and actual usage in a pro­duct­ive environment.

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

input.tf

Defines the vari­ables used in the mod­ule. The spe­cific val­ues are passed in a mod­ule call in a Ter­ra­form pro­ject and are used to para­met­rize the resources that are defined 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­tains the resources pro­vi­sioned by the mod­ule. As can be seen in this example, defin­ing a cus­tom mod­ule for the MS SQL Server achieved mul­tiple goals:

  • Encap­su­lates com­plex­ity of infra­struc­ture setup. The neces­sary setup needs to be defined once and not be rein­ven­ted by other teams and/or pro­jects again.
  • Stand­ard­iz­a­tion:  The SQL Server setup of Team A will look sim­ilar to the setup of Team B.
  • Secur­ity and com­pany policies: Secur­ity policies, such as restrict­ing pub­lic access and AAD authen­tic­a­tion only in the example, 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

Defines which resources and/or respect­ive prop­er­ties are access­ible in the Ter­ra­form pro­ject where the mod­ule is called.

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

In the pre­vi­ous example the plat­form admin­is­trator went from con­fig­ur­ing the Vnet, SQL Server, role assign­ments and private end­points manu­ally in the web UI, to adding them in the Ter­ra­form tem­plate for the tar­get envir­on­ment. Now, once the mod­ule is defined and ready to use, the plat­form admin­is­trator only needs to add one mod­ule call to the Ter­ra­form tem­plate for any future SQL Server deployment:

Con­sid­er­a­tions for design­ing shared Modules

  • A good mod­ule should provide a clear and struc­tured doc­u­ment­a­tion. The input vari­ables i.e. inter­face, as well as the mod­ule itself should be doc­u­mented in a way, that no han­dover from the developer to con­sumer is necessary.
  • The mod­ule should be designed as flex­ible as pos­sible. A gen­eral approach can be: in case a prop­erty of the main resource is not restric­ted by secur­ity or com­pany policies, it should be exposed as mod­ule vari­able. This approach provides flex­ib­il­ity for the mod­ule usage and hardens the inter­face against break­ing changes.
  • Avoid fea­ture bloat. While flex­ib­il­ity in its appli­ance to dif­fer­ent use cases was pre­vi­ously men­tioned as a design goal, in order to main­tain a mod­ule, a clear scope of its func­tion­al­ity needs to be defined. 
  • Ver­sion­ing is import­ant. Once a mod­ule is used by mul­tiple teams, changes to the mod­ule can poten­tially affect many applic­a­tions. A Ter­ra­form mod­ule should not simply be a col­lec­tion of Ter­ra­form code. It should be treated as a reusable infra­struc­ture build­ing block with a defined inter­face, pur­pose and lifecycle.

Ter­ra­form Mod­ules as a Self-Ser­vice Capability

Switch­ing from manual infra­struc­ture deploy­ments through the cloud pro­viders web UI to using IaC is a major step towards stand­ard­iz­a­tion not only between dif­fer­ent envir­on­ments, but also dif­fer­ent teams and applic­a­tions. Using mod­ules as reusable build­ing blocks can also solve an organ­iz­a­tional prob­lem: how can applic­a­tion teams request infra­struc­ture without requir­ing the plat­form team to pro­vi­sion every reques­ted change?

A pos­sible solu­tion is enabling the applic­a­tion teams to pro­vi­sion cer­tain infra­struc­ture parts them­selves, by provid­ing a com­pre­hens­ive cata­logue of Ter­ra­form mod­ules. A mod­ule does not auto­mat­ic­ally have to become a self-ser­vice inter­face. An organ­iz­a­tion can con­tinue to use cer­tain mod­ules only cent­rally within the plat­form team. More on that point in the fol­low­ing chapter.

Let us revisit the example of the plat­form admin­is­trator being reques­ted to pro­vi­sion a SQL Server in an Azure subscription:

We went from pro­vi­sion­ing and con­fig­ur­ing all resources needed in the web UI to defin­ing them in a Ter­ra­form tem­plate and finally to defin­ing a stand­ard­ized set of server con­fig­ur­a­tions, role assign­ments as well as net­work­ing setup and encap­su­lated this in a cus­tom mod­ule that is called in the tem­plate. The next step is to involve the request­ing team dir­ectly: instead of cre­at­ing a Ticket for the plat­form team, the applic­a­tion team can cre­ate a Pull Request in the repos­it­ory gov­ern­ing their sub­scrip­tion and include the mod­ule call with the desired spe­cific­a­tions dir­ectly. The plat­form admin­is­trator can then review the Pull Request and approve it for deployment.

This approach reduces oper­a­tional over­head from the plat­form team and alle­vi­ates poten­tial bot­tle­necks for the applic­a­tion team. The plat­form team there­fore does not have to manu­ally execute every infra­struc­ture request, while the applic­a­tion team does not need to become an expert in the under­ly­ing infra­struc­ture implementation.

Gov­ernance and Guardrails

While the self-ser­vice approach does reduce oper­a­tional over­head of the plat­form team over time, there are some con­sid­er­a­tions to be made in advance:

Defin­ing clear bound­ar­ies for what can be gov­erned by self-ser­vice and what shouldn’t

Set­ting up a SQL Server, a stor­age account or a new con­tainer in an Azure sub­scrip­tion, man­aging the role assign­ments on the respect­ive resources are tasks, which can be gov­erned 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 budget, integ­rat­ing a new Vnet in the exist­ing net­work infra­struc­ture or man­aging an MS Entra ten­ant are tasks, where a self-ser­vice approach is not advis­able. Such requests should still be imple­men­ted by the plat­form team or the respect­ive networking/security specialists.

Set­ting up guard­rails for applic­a­tion teams

Before provid­ing access to the sub­scrip­tion repos­it­or­ies as well as the shared mod­ules, a clear pro­cess, as well as guard­rails and pos­sible sup­port work­flows need to be defined. Here are a few ques­tions to guide the pro­cess, that need to have clear answers before start­ing the self-ser­vice project:

  • What rules and con­ven­tions are employed in the sub­scrip­tion repos­it­or­ies? How are they enforced?
  • How do you ensure that no changes to resources are made, that are out of bound for the self-ser­vice approach?
  • How do you pre­vent sim­ul­tan­eous deploy­ments in the same sub­scrip­tion by mul­tiple users.
  • Is there a clear pro­cess for applic­a­tion teams on how to con­trib­ute 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 doc­u­ment­a­tion in place and com­pre­hens­ive, so that applic­a­tion teams can start and dis­cover the pro­cess autonomously?
Mod­ule Repos­it­or­ies and Versioning

A cent­ral mod­ule repos­it­ory can provide a con­sist­ent place for teams to dis­cover and con­sume approved shared mod­ules. For a smal­ler num­ber of shared mod­ules, this is a good approach. Each mod­ule can be con­tained in its own dir­ect­ory and can be ref­er­enced accord­ingly in the mod­ule call in the tem­plate. With a rising num­ber of shared mod­ules and col­lab­or­at­ors, a switch to a ded­ic­ated repos­it­ory per mod­ule is preferred.

The mod­ules should fol­low a clearly defined life­cycle should also be ver­sioned accord­ingly. This dir­ectly ties to the usage of the shared mod­ules: when a mod­ule is called in a ter­ra­form tem­plate, a source to the mod­ule defin­i­tion needs to be provided. This can be a dir­ect­ory, a repos­it­ory branch or a repos­it­ory along with a tag. The lat­ter option should always be chosen.

Con­clu­sion

In this art­icle we have seen, how man­aging infra­struc­ture in a cloud envir­on­ment has changed with the advan­cing adop­tion of Infra­struc­ture as Code (with focus on Ter­ra­form) and what pos­sib­il­it­ies it provides in terms of stand­ard­iz­a­tion and scalab­il­ity. It is also import­ant to notice, that the out­lined devel­op­ment from manual deploy­ments in the web UI to man­aged self-ser­vice plat­form with stand­ard­ized mod­ules for every usecase, is only an out­look on how scalab­il­ity in infra­struc­ture man­age­ment can be achieved and not a solu­tion that fits every com­pany. While for smal­ler com­pan­ies with only a few applic­a­tion teams, hav­ing a small plat­form team main­tain­ing the sub­scrip­tion and mod­ule repos­it­or­ies them­selves might already be suf­fi­cient, big­ger organ­iz­a­tions will need to con­sider scalability.