Est.
FeaturesLong read

A Terraform Tutorial for Azure That Does Not Assume a Platform Team

Manage Azure infrastructure as code without waiting for a dedicated platform team.

Contributing Editor · · 11 min read
Features · September 26, 2026 · 11 min read · 2,386 words

Small teams on Azure fail at infrastructure because nobody wrote down what exists, and the one person who remembers left for another job. They fail because nobody wrote down what exists, and the one person who remembers left for another job. This piece walks through a Terraform setup that closes that gap, covering remote state, service principal auth, a file structure that survives three environments, and a CI/CD pipeline where nobody applies changes from a laptop. None of it requires a dedicated platform team. It just requires making the same handful of decisions a platform team would make, once, and writing them down as code.

Small engineering teams and Azure infrastructure that lives in someone's head

The default path looks like this: someone clicks through the Azure portal to spin up a resource group, then a VNet, then a container instance, because the deadline is Thursday and the portal is right there. Six months later there are 40 resources nobody documented, half of them named after whatever came to mind that day, and the one engineer who remembers why the subnet is sized the way it is just gave two weeks' notice.

Onboarding the next hire means reconstructing all of it by hand, resource by resource, guessing at intent.

Most Terraform writing doesn't help here, because most of it assumes a platform team already exists. It assumes a dedicated DevOps engineer, someone on the team who already knows HCL cold, and an org that can absorb a failed experiment without it costing a production incident. None of that describes a two- or three-person engineering team trying to ship a product.

This guide skips that assumption. It makes the calls a platform team would otherwise make, on state storage, authentication, file layout, and CI/CD, so a small team has one path to follow instead of a dozen forks to debate. Azure is a leading cloud platform and the common default for startups holding Microsoft credits or an existing enterprise relationship, and it should be run properly rather than by accretion.

What Terraform does and why HCL is worth learning

Terraform's core idea is simple even if the implementation isn't: you describe the infrastructure you want, in a declarative file, and Terraform works out the sequence of Azure API calls needed to make that state real. No imperative scripts. No manually ordering "create the VNet, then wait, then create the subnet." The tool builds a dependency graph and handles the ordering itself.

Three principles sit underneath that: manage resources across any cloud provider through a common workflow, define infrastructure declaratively rather than as a sequence of steps, and create and manage that infrastructure predictably, so the same config produces the same result every time it runs.

Operationally, the Terraform CLI is a single Go binary. There's no agent to install on a server, no daemon running in the background, nothing to patch or maintain beyond the binary itself. For a team without a platform org, that low footprint matters as much as the language does.

HashiCorp launched Terraform on July 28, 2014. The public module registry arrived in 2017, and Terraform Enterprise followed in 2019. That's over a decade of production use behind the tool, and it's a big part of why trusting it with real infrastructure isn't a leap of faith at this point, it's a well-worn path.

The license fork every team should understand before committing to the tool

In August 2023, HashiCorp moved Terraform from the Mozilla Public License to the Business Source License, restricting certain commercial uses of the tool. That single decision split the ecosystem, and any team starting fresh on Azure needs to understand the split before picking a side.

OpenTofu forked from that point and kept developing under the original MPL license, governed now by the Linux Foundation rather than a single company. For a working engineer, the practical difference today is smaller than the licensing headlines suggest, since HCL syntax is identical between the two, the azurerm provider works the same way in both, and the CLI commands match, with tofu swapped in for terraform.

Neither project is standing still, either. OpenTofu 1.11 shipped ephemeral resources and other features as part of active, ongoing development. HashiCorp Terraform, for its part, had already shipped provider-defined functions in Terraform 1.8 and ephemeral resources of its own in Terraform 1.10. Pick based on licensing terms that matter to the business. Neither has.

Setting up remote state before writing a single resource, the decision that prevents the most common team-scale failures

Before touching a single resource block, get the state backend right. This is the decision that saves a team from its most common self-inflicted failure.

