Guide - Terraform for an Azure environment you already have

It was created in the portal, by someone who has since moved to another team. Test and production were built months apart and have drifted since. Nobody can say what is running or why the bill is what it is.

Almost nobody gets to start over. This is how to put a running environment under Terraform without an outage, and the five decisions that determine whether it is still useful a year later.

Guide · for a developer or lead

Terraform for an Azure Environment You Already Have

Will Pickeral, William Belle LLC · support@williambelle.co

Most Azure environments were created in the portal, by someone who has since moved to another team. Test and production were built months apart and have drifted since. Nobody can say what is running, who has access, or why the bill is what it is, and finding out means clicking through blades and writing it down.

Terraform fixes that, but not by starting over. Almost nobody gets to start over. This is how to put a running environment under Terraform without an outage, and the five decisions that determine whether it stays useful a year later.

HashiCorp's documentation covers the language. This is about the parts that are Azure-specific, or that only hurt on a real environment.


Remote state, before anything else

Terraform records what it believes exists in a state file. Get this wrong and every other problem is downstream of it.

State does not go in your repository. Two people run apply from different checkouts, both are right about their own state file, and the second one destroys what the first one made. State also contains every value the provider read back, which is why the next rule exists.

State contains your secrets in plaintext. A database password passed to a resource is in the state file, readable by anyone who can read the file, whatever the resource does to hide it in the portal. This is documented behavior, not a defect, and it decides who may read the state container.

So the first thing to create — by hand, once, deliberately — is a storage account for state, in its own resource group, separate from anything Terraform will manage:

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateexample"
    container_name       = "tfstate"
    key                  = "production.tfstate"
  }
}

Blob storage gives you locking through a lease, so two runs cannot apply at once. Turn on versioning and soft delete on that container before you use it: a corrupted state file with no previous version is the worst afternoon in this entire subject.

Do not let Terraform manage its own state storage. A configuration that can destroy the record of what it built has a way of losing both at the same time.


Import what exists; do not rebuild it

The instinct is to write the configuration, run apply, and let it create everything fresh. On a running environment that means creating a second copy of production, or destroying the first.

Terraform's import block does this without a separate command, and it is checked by plan like everything else:

import {
  to = azurerm_resource_group.app
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod"
}

resource "azurerm_resource_group" "app" {
  name     = "rg-app-prod"
  location = "eastus"
}

Run plan and read it. What you are looking for is "0 to add, 0 to change, 0 to destroy." Anything else means your configuration and reality disagree, and reality is the one currently serving customers. Fix the configuration until the plan is empty, then apply.

Do this a resource at a time, starting with the resource group and the things that have no dependents. A dozen small empty plans are worth more than one large plan you skim.

Two things make this slower than it sounds, and neither is a reason to skip it:

  • The portal sets defaults you did not choose. Tags, TLS versions, network rules. The plan will want to remove them. Decide each one — most should go into the configuration, because they are now part of what production is.
  • Some resources cannot be imported cleanly, and a few cannot be imported at all. Where that happens, the honest answer is often to create the replacement alongside, move traffic, and delete the original.

One configuration, one directory per environment

Two ways to handle test and production, and the wrong one is the one that looks tidier.

Workspaces keep one configuration and switch state files. They are fine when environments differ only by name and size. They fail as soon as production has something test does not, because the difference has to be expressed as conditionals inside the configuration, and a configuration full of count = var.environment == "prod" ? 1 : 0 is one nobody can read.

A directory per environment, each with its own backend key and its own .tfvars, sharing modules:

infra/
  modules/app/          the resources, once
  environments/test/    backend key, tfvars, module call
  environments/prod/    backend key, tfvars, module call

The duplication is a few lines per environment and it buys you the thing that matters: you can read environments/prod and see what production is, without simulating a ternary in your head. It also means a mistake in the test directory cannot touch production state, because they are different state files reached by different runs.

Environments should differ only in the values, not in the resources. The moment production has a resource test does not, you have stopped being able to test the thing you deploy. Size differences are fine. Structural differences are the drift you started this to remove.


