När din Terraform-kodbas växer från 20 resurser till 200+ börjar allt som fungerade tidigare krackelera. Plan tar 8 minuter. State-filer krockar. Teamet är rädd för att köra apply. Den här artikeln beskriver hur vi löste varje problem - från modularisering och state-hantering till CI/CD-pipelines och team-arbetsflöden som faktiskt skalar.
01Problemet med Terraform i stor skala
Det börjar alltid bra. Du skriver din första main.tf, kör terraform apply, och magiskt nog skapas en VPC, några subnets och en VM i molnet. Terraform känns som superkrafter. Men sex månader senare har din kodbas blivit ett monster. En enda state-fil med 200+ resurser. terraform plan tar över 5 minuter. Och varje gång någon i teamet kör apply håller alla andan.
Vi upplevde exakt det här i ett av våra kundprojekt. Vår infrastruktur växte från ett litet GCP-projekt till ett ekosystem med 12 projekt, 40+ Cloud Functions, BigQuery-datasets över flera regioner, VPN-tunnlar till on-prem, och Kubernetes-kluster för interna tjänster. Allt var definierat i Terraform - men i en enda monolitisk konfiguration.
De specifika problemen vi såg var följande: plan-tider som översteg 8 minuter, state locking-konflikter när två utvecklare jobbade samtidigt, blast radius-risken att en felaktig ändring i nätverkskonfigurationen kunde ta ner hela produktionsmiljön, och det faktum att nya teammedlemmar behövde veckor för att förstå strukturen.
Lösningen var inte att byta verktyg - Terraform är fortfarande det bästa valet för multi-cloud IaC. Lösningen var att fundamentalt ändra hur vi strukturerade vår Terraform-kod. Vi behövde gå från en monolit till en modulär arkitektur med tydliga gränser, isolerade state-filer och automatiserade arbetsflöden.
02Modularisering
Det första och viktigaste steget var att bryta ner monoliten i moduler. Vi använder två typer av moduler: återanvändbara infrastrukturmoduler (som vi kallar "building blocks") och kompositionsmoduler som sätter ihop building blocks för specifika miljöer. Den här tvånivåstrukturen ger både återanvändning och flexibilitet.
Vår modulstruktur följer en strikt konvention. Varje modul har en tydlig uppgift, ett definierat interface med variables.tf och outputs.tf, och en README som förklarar vad modulen gör och varför den finns. Vi har också en regel: ingen modul får ha fler än 30 resurser. Om den växer över det så är det dags att bryta ut ytterligare submoduler.
infrastructure/
├── modules/ # Återanvändbara building blocks
│ ├── gcp-project/ # Skapar GCP-projekt med standardinst.
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── bigquery-dataset/ # Dataset + IAM + tabeller
│ ├── cloud-function/ # Function + trigger + IAM
│ ├── vpc-network/ # VPC + subnets + firewall
│ ├── gke-cluster/ # GKE med node pools
│ └── cloud-sql/ # CloudSQL med replikor
│
├── environments/ # Kompositionsmoduler per miljö
│ ├── production/
│ │ ├── networking/ # VPC, VPN, DNS
│ │ │ ├── main.tf
│ │ │ ├── backend.tf
│ │ │ └── terraform.tfvars
│ │ ├── data-platform/ # BigQuery, Cloud Functions
│ │ ├── compute/ # GKE, VMs
│ │ └── security/ # IAM, secrets, KMS
│ ├── staging/
│ └── development/
│
├── shared/ # Delad infrastruktur (DNS, billing)
└── scripts/ # CI/CD-hjälp, valideringVarje environment-mapp (t.ex. production/networking) är en oberoende Terraform root module med sin egen state-fil. Det innebär att en ändring i nätverkskonfigurationen aldrig kan påverka BigQuery-resurserna, och vice versa. Blast radius reduceras dramatiskt.
Här är ett konkret exempel på hur vår cloud-function modul ser ut. Den är generisk nog att användas för alla våra 40+ Cloud Functions, men specifik nog att enforcea våra standarder:
# modules/cloud-function/main.tf
resource "google_cloudfunctions2_function" "function" {
name = var.function_name
location = var.region
description = var.description
project = var.project_id
build_config {
runtime = var.runtime
entry_point = var.entry_point
source {
storage_source {
bucket = var.source_bucket
object = var.source_object
}
}
}
service_config {
max_instance_count = var.max_instances
min_instance_count = var.min_instances
available_memory = var.memory
timeout_seconds = var.timeout
service_account_email = var.service_account
environment_variables = merge(
var.environment_variables,
{
SENTRY_DSN = var.sentry_dsn
ENVIRONMENT = var.environment
FUNCTION_NAME = var.function_name
}
)
}
labels = merge(var.labels, {
managed_by = "terraform"
environment = var.environment
team = var.team
})
}
# Alltid skapa en alert policy för felprocent
resource "google_monitoring_alert_policy" "error_rate" {
display_name = "${var.function_name} error rate"
project = var.project_id
combiner = "OR"
conditions {
display_name = "Error rate > 5%"
condition_threshold {
filter = <<-EOT
resource.type = "cloud_function"
AND resource.labels.function_name = "${var.function_name}"
AND metric.type = "cloudfunctions.googleapis.com/function/execution_count"
AND metric.labels.status != "ok"
EOT
duration = "300s"
comparison = "COMPARISON_GT"
threshold_value = 0.05
}
}
notification_channels = var.notification_channels
}Notera att modulen automatiskt skapar monitoring för varje Cloud Function. Det är en av våra hårda regler: ingen resurs får deployas utan monitoring. Genom att bygga in det i modulen behövs ingen manuell konfiguration.
# Användning av modulen i produktionsmiljön
module "ingest_bolagsregister" {
source = "../../../modules/cloud-function"
function_name = "ingest-bolagsregister"
project_id = var.project_id
region = "europe-north1"
runtime = "nodejs20"
entry_point = "handler"
memory = "1Gi"
timeout = 540
max_instances = 5
min_instances = 0
environment = "production"
team = "data-platform"
environment_variables = {
BQ_DATASET = module.raw_dataset.dataset_id
BQ_TABLE = "stg_bolagsregister"
PROXY_POOL = "proxytjänst-residential-se"
BATCH_SIZE = "500"
}
service_account = module.sa_data_ingestion.email
sentry_dsn = var.sentry_dsn
notification_channels = [var.slack_channel_id]
source_bucket = var.functions_bucket
source_object = "ingest-bolagsregister-v12.zip"
}
# Samma modul för alla andra ingest-funktioner
module "ingest_företagskatalog" {
source = "../../../modules/cloud-function"
# ... (samma struktur, andra parametrar)
}
module "ingest_bolagsverket" {
source = "../../../modules/cloud-function"
# ...
}Med den här strukturen kan vi lägga till en ny Cloud Function på under 5 minuter. Vi kopierar ett modulanrop, ändrar parametrarna, och kör apply. Alla standarder - monitoring, labels, Sentry-integration, service account - följer automatiskt med.
03State Management
State-hantering är den största utmaningen med Terraform i stor skala. En enda state-fil med 200+ resurser är en tickande bomb. Vi löste det genom att splitta vår state i 12 oberoende workspaces, med tydliga gränser baserade på domänansvaret.
Vår strategi för state-uppdelning följer principen att resurser som ändras tillsammans ska dela state, medan resurser med olika livscykler ska vara separerade. Nätverksinfrastruktur ändras kanske en gång i kvartalet, medan Cloud Functions uppdateras dagligen. Att ha dem i samma state skapar onödig risk.
GCS Bucket: enterprise-tf-state
├── production/
│ ├── networking.tfstate (22 resurser, ändras sällan)
│ ├── data-platform.tfstate (68 resurser, ändras ofta)
│ ├── compute.tfstate (34 resurser, ändras veckovis)
│ ├── security.tfstate (41 resurser, ändras sällan)
│ └── monitoring.tfstate (28 resurser, ändras veckovis)
├── staging/
│ ├── networking.tfstate
│ ├── data-platform.tfstate
│ └── compute.tfstate
├── development/
│ └── all.tfstate (48 resurser, en state räcker)
└── shared/
└── global.tfstate (6 resurser: DNS, billing, org policies)Varje state-fil har en egen backend-konfiguration som pekar på samma GCS-bucket men med unika prefix. Vi använder state locking via GCS:s inbyggda mekanismer, vilket innebär att två utvecklare aldrig kan köra apply mot samma state samtidigt.
terraform {
backend "gcs" {
bucket = "enterprise-tf-state"
prefix = "production/data-platform"
}
required_version = ">= 1.7.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.20"
}
}
}För att dela data mellan state-filer använder vi terraform_remote_state data sources. Nätverkslayern exporterar VPC-ID:n och subnet-ID:n, som sedan konsumeras av compute och data-platform lagren. Det skapar en explicit dependency chain utan att riskera att en ändring i ett lager påverkar ett annat.
# I data-platform/main.tf - läsa nätverksinformation
data "terraform_remote_state" "networking" {
backend = "gcs"
config = {
bucket = "enterprise-tf-state"
prefix = "production/networking"
}
}
# Använd output från nätverkslayern
resource "google_compute_network_endpoint_group" "functions" {
network = data.terraform_remote_state.networking.outputs.vpc_id
subnetwork = data.terraform_remote_state.networking.outputs.functions_subnet_id
# ...
}Resultatet av state-uppdelningen var dramatisk. Plan-tider gick från 8+ minuter till under 30 sekunder för de flesta workspaces. Lock-konflikter försvann nästan helt eftersom två utvecklare sällan behöver modifiera samma workspace samtidigt. Och risken för catastrophic changes minskade proportionellt med state-storleken.
04CI/CD-integration
Att köra Terraform manuellt från en utvecklares maskin är en risk. Du har inga kodgranskningar, ingen audit trail, och ingen konsistens. Vi byggde en fullständig CI/CD-pipeline med GitHub Actions som hanterar hela Terraform-livscykeln: validering, plan, approval och apply.
Vår pipeline har tre steg. Först körs validering automatiskt på varje pull request: terraform validate, tflint för stil och best practices, checkov för säkerhet, och terraform plan för att visa vad som kommer att ändras. Resultatet postas som en kommentar på PR:en så att granskaren ser exakt vilka ändringar som kommer att göras.
name: Terraform CI/CD
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
env:
TF_VERSION: "1.7.4"
GOOGLE_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
workspaces: ${{ steps.changes.outputs.workspaces }}
steps:
- uses: actions/checkout@v4
- id: changes
run: |
# Hitta vilka workspaces som ändrats
CHANGED=$(git diff --name-only HEAD~1 | \
grep "^infrastructure/environments/" | \
cut -d'/' -f3,4 | sort -u | jq -R . | jq -s .)
echo "workspaces=$CHANGED" >> $GITHUB_OUTPUT
validate:
needs: detect-changes
runs-on: ubuntu-latest
strategy:
matrix:
workspace: ${{ fromJson(needs.detect-changes.outputs.workspaces) }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: infrastructure/environments/${{ matrix.workspace }}
run: terraform init -backend=false
- name: Terraform Validate
working-directory: infrastructure/environments/${{ matrix.workspace }}
run: terraform validate
- name: TFLint
run: |
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
cd infrastructure/environments/${{ matrix.workspace }}
tflint --init && tflint
- name: Checkov Security Scan
uses: bridgecrewio/checkov-action@master
with:
directory: infrastructure/environments/${{ matrix.workspace }}
framework: terraform
plan:
needs: [detect-changes, validate]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
strategy:
matrix:
workspace: ${{ fromJson(needs.detect-changes.outputs.workspaces) }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Plan
working-directory: infrastructure/environments/${{ matrix.workspace }}
run: |
terraform init
terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt
- name: Kommentera på PR
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync(
'infrastructure/environments/${{ matrix.workspace }}/plan.txt', 'utf8'
);
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## Terraform Plan: ${{ matrix.workspace }}\n\`\`\`\n' + plan + '\n\`\`\`'
});
apply:
needs: [detect-changes, validate]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
strategy:
matrix:
workspace: ${{ fromJson(needs.detect-changes.outputs.workspaces) }}
max-parallel: 1 # En workspace åt gången!
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Apply
working-directory: infrastructure/environments/${{ matrix.workspace }}
run: |
terraform init
terraform apply -auto-approveEn kritisk detalj är max-parallel: 1 i apply-steget. Vi kör aldrig parallella applies mot produktionsmiljön. Om networking-layern ändras och compute-layern beror på den, måste networking vara klar först. Sekventiell execution tar längre tid men eliminerar race conditions.
Vi har också ett bash-script som utvecklare använder lokalt för att köra plan mot rätt workspace utan att behöva komma ihåg exakta sökvägar:
#!/bin/bash
# Interaktivt script för att köra terraform plan
# Användning: ./scripts/tf-plan.sh [environment] [workspace]
ENV=${1:-production}
WORKSPACE=${2:-}
if [ -z "$WORKSPACE" ]; then
echo "Tillgängliga workspaces för $ENV:"
ls -1 infrastructure/environments/$ENV/
read -p "Välj workspace: " WORKSPACE
fi
DIR="infrastructure/environments/$ENV/$WORKSPACE"
if [ ! -d "$DIR" ]; then
echo "Workspace finns inte: $DIR"
exit 1
fi
echo ">>> Initierar Terraform i $DIR..."
cd "$DIR"
terraform init -upgrade
echo ">>> Kör plan..."
terraform plan -out=tfplan
echo ""
echo "För att applicera, kör:"
echo " cd $DIR && terraform apply tfplan"Pipeline-statistik sedan vi implementerade CI/CD: 0 manuella production-applies (allt går genom PR), 100% av ändringar granskade för merge, medelplan-tid ner till 24 sekunder, och noll incidents orsakade av infrastrukturändringar de senaste 4 månaderna.
05Team-arbetsfloden
Teknik är bara halva lösningen. Utan tydliga arbetsflöden och ägarskap kommer även den bäst strukturerade Terraform-kodbasen att degenerera. Vi införde tre huvudregler för hur teamet jobbar med infrastruktur.
Regel 1: En PR, ett workspace
Varje pull request får bara modifiera resurser i ett workspace. Om du behöver ändra både networking och compute skapar du två separata PR:s. Det känns omotiverat vid första anblicken, men det gör kodgranskning drastiskt enklare och minskar risken för oavsiktliga beroenden.
Regel 2: Inga manuella ändringar
All infrastruktur måste definieras i Terraform. Om du gör en manuell ändring i GCP Console måste du också lägga till den i Terraform och köra import. Vi kör terraform plan i ett cronjobb varje natt och skickar Slack-notiser om det finns drift.
Regel 3: Ägarskap per workspace
Varje workspace har en tydlig ägare (team eller individ) definierad i en CODEOWNERS-fil. Nätverkslayern ägs av platform-teamet. Data-platform ägs av data-teamet. Det innebär att rätt personer granskar rätt ändringar utan att behöva bli expert på allt.
Vi använder också Terraform-specifika kodgranskningsriktlinjer. Granskaren behöver inte bara kontrollera att koden är korrekt utan också att plan-outputen ser rimlig ut. Vi har sett fall där koden ser perfekt ut men planet visar att den kommer att ta bort och återskapa en databas - något som kodgranskning ensam aldrig skulle fånga.
# Infrastructure ownership
infrastructure/modules/ @platform-team
infrastructure/environments/*/networking/ @platform-team
infrastructure/environments/*/compute/ @platform-team
infrastructure/environments/*/data-platform/ @data-team
infrastructure/environments/*/security/ @security-team @platform-team
infrastructure/shared/ @platform-teamOnboarding av nya teammedlemmar blev också enklare. Istället för att behöva förstå hela infrastrukturen behöver en ny utvecklare i data-teamet bara förstå data-platform workspace och de moduler det använder. Det reducerar time-to-productivity från veckor till dagar.
06Lardomar
Efter att ha migrerat från en monolitisk Terraform-setup till den modulära strukturen beskrivs ovan, har vi dragit följande slutsatser:
Börja med state-uppdelning, inte modularisering
Vi gjorde misstaget att modularisera koden först och splitta state sen. Det skapade en svårjobbad migrationsperiod där moduler refererade till resurser i fel state. Börja med att bryta ut state-filer baserat på ändringshastighet, och modularisera koden inom varje state-gräns.
Terraform import är ditt bästa verktyg vid migrering
När vi splittade vår state använde vi terraform state mv och terraform import flitigt. Nyckelinsikten var att alltid köra en full plan efter varje state-ändring för att verifiera att Terraform inte vill förstöra och återskapa resurser.
Investera i lokal utvecklarupplevelse
Om det är jobbigt att köra plan lokalt kommer utvecklare att skippa det och pusha direkt. Vi byggde hjälp-scripts, alias, och dokumentation som gör det lätt att jobba med rätt workspace. Det lönar sig hundra gånger om.
Versionera moduler internt
Vi började med att referera till moduler via relativa sökvägar, men det innebar att en ändring i en modul påverkade alla användare omedelbart. Nu använder vi git tags för modulversioner och uppgraderar workspace för workspace, precis som man gör med externa moduler.
Drift-detektion är lika viktigt som CI/CD
Även med strikta processer skapas ibland manuella ändringar i konsolen. Vår nattliga drift-check kör plan mot alla workspaces och larmar på Slack om det finns differenser. Det har fångat allt från manuella brandväggsregler till GCP:s automatiska API-aktiveringar.
“Den största risken med Terraform i stor skala är inte teknisk - den är organisatorisk. Utan tydliga ägare, processer och automatisering kommer även den bäst strukturerade kodbasen att degenerera.”
- Simon Axelsson
Vår Terraform-setup hanterar nu 247 resurser över 12 workspaces i 3 miljöer. Nya resurser läggs till dagligen utan att det skapar friktion. Planer är snabba, reviews är meningsfulla, och ingen är rädd för att köra apply längre. Det krävde en betydande investering att komma hit - ungefär 3 veckors fokuserat arbete för migreringen - men det lönar sig varje dag.
Simon Axelsson
IT-konsult & teknisk rådgivare
Simon Axelsson är senior IT-konsult och grundare av SIAX Technology AB i Stockholm. Han hjälper nordiska företag med molninfrastruktur, dataplattformar och AI-automation. Han bygger system som omvandlar komplex molninfrastruktur till pålitliga, självbetjänande plattformar.