The state file is Terraform's memory: a JSON record of every resource it created, along with the IDs and properties of each. It's the tool's only source of truth about what actually exists in Azure. Lose it, or let two people fight over it, and Terraform starts making decisions based on a picture of the world that's already wrong.

That file can also contain sensitive values in plaintext, connection strings, keys, whatever got passed into a resource as an argument. It should never be checked into source control, and never sent around as a file attachment in a messaging app or email.

The moment a second engineer joins the project, local state stops being viable. If two people are running terraform apply from their own laptops against their own local state files, the second apply overwrites whatever the first one recorded, and the two copies of "truth" diverge immediately.

The fix on Azure is Azure Blob Storage as the backend. It gives the whole team shared access to one state file, locks it during an apply so two people can't step on each other mid-run. Set this up before the first resource block gets written, not after the first merge conflict in a .tfstate file forces the issue.

Authentication that does not rely on a single engineer's personal credentials

The most common shortcut, and the most dangerous one, is running Terraform against a single engineer's personal Azure login, or worse, hardcoding a client secret directly into a .tf file. Both create a single point of failure: the person leaves, rotates their password, or loses laptop access, and the pipeline breaks. Worse, a leaked secret in version control is a security incident, and it should be treated as one, meaning immediate rotation the moment it's discovered.

The right pattern is a service principal scoped narrowly to only the resources Terraform actually needs to touch. Not a human identity borrowed for convenience. Not a global admin account, because that's a blast radius no small team wants to own.

For CI/CD specifically, OpenID Connect with federated credentials removes the need to store any secret. The handshake runs like this: GitHub Actions requests an OIDC token, that token gets presented to Microsoft Entra ID, Entra ID checks it against a federated credential tied to the Azure service principal, and Azure RBAC grants access based on that identity. No client secret sitting in a GitHub repo's settings, ever.

A file structure and variable convention that scales from one environment to three without a rewrite

Split the configuration from day one, even when the whole thing would still fit in one file. Use main.tf for resources, variables.tf for variable declarations, terraform.tfvars for the values specific to whichever environment is running, outputs.tf for anything another config or another engineer needs to query, and provider.tf for the azurerm provider block.

Splitting the files this way pays off in code review: a pull request that touches networking looks different from one that touches variable defaults, and reviewers can tell at a glance what actually changed. It also leaves a natural seam for drawing module boundaries later, once the project outgrows a flat structure.

The variables and tfvars pattern is what lets one codebase serve dev, staging, and prod without forking the logic three times. Declare each variable in variables.tf with a type and a description, then override the values per environment in terraform.tfvars. The same resource block can deploy to East US for a dev environment and West US for prod, with nothing in the core files changing.

Naming discipline affects how much time gets spent later renaming resources across an environment. Every resource name should carry environment, region, and purpose: rg-prod-eastus-api, not my-resource-group. It costs nothing to type that out correctly the first time. Going back to rename 40 resources after the fact, some of which other configs already reference by name, costs a lot more.

Walking through a minimal but production-honest Azure deployment: resource group, VNet, and a containerized workload

Start with the resource group. It's the logical container that everything else in the deployment lives inside, and it's simple enough to use as a clean demonstration of the full workflow before any real complexity gets added.

The workflow itself has six steps, and skipping any of them is where teams get burned:

  • terraform init downloads the azurerm provider and initializes the backend connection to Azure Blob Storage
  • terraform validate checks the syntax before anything gets sent to Azure
  • terraform plan -out calculates what would change and saves that exact plan to a file
  • A manual review, someone actually reading the plan output before approving it
  • terraform apply, run against the saved plan file, not a fresh one
  • Verification, checking the result in the Azure portal or via the CLI to confirm it matches intent

That -out flag isn't a nice-to-have for a team. Applying a saved plan guarantees that what got reviewed is what gets applied to Azure, with no drift between the two. Running terraform apply fresh, without a saved plan, in an environment other people also touch, is how a plan that looked fine five minutes ago applies something different because someone else merged a change in between.

