Hoppa till innehåll
InfrastructureTerraformIaC

Terraform i stor skala: Hantera 200+ resurser utan att tappa kontrollen

Praktiska strategier för att strukturera, modularisera och automatisera Terraform-infrastruktur i produktionsmiljö

Simon Axelsson

Simon Axelsson

IT-konsult & teknisk rådgivare

2026-04-0222 min8 74067

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.

Plan-tid
8+ min
State-storlek
47 MB
Blast radius
247 resurser
Lock-konflikter
~3/vecka

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.

Mappstruktur
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, validering

Varje 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
# 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.

environments/production/data-platform/main.tf
# 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.

State-arkitektur

  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.

environments/production/data-platform/backend.tf
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.

Läsa från annan state
# 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.

.github/workflows/terraform.yml
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-approve

En 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:

scripts/tf-plan.sh
#!/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.

CODEOWNERS
# 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-team

Onboarding 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
Plan-tid
8+ min24 sek
-95%
Lock-konflikter
3/vecka0/vecka
-100%
Deploy-tid
45 min8 min
-82%
Incidents
2/månad0 / 4 mån
-100%

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.

TerraformIaCGCPDevOpsCI/CDState ManagementModulariseringGitHub Actions
Simon Axelsson

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.