$ run as a normal user# run as root (or with sudo)
Lay out the repository
Separate reusable modules from the environments that use them.
infra-shop/
├── modules/
│ ├── network/ (VNet, subnets, NSGs)
│ ├── web/ (VMs or App Service, load balancer)
│ └── data/ (PostgreSQL, storage, Key Vault)
├── envs/
│ ├── staging/ (main.tf, backend.tf, staging.tfvars)
│ └── prod/
├── .github/workflows/terraform.yml
└── README.mdBuild the state backend first
The storage for the state is created once, by hand or by a small separate configuration.
az group create -n rg-tfstate -l westeurope
az storage account create -g rg-tfstate -n sttfstateshop --sku Standard_ZRS --min-tls-version TLS1_2 --allow-blob-public-access false
az storage account blob-service-properties update -g rg-tfstate --account-name sttfstateshop --enable-versioning true --enable-delete-retention true --delete-retention-days 30
az storage container create --account-name sttfstateshop -n tfstate --auth-mode loginWrite the staging environment
The environment calls the modules with its own values.
module "network" {
source = "../../modules/network"
name = "vnet-shop-staging"
address_space = ["10.40.0.0/16"]
subnets = { web = "10.40.1.0/24", data = "10.40.2.0/24" }
tags = local.tags
}
module "web" {
source = "../../modules/web"
env = "staging"
subnet_id = module.network.subnet_ids["web"]
vm_count = 2
vm_size = "Standard_B2s"
tags = local.tags
}
module "data" {
source = "../../modules/data"
env = "staging"
subnet_id = module.network.subnet_ids["data"]
tags = local.tags
}Add the pipeline
On a pull request: format, validate, scan, plan (posted as a comment). On merge to main: apply the reviewed plan, with an approval step.
on:
pull_request: { paths: ["envs/**", "modules/**"] }
push: { branches: [main], paths: ["envs/**", "modules/**"] }
permissions: { id-token: write, contents: read, pull-requests: write }
jobs:
plan:
runs-on: ubuntu-latest
defaults: { run: { working-directory: envs/staging } }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: azure/login@v2
with: { client-id: "${{ vars.AZURE_CLIENT_ID }}", tenant-id: "${{ vars.AZURE_TENANT_ID }}", subscription-id: "${{ vars.AZURE_SUBSCRIPTION_ID }}" }
- run: terraform fmt -check -recursive ../..
- run: terraform init
- run: terraform validate
- run: trivy config --exit-code 1 ../..
- run: terraform plan -out=tfplan -var-file=staging.tfvarsThe full pipeline with OIDC and environments is in Deploy to Azure with OIDC.
Prove that it works
The project is done when the whole environment can be destroyed and rebuilt from Git without manual steps.
- [ ]
terraform destroythen a pipeline run rebuilds everything. - [ ] A change goes through a PR with plan output and a passing security scan.
- [ ] No secrets in Git; the database password comes from Key Vault.
- [ ] A nightly drift check (
plan -detailed-exitcode) alerts on manual changes. - [ ] The README has a diagram, the design decisions and the run commands.