Once the resource group exists, add a VNet and a subnet underneath it. This is where Terraform's dependency graph actually earns its keep: the subnet declaration references the VNet, and Terraform automatically sequences the creation so the VNet exists first. Nobody writes a "wait for VNet" step. The tool figures out the order on its own, and that's the proof the tool is doing its job.

Automating the plan-approve-apply loop with GitHub Actions so no one runs Terraform from a laptop in production

The workflow that makes this repeatable has a feature branch open a pull request, then a Terraform CI job runs init, validate, and plan, then posts that plan as a comment directly on the PR. A human reviews it. Once merged to main, a Terraform CD job applies the saved plan against Azure.

OIDC authentication, set up earlier, is what makes this safe to automate. No secret sits in GitHub's settings waiting to leak; the federated credential handles the handshake between GitHub Actions and Azure every time the pipeline runs.

Posting the plan as a PR comment replaces what a platform team's change-review process would otherwise do. Reviewers see the exact resources that will change, get added, or get destroyed in Azure, before they approve the merge, not after. Reviewing the plan means reviewing the actual infrastructure change the code produces, not just the code.

Production should sit behind a manual approval gate between plan and apply. A manual approval gate on the production environment, requiring a reviewer to sign off before the apply job runs, belongs on production specifically. Staging can auto-apply on merge, since the cost of a mistake there is a lot lower.

Changes involving GPU compute or an Azure Machine Learning workspace

An Azure Machine Learning workspace isn't one resource, it's a cluster of them. Spinning one up means provisioning a storage account, a Key Vault, an Application Insights instance, a container registry, and the workspace resource itself, all wired together. Terraform's dependency graph makes that cluster reproducible instead of a checklist someone has to remember to follow in order every time.

Compute clusters for training jobs benefit the same way. Auto-scaling declared directly in the Terraform config means the cluster scales down to zero when nothing's running and scales up the moment a training job kicks off. That's cost control written into the infrastructure definition itself.

RBAC for the workspace, who's allowed to submit training jobs, who can read model artifacts, belongs in version control right alongside the workspace definition. That turns access control into something auditable and reproducible, rather than a set of portal clicks one engineer performed once and nobody else can trace.

GPU-backed VMs slot into the same pattern as any other compute in Azure. Declare the SKU, the count, the network, the disk, and Terraform provisions it exactly like a standard VM. No separate mental model needed for GPU workloads versus everything else.

Terraform on Azure outgrowing what a PaaS can give you

None of this is an argument that every team needs Terraform on day one. Platform-as-a-service options exist precisely because they remove infrastructure decisions entirely, letting a small team push code without ever thinking about a VNet. For a team with zero bandwidth to manage infrastructure as code, that tradeoff is the right one, full stop.

The signal to watch for is the monthly bill. Once a PaaS bill crosses a threshold where moving to Azure with managed services would save meaningfully, and teams commonly find that crossover is somewhere in the 50 to 70 percent savings range, the economics stop favoring convenience and start favoring control.

That threshold matters more now given where the PaaS market itself is heading. Plenty of platforms in this category have been folded into maintenance mode by their parent companies over the past few years, feature development slowed to sustaining-engineering pace, existing customers kept running but no meaningful new investment coming. Any team on a platform showing that pattern should treat a move off it as a matter of when, not if, and start planning the migration on their own timeline instead of a forced one.

Migration realism matters here too. Cloud platform migrations run 20 to 40 percent longer than the initial estimate, almost always because of integrations and dependencies nobody accounted for at the planning stage. Budget a quarter of dedicated engineering time for a move like this. Terraform makes the destination cleaner once the team gets there. It doesn't make the trip shorter.

Sources

  1. Terraform (software) - Wikipedia
  2. Build infrastructure | Terraform | HashiCorp Developer
  3. developer.hashicorp.com
  4. en.wikipedia.org
  5. opentofu.org
  6. spacelift.io
  7. developer.hashicorp.com
  8. devblogs.microsoft.com

More in Features