Performance Optimization #
Slow Terraform isn’t just a convenience problem — a CI/CD pipeline waiting 20 minutes for one plan slows down the entire team workflow. Most Terraform performance problems come from one source: too many API calls to the cloud provider during state refresh. Understanding where time is spent and how to reduce it — without sacrificing reliability — is an essential skill for teams managing large infrastructure.
flowchart TD
A["terraform plan"] --> B["Refresh state\n(API calls)"]
B --> C["Compare with\nconfig"]
C --> D["Generate plan"]
B --> E["100 resources\n= 100+ API calls"]
E --> F["Slow plan\n~20 min"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#f59e0b,stroke:#d97706,color:#fff
style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
style D fill:#10b981,stroke:#059669,color:#fff
style E fill:#ef4444,stroke:#dc2626,color:#fff
style F fill:#ef4444,stroke:#dc2626,color:#fffMeasure Before Optimizing #
# Always measure before optimizing — don't assume where the bottleneck is
# Enable detailed logging
TF_LOG=INFO terraform plan 2>&1 | grep -E "provider|Refreshing|Reading"
# Or use timestamps for each phase
time terraform init
time terraform plan
time terraform apply
# For more detailed profiling (Terraform 1.2+)
TF_LOG=TRACE terraform plan 2>&1 | grep "provider.aws" | tail -50
# This shows all API calls to the AWS provider
# Output to watch for:
# [INFO] provider.aws: Refreshing state... (aws_instance.web)
# → Each line like this is one API call
# → If there are hundreds of lines, this is the source of slowness
-refresh=false: Skipping State Refresh #
# ANTI-PATTERN: Always refreshing (the default)
terraform plan
# → Terraform calls the cloud API for every resource in state
# → 200 resources = 200 API calls before the plan starts
# → Can take minutes if there are many resources
# CORRECT: Skip refresh if the state is known to be accurate
terraform plan -refresh=false
# → Directly compare the configuration with the existing state
# → Much faster, but doesn't detect drift
# When -refresh=false is SAFE to use:
# ✓ CI/CD pipelines where nobody can change infrastructure outside Terraform
# ✓ During fast iteration while developing a new configuration
# ✓ Right after an apply was just run (state is definitely accurate)
# When -refresh=false is NOT SAFE:
# ✗ If there's a possibility of manual changes in the cloud
# ✗ For drift detection (that's exactly when refresh is needed)
# ✗ Before applying to production that hasn't been planned in a long time
Parallelism: Controlling Concurrency #
Terraform by default runs a maximum of 10 operations concurrently. For independent resources, raising this number can speed up applies. But too high can cause API throttling from the cloud provider.
# The default parallelism is 10
terraform apply
# Raise it for infrastructure with many independent resources
terraform apply -parallelism=20
# Lower it if you often hit API rate limits
terraform apply -parallelism=5
# For AWS: rate limits differ per service
# EC2: fairly high (parallelism 20+ is fine)
# IAM: stricter (stay at 5-10)
# CloudFormation: very strict (drop to 3-5)
# Find the optimal value by experimenting:
for p in 5 10 15 20; do
echo "Testing parallelism=$p"
time terraform apply -parallelism=$p -auto-approve
done
Optimizing Data Sources #
Unnecessary data sources are one of the hidden causes of slow plans — every data source produces at least one API call.
# ANTI-PATTERN: Data sources called too often
# Each resource calls the data source separately
resource "aws_instance" "web_1" {
ami = data.aws_ami.ubuntu.id # 1 API call
}
resource "aws_instance" "web_2" {
ami = data.aws_ami.ubuntu.id # The same API call again (but Terraform caches it)
}
# Terraform automatically caches identical data sources — this is actually OK
# What's TRULY problematic: data sources with heavy filters
data "aws_instances" "all_running" {
filter {
name = "instance-state-name"
values = ["running"]
}
# This lists ALL running instances → expensive if there are thousands
}
# CORRECT: Use more specific filters
data "aws_instances" "web_fleet" {
filter {
name = "tag:Role"
values = ["web-server"]
}
filter {
name = "tag:Environment"
values = ["production"]
}
# Tight filters → lighter API calls
}
# ANTI-PATTERN: Data sources for already-known values
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
# If the region and account ID are known and don't change,
# it's more efficient to use a variable or local
locals {
region = "ap-southeast-1"
account_id = "123456789012"
}
# Data sources remain useful for truly dynamic values
# (latest AMI, available AZs, etc.)
-target: Specific Operations Only #
-target lets you run a plan or apply on specific resources only, ignoring the others.
# Plan only specific resources
terraform plan -target=aws_instance.web
terraform plan -target=module.database
# Apply only specific resources
terraform apply -target=aws_security_group.new_rule
# Useful for:
# ✓ Debugging — isolate a problematic resource
# ✓ Bootstrapping — create needed resources before others
# ✓ Emergency fixes — fix one resource without risking others
-targetis a tool for special situations, not routine workflows. Repeated use can cause inconsistent state — Terraform’s dependency graph isn’t fully executed, so changes to the targeted resource may not propagate to resources depending on it. After using-target, always runterraform planwithout a target to make sure nothing is left behind.
Provider Cache #
Every time terraform init runs, Terraform downloads the provider. In frequently run CI/CD, this consumes time and bandwidth.
# Set up a provider cache (in CI/CD or locally)
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p $TF_PLUGIN_CACHE_DIR
# Now terraform init will check the cache before downloading
terraform init
# If the provider is already cached → use it directly, no re-download
# In GitHub Actions: cache the provider with actions/cache
- name: Cache Terraform Plugins
uses: actions/cache@v4
with:
path: ~/.terraform.d/plugin-cache
key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-
- name: Terraform Init
run: terraform init
env:
TF_PLUGIN_CACHE_DIR: ~/.terraform.d/plugin-cache
Optimizing terraform init #
# ANTI-PATTERN: terraform init that re-downloads every time
terraform init # Always downloads from the registry
# CORRECT: Use -upgrade only when you actually need a provider upgrade
terraform init # Use the already locked versions
terraform init -upgrade # Only when upgrading to the latest versions
# For CI/CD: skip backend init if not needed
terraform init -backend=false # For validation only, no state access
# Typical benchmark times:
# terraform init without cache: 30-60 seconds (provider download)
# terraform init with cache: 2-5 seconds (copy from cache)
# This difference is significant if the pipeline runs dozens of times a day
flowchart LR
A["Split\nstate"] --> B["-target\nspecific"]
B --> C["Parallel\nexecution"]
C --> D["Caching\nprovider"]
D --> E["Fast plan\n~2 min"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#3b82f6,stroke:#1e40af,color:#fff
style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
style D fill:#8b5cf6,stroke:#6d28d9,color:#fff
style E fill:#10b981,stroke:#059669,color:#fffParallelism Tuning #
Terraform can create resources in parallel. Adjusting parallelism can speed up applies.
# Default: 10 parallel resources
terraform apply
# Raise it for large infrastructure
terraform apply -parallelism=20
# Lower it if the API is rate-limited
terraform apply -parallelism=3
# For providers sensitive to concurrent requests
# (for example: some SaaS APIs)
terraform apply -parallelism=1
# Monitoring execution times
time terraform plan
time terraform apply -auto-approve
# Terraform trace logging for performance debugging
TF_LOG=TRACE terraform plan 2>&1 | tee trace.log
# Look for "applyable" to see which resources are slow
Provider Caching #
# Enable provider plugin caching to speed up init
# ~/.terraformrc:
# provider_cache {
# dir = "$HOME/.terraform.d/plugin-cache"
# }
# Or set it via an environment variable
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
terraform init
# The provider is only downloaded once, other workspaces use the cache
# Clean the cache if needed
rm -rf $TF_PLUGIN_CACHE_DIR/*
# Use efficient data sources
# BAD: Fetching all instances
data "aws_instances" "all" {}
# GOOD: Specific filters
data "aws_instances" "web" {
filter {
name = "tag:Role"
values = ["web"]
}
}
Plan Caching #
# Terraform doesn't cache plans natively
# But there are several strategies to speed things up:
# 1. Save the plan output and apply later
terraform plan -out=plan.cache
terraform apply plan.cache
# Avoids re-planning which can take time
# 2. Targeted refresh (only changed resources)
terraform plan -target=aws_instance.web
# Useful during development, NOT in production
# 3. Reduce data source calls
# Reduce the number of data sources, use locals caching
# 4. Parallel backend initialization
# terraform init can download providers in parallel
terraform init -plugin-dir=/shared/plugin-cache
Provider Plugin Caching #
# Cache provider plugins to speed up init
mkdir -p ~/.terraform.d/plugin-cache
# ~/.terraformrc
# plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"
# Or set via an environment variable
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
# In CI/CD: persist the cache between runs
# GitHub Actions:
- uses: actions/cache@v3
with:
path: ~/.terraform.d/plugin-cache
key: terraform-providers-${{ hashFiles('.terraform.lock.hcl') }}
# Shared plugin cache for the team
# Store it on a shared filesystem or S3
terraform init -plugin-dir=/shared/terraform/plugins
Summary #
- Measure first, optimize later — enable
TF_LOG=INFOto see which API calls take the most time before deciding on an optimization approach.-refresh=falseis the biggest optimization for tightly controlled CI/CD — skipping hundreds of refresh API calls can cut plan time from minutes to seconds.- Parallelism can be raised from the default 10 for large infrastructure, but lower it if you often hit cloud provider API rate limits.
- Data sources with loose filters are a hidden bottleneck — filter as tightly as possible so API responses are smaller and faster.
- Provider caches cut
terraform inittime from 30-60 seconds to 2-5 seconds — a must for frequently run CI/CD.-targetfor special situations only — after using it, always run a full plan to make sure no inconsistency is left behind.
← Previous: Scale Terraform Next: Anti-Pattern: Terraform as CM →