Draw the boundary: what Terraform must not own

Terraform is good at infrastructure that changes rarely. It is bad at anything that changes every day, and using it there produces slow pipelines and dangerous plans.

Terraform owns: resource groups, networking, databases and their firewall rules, App Service plans and apps, Key Vault and its access policies, storage, managed identities, role assignments, alert rules.

Terraform does not own:

  • The application deployment. The pipeline pushes code to the app; Terraform creates the app. If Terraform also deployed the code, every application release would need an apply against your whole infrastructure.
  • Secret values. Terraform creates the vault and grants the identity that reads it. The values are set once, by a person or a rotation job. Putting a secret's value in the configuration puts it in the state file and in your repository.
  • Anything with data in it that a destroy would take. Add prevent_destroy to databases and storage accounts holding anything real:
lifecycle {
  prevent_destroy = true
}

That flag has saved more production databases than every code review in the industry. It makes terraform destroy fail rather than proceed, which is the correct behavior for a command nobody should be running against production anyway.


Running it from a pipeline

The same rules as any deployment pipeline, with one addition specific to Terraform.

Plan on the pull request; apply on the default branch. The plan is the review. A pull request that changes infrastructure should show what it will do to that infrastructure, and a reviewer who cannot see the plan is approving prose.

Apply the exact plan you reviewed, not a fresh one:

- script: terraform plan -out=tfplan -var-file=prod.tfvars
  displayName: Plan

- script: terraform apply -auto-approve tfplan
  displayName: Apply

A saved plan file is applied without re-planning. Without -out, the apply computes a new plan against whatever the world looks like at that moment, and what a person approved is not necessarily what runs.

Authenticate the same way the application pipeline does: a service connection using workload identity federation, scoped to the subscription or resource group Terraform manages, with no secret to expire. The state storage account is the exception to least privilege — the pipeline identity needs data-plane access to that container specifically, which is separate from the control-plane access it has to everything else.


When somebody changes it in the portal

They will. The database is down at 2am, a firewall rule fixes it, and nobody opens an editor at 2am. That is the right call in the moment.

Terraform's response to portal changes is plan, which shows the difference between the configuration and reality. Three responses, in order of how often each is right:

  1. The change was correct — put it in the configuration. Most emergency changes are things that should have been there.
  2. The change was wrong — let the next apply revert it. Rare, and only when you are sure of what it was for.
  3. The resource should not be Terraform's — a queue whose properties an application tunes at run time, for instance. Remove it from state and stop describing it.

What does not work is treating drift as a discipline problem. Run plan on a schedule and report the result, so drift is noticed in hours rather than found six months later during an unrelated change. A configuration nobody has planned against in months is documentation, not infrastructure as code.


If you have none of this today

Each step is useful on its own, and none of them requires the next one.

  1. Create the state storage account by hand, with versioning and soft delete on. Write down that it is not managed by Terraform and why.
  2. Import one environment, one resource at a time, until plan is empty. Start with the environment that is not production. You will learn what the portal set for you at somebody else's expense.
  3. Extract the shared resources into a module, then stand up the second environment from it. This is the step that finds the drift, and it is usually uncomfortable.
  4. Move plan into the pull request so infrastructure changes are reviewed the way code is.
  5. Add scheduled drift detection. Last, because it is only useful once the configuration is trusted.

Most of the value arrives at step 2, when somebody can finally answer "what is running in production" by reading a file.


What this does not solve

Terraform describes infrastructure. It does not know whether your application works on it, and an apply that succeeds while the site returns errors is an ordinary outcome. Knowing the application is healthy is a separate piece of work.

It also does not make your environments identical on its own. It makes them describable, and it makes a difference between them visible in a diff. Closing that difference is a decision somebody still has to make.


Get a free 20-minute review — tell me what your Azure looks like now and I'll tell you where I'd start, and why. Nothing to prepare.

Twenty minutes, and nothing to prepare.

Tell me what you need built or fixed, and I'll tell you where I'd start.

Or send it in writing →

Something this didn’t answer?

Ask it here and I will write back. I'll get back to you within one business day.