Design a Terraform-Style Infrastructure as Code Platform
Building a complete IaC engine from scratch — state management, resource graphs, provider plugins, plan/apply workflows, policy as code, and multi-cloud orchestration
Table of Contents
- Introduction — The Infrastructure as Code Landscape
- IaC Evolution — From Scripts to Declarative Engines
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model — State as the Source of Truth
- High-Level Architecture
- API Design
- HCL Language & Parser Design
- Provider Plugin System
- Resource Graph & Dependency Resolution
- Plan & Apply Workflow
- State Management & Remote Backends
- Drift Detection & Reconciliation
- Module Registry & Reusability
- Workspaces & Environment Isolation
- Import & Migration Strategies
- Sentinel — Policy as Code Framework
- Terraform Cloud & Enterprise Architecture
- Multi-Cloud Support & Abstraction
- Testing with Terratest
- Security Scanning with tfsec
- Cost Estimation with Infracost
- Collaboration & VCS Integration
- Monitoring & Observability
- Interview Q&A
1. Introduction — The Infrastructure as Code Landscape
Infrastructure as Code (IaC) is the practice of managing and provisioning computing infrastructure through machine-readable configuration files rather than through manual processes or interactive configuration tools. Terraform, created by HashiCorp in 2014, became the de facto standard for IaC by introducing a declarative language called HCL (HashiCorp Configuration Language), a provider plugin architecture that supports virtually every cloud and SaaS platform, and a state management system that tracks the mapping between configuration and real-world resources. Today Terraform manages millions of resources across thousands of organizations, with registries hosting over 4,000 providers and 10,000 community modules.
Building a Terraform-style IaC platform from scratch is one of the most challenging and rewarding systems design exercises because it touches every layer of the stack: language parsing and AST transformation, directed acyclic graph construction and topological sorting, concurrent resource orchestration across heterogeneous APIs, distributed state management with locking and versioning, and a plugin system that must remain stable while supporting rapid community contribution. This guide walks through every major subsystem, provides production-quality C# implementation code, and covers the advanced topics that distinguish a toy project from a platform that can manage real infrastructure at scale.
The IaC market is projected to reach 4.5 billion dollars by 2028, driven by cloud adoption, platform engineering, and the need for repeatable, auditable infrastructure. Companies like Google Cloud, AWS, and Microsoft Azure all provide first-class Terraform providers, and the OpenTofu fork ensures that the open-source IaC ecosystem remains vibrant. Understanding the internals of how these tools work positions you to make better architectural decisions, contribute to open source, and build the next generation of infrastructure tooling.
This guide is structured as a complete design document. We begin with the evolution of infrastructure automation, define comprehensive requirements and capacity estimates, design the state data model, build the core engine subsystems including the parser, graph builder, plan engine, and apply scheduler, then extend outward to provider plugins, module registries, workspaces, policy frameworks, and operational concerns like drift detection, security scanning, and cost estimation. Each section includes production-quality C# code, architecture diagrams, comparison tables, and practical guidance drawn from managing infrastructure at scale.
2. IaC Evolution — From Scripts to Declarative Engines
The history of infrastructure automation follows a clear progression from imperative shell scripts to fully declarative, graph-based engines. Each generation solved real problems introduced by its predecessor while adding new capabilities and complexity. Understanding this evolution is essential for appreciating why modern IaC platforms are designed the way they are.
Generation 1: Imperative Scripts (2000s)
The earliest infrastructure automation consisted of Bash scripts, Perl one-liners, and Makefiles that ran commands against servers. Scripts were easy to write but extremely fragile. They had no concept of desired state — they could only execute commands, not describe what the end result should look like. Running the same script twice would fail on the second run because resources already existed. Ordering was implicit in the script line order, and error handling was primitive at best.
Generation 2: Configuration Management (2009–2013)
Chef, Puppet, and Ansible introduced the concept of desired state configuration. You described the desired state of your servers, and the tool figured out the steps to get there. These tools introduced idempotency — running them multiple times produced the same result. However, they primarily focused on configuration within servers (installing packages, managing files, starting services) rather than provisioning the servers themselves. They also used an imperative or procedural model under the hood, processing recipes or playbooks sequentially.
Generation 3: Declarative Cloud Orchestration (2014–present)
Terraform, CloudFormation, and Pulumi represented a paradigm shift. You declare what you want, not how to get there. The engine builds a dependency graph, determines the execution order, and executes resource operations concurrently where possible. Terraform key innovation was cloud-agnostic declarative infrastructure with a provider plugin model. Pulumi brought general-purpose programming languages to IaC. CloudFormation tightly integrated with AWS but lacked multi-cloud support.
Key Design Principles from the Evolution
| Principle | Script Era | Config Mgmt | Declarative IaC |
|---|---|---|---|
| Model | Imperative | Procedural/Declarative | Fully Declarative |
| Idempotency | Manual | Built-in | Mathematical |
| State Tracking | None | Optional | Mandatory |
| Dependency Mgmt | Script order | Explicit | Graph-based |
| Concurrency | Sequential | Limited | Parallel by default |
| Extensibility | Custom scripts | Modules | Provider plugins |
The transition from generation 2 to generation 3 was driven by the cloud revolution. As organizations moved from managing their own servers to consuming cloud services, they needed tools that could provision VPCs, load balancers, databases, and serverless functions — not just configure operating systems. Terraform filled this gap with its provider model, where each cloud platform is represented by a plugin that knows how to create, read, update, and delete that platform resources through its API. This abstraction layer meant that the same tool, the same language, and the same workflow could be used across AWS, Azure, GCP, and hundreds of other platforms.
The fourth generation is emerging now, driven by platform engineering teams that build internal developer platforms on top of Terraform. These platforms add self-service portals, automated policy enforcement, cost governance, and AI-assisted configuration. They treat infrastructure as a product with SLAs, documentation, and feedback loops. Understanding the full evolution helps us see where the industry is heading and what capabilities the next generation of tools will need.
3. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | HCL Parsing | Must | Parse HashiCorp Configuration Language into AST |
| F2 | Resource Graph | Must | Build DAG from resource dependencies |
| F3 | Plan Generation | Must | Produce execution plan showing create/update/delete |
| F4 | Apply Execution | Must | Execute plan with concurrency and error handling |
| F5 | State Management | Must | Persist and version infrastructure state |
| F6 | Provider Plugins | Must | Extensible provider system for cloud APIs |
| F7 | Module System | Should | Reusable, versioned infrastructure components |
| F8 | Workspaces | Should | Multiple state files for environment isolation |
| F9 | Import Existing | Should | Bring existing resources under management |
| F10 | Policy Enforcement | Should | Admission control policies before apply |
| F11 | Cost Estimation | Nice | Pre-apply cost estimation and budget alerts |
| F12 | Drift Detection | Nice | Detect manual changes to managed resources |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Plan latency | < 30s for 500 resources | Developer productivity and fast feedback loops |
| Apply throughput | 50 resources/minute | Limited by cloud API rate limits |
| State size | Up to 100 MB | Enterprise environments with thousands of resources |
| Concurrent operations | 10 parallel applies | Team environments with multiple workspaces |
| State durability | 99.999999999% | State loss means infrastructure orphaning |
| Plugin isolation | Process-level | Crashing a provider must not affect the engine |
| Schema backward compat | 2 major versions | Providers evolve without breaking existing configs |
The requirements above reflect real-world production needs. The Must priorities represent the minimum viable platform: without HCL parsing, the engine cannot read configurations; without a resource graph, it cannot determine execution order; without plan generation, it cannot show users what will change; without apply execution, it cannot make any changes; without state management, it cannot track what it manages; and without provider plugins, it cannot interact with any cloud platform. The Should requirements add features that enterprise teams need but can be implemented incrementally. The Nice-to-have features enhance the developer experience and operational maturity of the platform.
4. Capacity Estimation & Back-of-Envelope
Workload Profile
Consider a mid-size platform engineering team managing 10,000 resources across 3 cloud providers with 50 engineers performing 200 plan/apply operations per day. Each resource has approximately 8 attributes tracked in state, giving a total state size of roughly 3.2 MB. The plan phase must read current state, fetch remote resource attributes via provider APIs, and compute diffs. The apply phase must execute CRUD operations against cloud APIs with appropriate retry and rate limiting.
Resource Estimates
| Metric | Estimate | Calculation |
|---|---|---|
| Resources managed | 10,000 | Typical mid-size organization |
| State file size | 3.2 MB | 10K resources x 8 attrs x 40 bytes |
| Plan operations per day | 200 | 200 plans plus 80 applies |
| Plan API calls | 80,000 | 200 plans x 400 average resource reads |
| Apply API calls per day | 24,000 | 80 applies x 300 average mutations |
| State storage per month | 9.6 GB | 3.2 MB x 3 versions per day x 100 days |
| Network bandwidth | 50 Mbps | 80K API calls x 10KB average response |
| Concurrent DAG workers | 16 | CPU cores for parallel resource operations |
Latency Budget Breakdown
| Phase | Target | Bottleneck |
|---|---|---|
| HCL Parse | < 200ms | File I/O and lexer/parser |
| Graph Build | < 500ms | Reference resolution and cycle detection |
| State Refresh | < 15s | Concurrent cloud API reads |
| Diff Computation | < 1s | Schema-aware attribute comparison |
| Plan Render | < 500ms | Human-readable output formatting |
| Apply Execution | 30s to 30min | Cloud API latency and resource count |
| State Write | < 2s | Backend upload and locking |
The state refresh phase is typically the longest part of plan because it must make API calls to every cloud provider to verify the current state of each managed resource. This is why Terraform supports targeted plans and refresh-only modes — they allow operators to reduce the scope of API calls when only a subset of resources needs attention. The apply phase duration depends entirely on the number of resource changes and the speed of the target cloud APIs. AWS, Azure, and GCP all impose API rate limits that can slow down large applies, which is why the engine must implement exponential backoff, jitter, and request queuing per provider.
State storage costs are often underestimated. At 3.2 MB per state file with versioning enabled and backups, a team performing 3 plan/apply cycles per day will generate approximately 9.6 GB of state storage per month. Over a year, this accumulates to over 115 GB. S3 standard storage at 0.023 dollars per GB per month costs about 2.65 dollars per year, but S3 Intelligent-Tiering and lifecycle policies can reduce this further. The real cost is not storage but the compute and network resources consumed during state refresh and apply operations.
5. Data Model — State as the Source of Truth
The state file is the most critical data structure in an IaC platform. It bridges the gap between the desired state declared in configuration files and the actual state of real-world infrastructure. Without state, the engine cannot determine which resources already exist, which need to be created, updated, or destroyed, or what attributes are managed versus unmanaged. Designing a robust state data model is the foundation of everything else.
State File Structure
json{
"version": 4,
"terraform_version": "1.8.0",
"serial": 42,
"lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"outputs": {
"vpc_id": {
"value": "vpc-0abc1234def567890",
"type": "string"
},
"subnet_cidrs": {
"value": ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"],
"type": "list(string)"
}
},
"resources": [
{
"module": "module.vpc",
"mode": "managed",
"type": "aws_vpc",
"name": "main",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"index_key": null,
"schema_version": 0,
"attributes": {
"id": "vpc-0abc1234def567890",
"cidr_block": "10.0.0.0/16",
"enable_dns_support": true,
"enable_dns_hostnames": true,
"tags": {
"Name": "production-vpc",
"Environment": "prod"
},
"arn": "arn:aws:ec2:us-east-1:123456789012:vpc/vpc-0abc1234def567890"
},
"attributes_sensitive": {},
"dependencies": []
}
]
}
]
}
State Entity Model
csharppublic class TerraformState
{
public int Version { get; set; } = 4;
public string TerraformVersion { get; set; }
public long Serial { get; set; }
public Guid Lineage { get; set; } = Guid.NewGuid();
public Dictionary<string, OutputValue> Outputs { get; set; } = new();
public List<ResourceState> Resources { get; set; } = new();
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
}
public class ResourceState
{
public string Module { get; set; }
public ResourceMode Mode { get; set; }
public string Type { get; set; }
public string Name { get; set; }
public string Provider { get; set; }
public List<ResourceInstance> Instances { get; set; } = new();
}
public class ResourceInstance
{
public object IndexKey { get; set; }
public int SchemaVersion { get; set; }
public Dictionary<string, object> Attributes { get; set; } = new();
public HashSet<string> SensitiveAttributes { get; set; } = new();
public List<ResourceDependency> Dependencies { get; set; } = new();
public string PrivateKey { get; set; }
public string ProviderType { get; set; }
}
public enum ResourceMode { Managed, Data }
public class OutputValue
{
public object Value { get; set; }
public string Type { get; set; }
public bool Sensitive { get; set; }
}
public class ResourceDependency
{
public string ResourceType { get; set; }
public string ResourceName { get; set; }
public string Module { get; set; }
}
State Versioning and Lineage
Every state file has a monotonically increasing serial number and a unique lineage UUID. The serial prevents concurrent writes from overwriting each other — if the serial in the backend does not match the serial the client last read, the write is rejected. The lineage is used to detect state file corruption or accidental replacement. When creating a new state from a backup or during state migration, the lineage remains the same but the serial increments. Backends like S3, Consul, and Terraform Cloud enforce these invariants through atomic compare-and-swap operations.
The state model must also handle sensitive values. Some resource attributes contain secrets like database passwords, API keys, or TLS private keys. These must be encrypted at rest in the state file and never logged or displayed in plan output. Terraform marks sensitive attributes in the state file and masks them during plan and apply output. The engine must track which attributes are sensitive using the schema information from the provider and propagate this sensitivity through the entire pipeline.
6. High-Level Architecture
A Terraform-style IaC platform consists of six major subsystems that collaborate to transform declarative configuration into managed infrastructure. Understanding how these subsystems interact is essential for building a robust and extensible platform.
Subsystem Responsibilities
| Subsystem | Responsibility | Key Interfaces |
|---|---|---|
| Parser | Tokenize and parse HCL into AST | IHclParser, AstNode |
| Graph Builder | Resolve references, build DAG | IDependencyResolver, ResourceGraph |
| Plan Engine | Compare desired vs actual state | IPlanEngine, ExecutionPlan |
| Apply Scheduler | Orchestrate concurrent resource ops | IApplyScheduler, IWorkerPool |
| State Manager | Read/write/lock state files | IStateBackend, IStateLock |
| Plugin System | Manage provider lifecycle | IProvider, IProviderFactory |
The architecture follows a pipeline pattern where data flows from left to right through the subsystems. Configuration enters through the parser, becomes an AST, gets resolved into a dependency graph, is compared against state to produce a plan, and then the plan is executed by the apply scheduler. Each subsystem is decoupled from the others through well-defined interfaces, which enables independent testing, alternative implementations, and clean separation of concerns. The state layer sits in the middle because it is consulted during both plan and apply phases, making it the most frequently accessed subsystem.
7. API Design
The core IaC engine exposes a set of operations that map to the standard Terraform workflow: init, validate, plan, apply, destroy, and state manipulation commands. These operations can be exposed as a CLI, a gRPC API for IDE integrations, or a REST API for web-based platforms.
Core API Operations
csharppublic interface ITerraformEngine
{
Task<InitResult> InitAsync(InitRequest request, CancellationToken ct);
Task<ValidationResult> ValidateAsync(ValidateRequest request, CancellationToken ct);
Task<PlanResult> PlanAsync(PlanRequest request, CancellationToken ct);
Task<ApplyResult> ApplyAsync(ApplyRequest request, CancellationToken ct);
Task<DestroyResult> DestroyAsync(DestroyRequest request, CancellationToken ct);
Task<ImportResult> ImportAsync(ImportRequest request, CancellationToken ct);
}
public class PlanRequest
{
public string WorkingDirectory { get; set; }
public Dictionary<string, string> Variables { get; set; } = new();
public string TargetResource { get; set; }
public bool RefreshOnly { get; set; }
public string Workspace { get; set; } = "default";
public List<string> ExcludeResources { get; set; } = new();
public PlanOptions Options { get; set; } = new();
}
public class PlanResult
{
public string PlanId { get; set; }
public ExecutionPlan Plan { get; set; }
public PlanSummary Summary { get; set; }
public List<PolicyViolation> PolicyViolations { get; set; } = new();
public CostEstimate CostEstimate { get; set; }
public bool HasChanges { get; set; }
}
public class ApplyRequest
{
public string PlanId { get; set; }
public bool AutoApprove { get; set; }
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(30);
public List<string> TargetResources { get; set; } = new();
public Dictionary<string, string> Variables { get; set; } = new();
}
public class ExecutionPlan
{
public List<PlannedResourceChange> ResourceChanges { get; set; } = new();
public List<OutputChange> OutputChanges { get; set; } = new();
public Graph ExecutionGraph { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class PlannedResourceChange
{
public string ResourceAddress { get; set; }
public ChangeAction Action { get; set; }
public Dictionary<string, object> Before { get; set; }
public Dictionary<string, object> After { get; set; }
public ResourceDependency DependsOn { get; set; }
public List<string> Actions { get; set; }
}
public enum ChangeAction
{
Create, Update, Delete, Read, NoOp, Move, Forget
}
REST API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/workspaces/{id}/init | Initialize workspace, download providers |
| POST | /api/v1/workspaces/{id}/plan | Generate execution plan |
| POST | /api/v1/workspaces/{id}/apply | Apply a previously generated plan |
| POST | /api/v1/workspaces/{id}/destroy | Destroy all managed resources |
| GET | /api/v1/workspaces/{id}/state | Read current state |
| GET | /api/v1/workspaces/{id}/state/versions | List state versions |
| POST | /api/v1/workspaces/{id}/import | Import existing resource |
| GET | /api/v1/workspaces/{id}/drift | Run drift detection scan |
The API design follows REST conventions for resource-oriented operations and uses POST for operations that trigger side effects like plan, apply, and destroy. Each operation returns a structured response that includes success or failure status, the operation output, and any policy violations or cost estimates. Long-running operations like apply can be polled for progress using a status endpoint that returns the current phase, completed resources, and estimated time remaining.
8. HCL Language & Parser Design
HCL (HashiCorp Configuration Language) is the configuration language that makes Terraform accessible and powerful. Unlike JSON, HCL is designed for humans to read and write. Unlike YAML, it has a formal grammar that prevents ambiguity. HCL supports blocks, attributes, expressions, function calls, conditional logic, and string interpolation. Understanding its parse tree is essential for building an IaC engine.
HCL Grammar Features
hclvariable "region" {
type = string
default = "us-east-1"
description = "AWS region for deployment"
}
variable "instance_count" {
type = number
default = 3
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = var.project_name
}
instance_type = var.environment == "prod" ? "m5.xlarge" : "t3.medium"
}
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type
subnet_id = aws_subnet.public[count.index].id
vpc_security_group_ids = [aws_security_group.web.id]
tags = merge(local.common_tags, {
Name = "${var.project_name}-web-${count.index}"
})
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_security_group" "web" {
name_prefix = "${var.project_name}-web-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
output "instance_ips" {
value = aws_instance.web[*].public_ip
}
Parser Implementation
csharppublic class HclParser
{
private readonly ITokenizer _tokenizer;
private readonly IFunctionRegistry _functions;
public HclParser(ITokenizer tokenizer, IFunctionRegistry functions)
{
_tokenizer = tokenizer;
_functions = functions;
}
public AstNode Parse(string source)
{
var tokens = _tokenizer.Tokenize(source);
var parser = new RecursiveDescentParser(tokens, _functions);
return parser.ParseBody();
}
}
public class RecursiveDescentParser
{
private readonly IReadOnlyList<Token> _tokens;
private int _position;
public AstNode ParseBody()
{
var body = new BodyNode();
while (!IsAtEnd())
{
if (Peek().Type == TokenType.Block)
body.Blocks.Add(ParseBlock());
else if (Peek().Type == TokenType.Identifier)
body.Attributes.Add(ParseAttribute());
else
Advance();
}
return body;
}
private BlockNode ParseBlock()
{
Consume(TokenType.Block);
var label = ParseIdentifierList();
Expect(TokenType.LeftBrace);
var body = ParseBody();
Expect(TokenType.RightBrace);
return new BlockNode { Labels = label, Body = body };
}
private AttributeNode ParseAttribute()
{
var name = Consume(TokenType.Identifier).Value;
Expect(TokenType.Assign);
var expression = ParseExpression();
return new AttributeNode { Name = name, Expression = expression };
}
private ExpressionNode ParseExpression()
{
if (Peek().Type == TokenType.LeftParen)
return ParseFunctionCall();
if (Peek().Type == TokenType.String)
return ParseStringInterpolation();
if (Peek().Type == TokenType.Identifier)
return ParseReference();
return ParseLiteral();
}
private ExpressionNode ParseConditional()
{
var condition = ParseOrExpression();
if (Match(TokenType.Ternary))
{
var trueExpr = ParseExpression();
Expect(TokenType.Colon);
var falseExpr = ParseExpression();
return new ConditionalNode
{
Condition = condition,
TrueExpression = trueExpr,
FalseExpression = falseExpr
};
}
return condition;
}
private ExpressionNode ParseReference()
{
var parts = new List<string> { Consume(TokenType.Identifier).Value };
while (Match(TokenType.Dot))
parts.Add(Consume(TokenType.Identifier).Value);
if (Match(TokenType.LeftBracket))
{
var key = ParseExpression();
Expect(TokenType.RightBracket);
return new ReferenceNode { Parts = parts, Index = key };
}
return new ReferenceNode { Parts = parts };
}
}
AST Node Types
| Node Type | Description | Example |
|---|---|---|
| BodyNode | Top-level container of blocks and attributes | Entire .tf file content |
| BlockNode | Named block with labels and body | resource "aws_instance" "web" { ... } |
| AttributeNode | Named key-value assignment | ami = "abc123" |
| ReferenceNode | Cross-resource reference | aws_vpc.main.id |
| FunctionCallNode | Built-in or custom function | file("${path.module}/userdata.sh") |
| ConditionalNode | Ternary expression | var.prod ? "m5.xl" : "t3.m" |
| StringInterpolationNode | Embedded expressions | "prefix-${var.name}-suffix" |
| ForExpressionNode | Collection transformation | [for s in var.list : upper(s)] |
The parser uses a recursive descent algorithm, which is the most natural fit for HCL grammar. The tokenizer first converts the raw source text into a stream of tokens (identifiers, keywords, operators, literals, brackets, etc.), and then the parser consumes these tokens to build the AST. Each production rule in the grammar maps directly to a method in the parser. This approach makes the parser easy to understand, maintain, and extend with new language features. Error recovery is implemented by synchronizing to the next block boundary when an unexpected token is encountered, allowing the parser to report multiple errors in a single pass.
9. Provider Plugin System
The provider plugin system is what makes Terraform universally applicable. Each provider encapsulates the knowledge of how to authenticate with a cloud API, what resources exist, what their schemas are, and how to perform CRUD operations against them. Providers communicate with the Terraform engine through gRPC, which provides language-agnostic, versioned, and isolated communication. This separation means the core engine never needs to know anything about AWS, Azure, or GCP — it only needs to understand the provider protocol.
Provider Interface
csharppublic interface IProvider
{
string Name { get; }
string Version { get; }
ProviderSchema Schema { get; }
Task<ProviderConfigResult> ConfigureAsync(
Dictionary<string, object> config, CancellationToken ct);
Task<ResourceOperationResult> PlanResourceChangeAsync(
ResourceChangeRequest request, CancellationToken ct);
Task<ResourceOperationResult> ApplyResourceChangeAsync(
ResourceChangeRequest request, CancellationToken ct);
Task<ResourceOperationResult> ReadResourceAsync(
ResourceReadRequest request, CancellationToken ct);
Task<ResourceOperationResult> DeleteResourceAsync(
ResourceDeleteRequest request, CancellationToken ct);
Task<ImportResult> ImportResourceAsync(
ImportRequest request, CancellationToken ct);
Task<ValidateResult> ValidateConfigAsync(
ValidateConfigRequest request, CancellationToken ct);
}
public class ProviderSchema
{
public Dictionary<string, ResourceSchema> ResourceSchemas { get; set; } = new();
public Dictionary<string, DataSourceSchema> DataSourceSchemas { get; set; } = new();
public SchemaNode ConfigSchema { get; set; }
}
public class ResourceSchema
{
public string Version { get; set; }
public SchemaNode Schema { get; set; }
public List<string> InstanceIdentityAttributes { get; set; } = new();
public List<string> BlockMovement { get; set; } = new();
public bool Sensitive { get; set; }
}
public class SchemaNode
{
public SchemaNodeType Type { get; set; }
public Dictionary<string, SchemaNode> Attributes { get; set; } = new();
public List<SchemaNode> BlockTypes { get; set; } = new();
public bool Required { get; set; }
public bool Optional { get; set; }
public bool Computed { get; set; }
public bool ForceNew { get; set; }
public List<object> AllowedValues { get; set; }
public object Default { get; set; }
}
gRPC Provider Protocol
protobufsyntax = "proto3";
package plugin;
service Provider {
rpc GetSchema(Empty) returns (GetSchemaResponse);
rpc Configure(ConfigureRequest) returns (ConfigureResponse);
rpc PlanResourceChange(PlanResourceChangeRequest)
returns (PlanResourceChangeResponse);
rpc ApplyResourceChange(ApplyResourceChangeRequest)
returns (ApplyResourceChangeResponse);
rpc ReadResource(ReadResourceRequest)
returns (ReadResourceResponse);
rpc UpgradeResourceState(UpgradeResourceStateRequest)
returns (UpgradeResourceStateResponse);
rpc ImportResourceState(ImportResourceStateRequest)
returns (ImportResourceStateResponse);
rpc ValidateConfig(ValidateConfigRequest)
returns (ValidateConfigResponse);
rpc ReadDataSource(ReadDataSourceRequest)
returns (ReadDataSourceResponse);
}
message PlanResourceChangeRequest {
string type_name = 1;
DynamicMessage prior_state = 2;
DynamicMessage proposed_new_state = 3;
DynamicMessage config = 4;
DynamicMessage provider_meta = 5;
DynamicMessage prior_private = 6;
}
message PlanResourceChangeResponse {
DynamicMessage planned_state = 1;
repeated string requires_replace = 2;
DynamicMessage planned_private = 3;
repeated string sensitivity = 4;
}
Provider Lifecycle Management
Provider plugins run as separate OS processes, communicating via gRPC over stdio or a local network socket. The engine must handle provider installation (downloading binaries from the registry), version negotiation (ensuring compatible protocol versions), process health monitoring (detecting and restarting crashed providers), and graceful shutdown. Each provider runs in its own process so a crash in the AWS provider cannot affect the Azure provider or the core engine.
The provider must also handle retries, rate limiting, and exponential backoff for cloud API calls. Different cloud platforms have different rate limits — AWS allows 400 API calls per second per account, Azure varies by resource type, and GCP uses per-project quotas. A well-implemented provider tracks its own API usage and backs off before hitting these limits, preventing throttling that would slow down or fail the apply operation. The provider should also implement circuit breaker patterns to handle cloud API outages gracefully, failing fast rather than queuing requests that will inevitably timeout.
Provider Discovery and Installation
| Source | Discovery | Signing | Best For |
|---|---|---|---|
| Official Registry | registry.terraform.io | HashiCorp signed | Production use |
| Community Registry | registry.terraform.io/community | Community verified | Non-AWS clouds |
| Local filesystem | File path | Manual verification | Development and testing |
| Network mirror | Custom URL | Organization signed | Air-gapped environments |
| GitHub release | Direct download | GPG signature | Beta features |
10. Resource Graph & Dependency Resolution
The resource graph is the core data structure that enables intelligent execution ordering. Every resource in the configuration becomes a node in a directed acyclic graph (DAG), and explicit dependencies (via depends_on), implicit dependencies (via attribute references), and provider dependencies become edges. The graph is then topologically sorted to determine the execution order, and resources at the same depth can be executed concurrently.
Dependency Detection
Dependencies are detected by analyzing the AST for references. When a resource attribute references another resource (for example, aws_subnet.public[0].id), the parser creates an implicit dependency edge. Explicit dependencies via depends_on add additional edges. Provider dependencies (both resources using the same AWS provider) are also tracked to ensure provider configuration is applied before any resources that depend on it.
Graph Construction
csharppublic class ResourceGraphBuilder
{
private readonly Dictionary<string, GraphNode> _nodes = new();
private readonly List<GraphEdge> _edges = new();
public ResourceGraph Build(Configuration config, State currentState)
{
foreach (var resource in config.Resources)
{
var nodeId = resource.Address;
_nodes[nodeId] = new GraphNode
{
Id = nodeId,
Resource = resource,
Type = NodeType.Resource
};
}
foreach (var resource in config.Resources)
{
var dependencies = ResolveDependencies(resource, config);
foreach (var dep in dependencies)
{
_edges.Add(new GraphEdge
{
Source = dep,
Target = resource.Address,
Type = dep.StartsWith("provider.")
? EdgeType.Provider
: EdgeType.ResourceReference
});
}
}
var graph = new ResourceGraph(_nodes, _edges);
ValidateNoCycles(graph);
return graph;
}
private List<string> ResolveDependencies(
ResourceConfig resource, Configuration config)
{
var deps = new List<string>();
foreach (var block in resource.Body.Blocks)
{
var refs = ExtractReferences(block);
deps.AddRange(refs.Where(r => _nodes.ContainsKey(r)));
}
foreach (var explicit in resource.DependsOn)
deps.Add(explicit);
deps.Add($"provider.{resource.ProviderName}");
return deps;
}
private void ValidateNoCycles(ResourceGraph graph)
{
var visited = new HashSet<string>();
var inStack = new HashSet<string>();
foreach (var node in graph.Nodes)
{
if (!visited.Contains(node.Key))
{
if (HasCycleDfs(node.Key, graph, visited, inStack))
throw new CircularDependencyException(
$"Circular dependency detected involving {node.Key}");
}
}
}
private bool HasCycleDfs(string nodeId, ResourceGraph graph,
HashSet<string> visited, HashSet<string> inStack)
{
visited.Add(nodeId);
inStack.Add(nodeId);
foreach (var edge in graph.GetOutgoingEdges(nodeId))
{
if (!visited.Contains(edge.Target))
{
if (HasCycleDfs(edge.Target, graph, visited, inStack))
return true;
}
else if (inStack.Contains(edge.Target))
return true;
}
inStack.Remove(nodeId);
return false;
}
}
Topological Sort for Parallel Execution
csharppublic class ParallelExecutionScheduler
{
public List<List<string>> GetExecutionLevels(ResourceGraph graph)
{
var levels = new List<List<string>>();
var completed = new HashSet<string>();
var inDegree = graph.ComputeInDegrees();
while (completed.Count < graph.NodeCount)
{
var currentLevel = graph.Nodes.Keys
.Where(id => !completed.Contains(id)
&& inDegree[id] == 0
&& graph.GetIncomingEdges(id)
.All(e => completed.Contains(e.Source)))
.ToList();
if (currentLevel.Count == 0)
throw new InvalidOperationException(
"Deadlock in execution graph");
levels.Add(currentLevel);
foreach (var node in currentLevel)
completed.Add(node);
}
return levels;
}
}
The parallel execution scheduler assigns resources to levels where all dependencies of resources in a given level have been completed in previous levels. This maximizes parallelism while respecting the dependency ordering. In the example above, Provider Config runs first alone, then VPC runs alone (it depends on the provider), then Subnet 1, Subnet 2, and Security Group can all run in parallel (they all depend only on VPC), then Instance 1 and Instance 2 can run in parallel (they depend on subnets and security group), and finally the Load Balancer runs (it depends on both instances). This gives us 5 execution levels instead of 8 sequential operations, significantly reducing total apply time.
11. Plan & Apply Workflow
The plan/apply workflow is the defining feature of Terraform approach to infrastructure management. The plan phase reads the current state, refreshes it against the real world, compares the desired state from configuration, and produces a human-readable execution plan. The apply phase then executes that plan, recording progress in state as it goes. This two-phase approach enables review, approval, and policy enforcement before any changes are made to real infrastructure.
Plan Phase Implementation
csharppublic class PlanEngine
{
private readonly IStateBackend _stateBackend;
private readonly IProviderFactory _providerFactory;
private readonly IGraphExecutor _graphExecutor;
public async Task<PlanResult> PlanAsync(
Configuration config, PlanOptions options, CancellationToken ct)
{
var priorState = await _stateBackend.ReadStateAsync(
options.Workspace, ct);
var refreshedState = await RefreshStateAsync(
priorState, config, ct);
var changes = ComputeChanges(config, refreshedState);
if (options.TargetResources.Any())
changes = FilterTargets(changes, options.TargetResources);
var plan = new ExecutionPlan
{
ResourceChanges = changes,
CreatedAt = DateTimeOffset.UtcNow,
ExecutionGraph = BuildExecutionGraph(changes)
};
var planFile = new PlanFile
{
FormatVersion = "1.2",
TerraformVersion = "1.8.0",
ConfigurationHash = config.ComputeHash(),
PriorState = refreshedState,
PlannedState = ApplyChanges(refreshedState, plan),
PlannedChanges = changes,
Timestamp = DateTimeOffset.UtcNow
};
var planId = await SavePlanAsync(planFile, ct);
return new PlanResult
{
PlanId = planId,
Plan = plan,
Summary = ComputeSummary(changes),
HasChanges = changes.Any(c => c.Action != ChangeAction.NoOp),
PolicyViolations = await EvaluatePoliciesAsync(plan, ct),
CostEstimate = await EstimateCostAsync(plan, ct)
};
}
private List<PlannedResourceChange> ComputeChanges(
Configuration config, State currentState)
{
var changes = new List<PlannedResourceChange>();
foreach (var resource in config.Resources)
{
var currentStateResource = currentState.FindResource(
resource.Address);
if (currentStateResource == null)
{
changes.Add(new PlannedResourceChange
{
ResourceAddress = resource.Address,
Action = ChangeAction.Create,
Before = null,
After = ComputeDesiredState(resource)
});
}
else
{
var diff = ComputeDiff(
currentStateResource.Attributes,
ComputeDesiredState(resource));
if (diff.HasChanges)
{
changes.Add(new PlannedResourceChange
{
ResourceAddress = resource.Address,
Action = ChangeAction.Update,
Before = currentStateResource.Attributes,
After = diff.MergedAttributes,
Actions = diff.AttributeChanges
});
}
else
{
changes.Add(new PlannedResourceChange
{
ResourceAddress = resource.Address,
Action = ChangeAction.NoOp,
Before = currentStateResource.Attributes,
After = currentStateResource.Attributes
});
}
}
}
var configAddresses = config.Resources
.Select(r => r.Address).ToHashSet();
foreach (var stateResource in currentState.Resources)
{
if (!configAddresses.Contains(stateResource.Address))
{
changes.Add(new PlannedResourceChange
{
ResourceAddress = stateResource.Address,
Action = ChangeAction.Delete,
Before = stateResource.Attributes,
After = null
});
}
}
return changes;
}
}
Apply Phase Implementation
csharppublic class ApplyEngine
{
private readonly IPlanStore _planStore;
private readonly IStateBackend _stateBackend;
private readonly IProviderFactory _providerFactory;
private readonly ISemaphoreSlim _applySemaphore;
private readonly ILogger<ApplyEngine> _logger;
public async Task<ApplyResult> ApplyAsync(
ApplyRequest request, CancellationToken ct)
{
var planFile = await _planStore.GetPlanAsync(request.PlanId, ct);
var state = await _stateBackend.ReadStateAsync("default", ct);
var executionLevels = new ParallelExecutionScheduler()
.GetExecutionLevels(planFile.ExecutionGraph);
var appliedCount = 0;
var failedCount = 0;
var errors = new List<ApplyError>();
foreach (var level in executionLevels)
{
await _applySemaphore.WaitAsync(ct);
try
{
var tasks = level.Select(async nodeId =>
{
var change = planFile.PlannedChanges
.First(c => c.ResourceAddress == nodeId);
if (change.Action == ChangeAction.NoOp) return;
try
{
await ApplyResourceChangeAsync(change, state, ct);
Interlocked.Increment(ref appliedCount);
}
catch (Exception ex)
{
Interlocked.Increment(ref failedCount);
lock (errors)
{
errors.Add(new ApplyError
{
ResourceAddress = nodeId,
Message = ex.Message
});
}
_logger.LogError(ex,
"Failed to apply {Resource}", nodeId);
}
});
await Task.WhenAll(tasks);
await _stateBackend.WriteStateAsync(state, ct);
}
finally
{
_applySemaphore.Release();
}
}
return new ApplyResult
{
Applied = appliedCount,
Failed = failedCount,
Errors = errors,
FinalState = state
};
}
private async Task ApplyResourceChangeAsync(
PlannedResourceChange change, State state, CancellationToken ct)
{
var provider = _providerFactory.GetProviderFor(change.ResourceAddress);
switch (change.Action)
{
case ChangeAction.Create:
var createResult = await provider.ApplyResourceChangeAsync(
new ResourceChangeRequest
{
TypeName = ExtractType(change.ResourceAddress),
PriorState = null,
DesiredState = change.After
}, ct);
state.UpdateResource(change.ResourceAddress,
createResult.NewState);
break;
case ChangeAction.Update:
var updateResult = await provider.ApplyResourceChangeAsync(
new ResourceChangeRequest
{
TypeName = ExtractType(change.ResourceAddress),
PriorState = change.Before,
DesiredState = change.After
}, ct);
state.UpdateResource(change.ResourceAddress,
updateResult.NewState);
break;
case ChangeAction.Delete:
await provider.DeleteResourceAsync(
new ResourceDeleteRequest
{
TypeName = ExtractType(change.ResourceAddress),
State = change.Before
}, ct);
state.RemoveResource(change.ResourceAddress);
break;
}
}
}
The apply engine must also implement several safety mechanisms. It should lock the state before starting an apply and release the lock when finished. It should record the plan ID that was applied so that it can verify the apply matches the plan that was reviewed. It should support timeout per resource so that a hung API call does not block the entire operation. It should emit structured logs and events for every resource operation so that operators can trace exactly what happened during an apply. And it should implement graceful degradation — if a resource fails to create, it should not attempt to create resources that depend on it, but it should still attempt to create resources in unrelated branches of the dependency graph.
12. State Management & Remote Backends
State management is arguably the most operationally critical component of an IaC platform. State files must be stored durably, accessed concurrently by multiple team members, locked during mutations to prevent corruption, and versioned to enable rollback. Terraform supports a pluggable backend architecture where each backend type (S3, Consul, Terraform Cloud, PostgreSQL, etc.) implements the same interface but with different durability, consistency, and locking characteristics.
Backend Interface
csharppublic interface IStateBackend
{
string Name { get; }
Task<StateReadResult> ReadStateAsync(
string workspace, CancellationToken ct);
Task<StateWriteResult> WriteStateAsync(
State state, StateWriteOptions options, CancellationToken ct);
Task<StateLockResult> LockAsync(
string workspace, string lockId, CancellationToken ct);
Task<StateLockResult> UnlockAsync(
string workspace, string lockId, CancellationToken ct);
Task<IReadOnlyList<StateVersion>> ListVersionsAsync(
string workspace, CancellationToken ct);
Task<State> GetVersionAsync(
string workspace, int serial, CancellationToken ct);
Task DeleteWorkspaceAsync(string workspace, CancellationToken ct);
}
public class StateWriteOptions
{
public long ExpectedSerial { get; set; }
public bool Force { get; set; }
public string LockId { get; set; }
}
public class StateLockResult
{
public bool Acquired { get; set; }
public string LockId { get; set; }
public string Who { get; set; }
public DateTimeOffset Created { get; set; }
public string Operation { get; set; }
}
S3 Backend Implementation
csharppublic class S3StateBackend : IStateBackend
{
private readonly IAmazonS3 _s3Client;
private readonly IAmazonDynamoDB _dynamoClient;
private readonly S3BackendConfig _config;
public async Task<StateReadResult> ReadStateAsync(
string workspace, CancellationToken ct)
{
var key = GetStateKey(workspace);
try
{
var response = await _s3Client.GetObjectAsync(
_config.Bucket, key, ct);
using var reader = new StreamReader(response.ResponseStream);
var json = await reader.ReadToEndAsync();
var state = JsonSerializer.Deserialize<TerraformState>(json);
return new StateReadResult
{
State = state,
Serial = state.Serial,
Lineage = state.Lineage
};
}
catch (NoSuchKeyException)
{
return new StateReadResult { State = new TerraformState() };
}
}
public async Task<StateWriteResult> WriteStateAsync(
State state, StateWriteOptions options, CancellationToken ct)
{
var key = GetStateKey(state.Workspace);
var json = JsonSerializer.Serialize(state, new JsonSerializerOptions
{
WriteIndented = true
});
if (_config.UseDynamoDbLocking)
{
var acquired = await AcquireLockAsync(
state.Workspace, options.LockId, ct);
if (!acquired)
return new StateWriteResult
{
Success = false,
Error = "Could not acquire state lock"
};
}
try
{
await _s3Client.PutObjectAsync(new PutObjectRequest
{
BucketName = _config.Bucket,
Key = key,
ContentBody = json,
ContentType = "application/json",
Metadata = new Dictionary<string, string>
{
{ "terraform-serial", state.Serial.ToString() },
{ "terraform-lineage", state.Lineage.ToString() }
}
}, ct);
await StoreVersionAsync(state, ct);
return new StateWriteResult { Success = true };
}
finally
{
if (_config.UseDynamoDbLocking)
await ReleaseLockAsync(state.Workspace, options.LockId, ct);
}
}
private string GetStateKey(string workspace) =>
$"{_config.KeyPrefix}/{workspace}/terraform.tfstate";
}
Backend Comparison
| Backend | Locking | Versioning | Encryption | Best For |
|---|---|---|---|---|
| Local | File lock | Manual | No | Development, learning |
| S3 + DynamoDB | DynamoDB | S3 versioning | KMS | AWS-native teams |
| Azure Blob | Blob lease | Blob versioning | AES256 | Azure-native teams |
| Consul | Consul KV | Consul snapshots | TLS | Consul-based stacks |
| PostgreSQL | Row-level | Custom | TLS | Self-hosted teams |
| Terraform Cloud | Built-in | Built-in | AES256 | Enterprise teams |
| GCS | Object generation | Object versioning | Google-managed | GCP-native teams |
Choosing the right backend depends on your cloud platform, team size, and compliance requirements. S3 with DynamoDB locking is the most popular choice for AWS shops because it provides strong consistency, unlimited durability, fine-grained IAM access control, and server-side encryption with KMS. Azure Blob Storage with lease-based locking is the equivalent for Azure shops. Terraform Cloud provides the best developer experience with built-in state management, policy enforcement, and audit logging, but requires a subscription for teams beyond the free tier.
13. Drift Detection & Reconciliation
Drift occurs when the actual state of infrastructure diverges from what the IaC configuration declares. Drift can happen for many reasons: a teammate makes a manual change in the cloud console, an automated process modifies a resource attribute, a service updates a tag, or a disaster recovery failover changes DNS records. Detecting drift promptly and deciding how to reconcile it is a critical operational concern for any organization managing infrastructure as code.
Drift Detection Engine
csharppublic class DriftDetector
{
private readonly IStateBackend _stateBackend;
private readonly IProviderFactory _providerFactory;
private readonly IDriftStore _driftStore;
public async Task<DriftReport> DetectDriftAsync(
string workspace, CancellationToken ct)
{
var state = await _stateBackend.ReadStateAsync(workspace, ct);
var report = new DriftReport { Workspace = workspace };
var tasks = state.Resources.Select(async resourceState =>
{
var provider = _providerFactory.GetProvider(
resourceState.Provider);
var currentState = await provider.ReadResourceAsync(
new ResourceReadRequest
{
TypeName = resourceState.Type,
Id = resourceState.Attributes["id"].ToString()
}, ct);
var drift = CompareResource(resourceState, currentState.State);
if (drift.HasDrift)
{
lock (report.DriftedResources)
{
report.DriftedResources.Add(drift);
}
}
});
await Task.WhenAll(tasks);
report.TotalResources = state.Resources.Count;
report.DriftedCount = report.DriftedResources.Count;
report.GeneratedAt = DateTimeOffset.UtcNow;
await _driftStore.SaveReportAsync(report, ct);
return report;
}
private DriftedResource CompareResource(
ResourceState expected, Dictionary<string, object> actual)
{
var drifted = new DriftedResource
{
ResourceAddress = expected.Address,
Type = expected.Type
};
foreach (var attr in expected.Attributes)
{
if (!actual.ContainsKey(attr.Key)) continue;
if (attr.Key == "id") continue;
if (!Equals(attr.Value, actual[attr.Key]))
{
drifted.AttributeDrifts.Add(new AttributeDrift
{
Attribute = attr.Key,
Expected = attr.Value,
Actual = actual[attr.Key]
});
}
}
drifted.HasDrift = drifted.AttributeDrifts.Any();
return drifted;
}
public async Task<ReconcileResult> ReconcileAsync(
string workspace, List<string> addresses, CancellationToken ct)
{
var state = await _stateBackend.ReadStateAsync(workspace, ct);
var result = new ReconcileResult();
foreach (var address in addresses)
{
var resource = state.FindResource(address);
if (resource == null) continue;
var provider = _providerFactory.GetProvider(resource.Provider);
var actual = await provider.ReadResourceAsync(
new ResourceReadRequest
{
TypeName = resource.Type,
Id = resource.Attributes["id"].ToString()
}, ct);
resource.Attributes = actual.State;
state.UpdateResource(address, resource);
result.Reconciled.Add(address);
}
await _stateBackend.WriteStateAsync(state, new StateWriteOptions
{
Force = true
}, ct);
return result;
}
}
The drift detection engine compares every attribute of every managed resource against its actual state in the cloud. Some attributes are expected to change automatically (for example, the public_ip of an EC2 instance changes on stop/start, or the ARN changes on recreation), so the detector must be aware of which attributes are computed-only and exclude them from drift comparison. This awareness comes from the provider schema, which marks attributes as computed, optional, or required.
When drift is detected, the team has several options. They can update the Terraform configuration to match the drift (if the manual change was intentional and should be kept), they can run terraform apply to revert the drift back to the declared configuration, or they can ignore the drift if it falls within acceptable bounds (such as a security group rule that was added manually but does not violate policy). The choice depends on the team governance model and whether the IaC configuration is treated as the single source of truth or as one of several input sources.
14. Module Registry & Reusability
Modules are the primary mechanism for code reuse in Terraform. A module is a collection of Terraform files that together define a reusable infrastructure component. The Terraform Registry (registry.terraform.io) hosts thousands of public modules ranging from simple VPC configurations to complete EKS cluster setups. Organizations also maintain private registries for internal modules that encapsulate company-specific patterns, security requirements, and compliance rules.
Module System Architecture
csharppublic interface IModuleRegistry
{
Task<ModuleMetadata> GetModuleAsync(
string ns, string name, string provider,
string version, CancellationToken ct);
Task<ModuleSource> DownloadModuleAsync(
string source, string version, CancellationToken ct);
Task<IReadOnlyList<ModuleVersion>> ListVersionsAsync(
string ns, string name, string provider,
CancellationToken ct);
}
public class ModuleMetadata
{
public string Id { get; set; }
public string Namespace { get; set; }
public string Name { get; set; }
public string Provider { get; set; }
public string Version { get; set; }
public string Description { get; set; }
public List<ModuleVersion> Versions { get; set; } = new();
public List<string> Submodules { get; set; } = new();
}
public class ModuleResolver
{
private readonly IModuleRegistry _registry;
private readonly IFileSystem _fileSystem;
public async Task<ResolvedModule> ResolveAsync(
ModuleCallNode call, string parentDir, CancellationToken ct)
{
if (call.Source.StartsWith("./") || call.Source.StartsWith("../"))
return await ResolveLocalModule(call, parentDir, ct);
if (call.Source.StartsWith("git::"))
return await ResolveGitModule(call, ct);
if (call.Source.StartsWith("s3::") || call.Source.StartsWith("gcs::"))
return await ResolveCloudStorageModule(call, ct);
return await ResolveRegistryModule(call, ct);
}
private async Task<ResolvedModule> ResolveRegistryModule(
ModuleCallNode call, CancellationToken ct)
{
var parts = call.Source.Split('/');
var ns = parts[0];
var name = parts[1];
var provider = parts.Length > 2 ? parts[2] : "aws";
var version = call.Version ?? "latest";
var metadata = await _registry.GetModuleAsync(
ns, name, provider, version, ct);
var source = await _registry.DownloadModuleAsync(
call.Source, version, ct);
return new ResolvedModule
{
Name = call.Name,
Source = call.Source,
Version = metadata.Version,
Path = source.LocalPath,
Config = call.Body
};
}
}
Module Version Constraints
| Constraint | Meaning | Example |
|---|---|---|
| Exact version | Specific version only | "1.2.3" |
| Greater than | Minimum version | ">= 1.2.0" |
| Compatible range | SemVer range | "~> 1.2" |
| Exclusive upper | Below next major | ">= 1.0, < 2.0" |
| Pre-release | Include pre-releases | ">= 1.0-beta1" |
Module versioning follows semantic versioning (SemVer) conventions. Patch versions (1.2.x) should only include backward-compatible bug fixes. Minor versions (1.x.0) should add new features in a backward-compatible manner. Major versions (x.0.0) may include breaking changes. The tilde constraint (~> 1.2) is especially popular because it allows patch updates but pins the minor version, providing stability while receiving bug fixes. Module authors should document their versioning discipline and provide changelogs for each release.
15. Workspaces & Environment Isolation
Workspaces provide a way to use the same configuration for different infrastructure environments. Each workspace has its own state file, allowing you to manage a staging environment and a production environment from identical Terraform code. Workspaces are lighter than separate Terraform directories but require discipline because the same code is applied in different contexts with different variables.
Workspace Manager
csharppublic class WorkspaceManager
{
private readonly IStateBackend _stateBackend;
private readonly IWorkspaceStore _workspaceStore;
public async Task<WorkspaceInfo> CreateWorkspaceAsync(
string name, WorkspaceConfig config, CancellationToken ct)
{
var existing = await _workspaceStore.GetAsync(name, ct);
if (existing != null)
throw new WorkspaceAlreadyExistsException(name);
var workspace = new WorkspaceInfo
{
Name = name,
CreatedAt = DateTimeOffset.UtcNow,
Config = config,
Lineage = Guid.NewGuid()
};
var emptyState = new TerraformState
{
TerraformVersion = "1.8.0",
Lineage = workspace.Lineage,
Serial = 0
};
await _stateBackend.WriteStateAsync(emptyState,
new StateWriteOptions { Force = true }, ct);
await _workspaceStore.SaveAsync(workspace, ct);
return workspace;
}
public async Task<void> MoveResourceAsync(
string fromWorkspace, string toWorkspace,
string resourceAddress, CancellationToken ct)
{
var fromState = await _stateBackend.ReadStateAsync(fromWorkspace, ct);
var toState = await _stateBackend.ReadStateAsync(toWorkspace, ct);
var resource = fromState.FindResource(resourceAddress);
if (resource == null)
throw new ResourceNotFoundException(resourceAddress);
fromState.RemoveResource(resourceAddress);
toState.AddResource(resource);
await _stateBackend.WriteStateAsync(fromState,
new StateWriteOptions { Force = true }, ct);
await _stateBackend.WriteStateAsync(toState,
new StateWriteOptions { Force = true }, ct);
}
}
Many teams prefer environment isolation through directory separation rather than workspaces. In the directory approach, each environment has its own Terraform files in a separate directory (for example, modules/production/main.tf and modules/staging/main.tf). This approach is clearer about what each environment contains but requires more file duplication. Workspaces reduce duplication but can be confusing when engineers accidentally apply changes to the wrong environment. The best practice is to use workspaces for identical environments (dev/staging/prod with different instance counts) and directory separation for structurally different environments (production with extra security resources that staging does not need).
16. Import & Migration Strategies
Import is the process of bringing existing, manually-created infrastructure under Terraform management. Without import, the only way to manage infrastructure with Terraform is to create it from scratch, which is impractical for organizations with existing brownfield environments. Import adds the resource to the state file without creating or modifying the actual resource, but the corresponding Terraform configuration must be written manually or generated to match the imported resource attributes.
Import Workflow Implementation
csharppublic class ImportEngine
{
private readonly IStateBackend _stateBackend;
private readonly IProviderFactory _providerFactory;
private readonly IConfigurationGenerator _configGenerator;
public async Task<ImportResult> ImportResourceAsync(
ImportRequest request, CancellationToken ct)
{
var provider = _providerFactory.GetProvider(request.ProviderName);
var readResult = await provider.ImportResourceAsync(
new ImportRequest
{
TypeName = request.TypeName,
Id = request.ResourceId
}, ct);
if (!readResult.Success)
return new ImportResult
{
Success = false,
Error = $"Could not import resource: {readResult.Error}"
};
var state = await _stateBackend.ReadStateAsync(
request.Workspace, ct);
var newResource = new ResourceState
{
Type = request.TypeName,
Name = request.ResourceName,
Mode = ResourceMode.Managed,
Provider = request.ProviderName,
Instances = new List<ResourceInstance>
{
new ResourceInstance
{
Attributes = readResult.Attributes,
SchemaVersion = readResult.SchemaVersion
}
}
};
state.AddResource(newResource);
state.Serial++;
await _stateBackend.WriteStateAsync(state,
new StateWriteOptions { Force = true }, ct);
var suggestedConfig = await _configGenerator.GenerateConfigAsync(
newResource, ct);
return new ImportResult
{
Success = true,
ResourceAddress = request.ResourceAddress,
Attributes = readResult.Attributes,
SuggestedConfig = suggestedConfig
};
}
}
State Migration Strategies
| Strategy | Use Case | Complexity | Downtime |
|---|---|---|---|
| terraform state mv | Rename resources | Low | None |
| terraform import | Add existing resources | Medium | None |
| terraform state push | Recover from corruption | High | Minimal |
| Workspace move | Environment restructuring | Medium | None |
| Module extraction | Refactor into modules | High | None |
| OpenTofu migration | Tool switch | Medium | Minimal |
The most powerful import capability is the ability to generate Terraform configuration from existing infrastructure. Tools like terraformer (by Google) can discover all resources in a cloud account and generate both the Terraform configuration and the state file. This enables a zero-code migration path where organizations can adopt Terraform for their entire cloud footprint without writing any configuration manually. The generated configuration serves as a starting point that can then be refined, modularized, and versioned like any other Terraform code.
17. Sentinel — Policy as Code Framework
Sentinel is HashiCorp policy as code framework that enables organizations to enforce governance rules during the Terraform plan and apply workflow. Policies are written in a purpose-built language and are evaluated before any infrastructure changes are applied. This enables guardrails such as requiring specific tags on all resources, preventing public S3 buckets, restricting instance types to approved lists, and enforcing cost budgets — all without blocking developer velocity because policies are checked during plan, not after apply.
Sentinel Policy Example — Require Tags
sentinel# Enforce required tags on all AWS resources
import "tfplan/v2" as tfplan
required_tags = ["Environment", "Owner", "CostCenter", "ManagedBy"]
# Filter to managed resources that are being created or updated
tagged_resources = filter tfplan.resource_changes as _, rc {
rc.mode is "managed" and
(rc.change.actions contains "create" or
rc.change.actions contains "update")
}
main = rule {
all tagged_resources as _, rc {
(rc.type is "aws_instance" or
rc.type is "aws_s3_bucket" or
rc.type is "aws_security_group") implies
length(rc.change.after.tags keys as k {
required_tags contains k
}) is length(required_tags)
}
}
Sentinel Policy — Restrict Instance Types
sentinelimport "tfplan/v2" as tfplan
approved_instance_types = [
"t3.micro", "t3.small", "t3.medium", "t3.large",
"m5.large", "m5.xlarge", "m5.2xlarge",
"c5.large", "c5.xlarge",
]
main = rule {
all tfplan.resource_changes as _, rc {
rc.mode is "managed" and
rc.type is "aws_instance" and
(rc.change.actions contains "create" or
rc.change.actions contains "update") implies
rc.change.after.instance_type in approved_instance_types
}
}
Policy Evaluation Engine
csharppublic class PolicyEngine
{
private readonly ISentinelRuntime _runtime;
private readonly IPolicyStore _policyStore;
public async Task<PolicyEvaluationResult> EvaluateAsync(
ExecutionPlan plan, string workspace, CancellationToken ct)
{
var policies = await _policyStore.GetPoliciesAsync(
workspace, ct);
var result = new PolicyEvaluationResult();
foreach (var policy in policies)
{
var evaluation = await _runtime.EvaluateAsync(
policy, plan, ct);
result.Evaluations.Add(new PolicyEvaluation
{
PolicyName = policy.Name,
Level = policy.Level,
Result = evaluation.Result,
Message = evaluation.Message,
Metadata = evaluation.Metadata
});
if (evaluation.Result == PolicyResult.Deny)
result.HasDenials = true;
}
return result;
}
}
public enum PolicyLevel
{
Advisory, // Warn but allow
Soft Mandatory, // Require override approval
Hard Mandatory // Block entirely
}
public class PolicyEvaluationResult
{
public List<PolicyEvaluation> Evaluations { get; set; } = new();
public bool HasDenials { get; set; }
public bool HasAdvisoryViolations => Evaluations.Any(
e => e.Result == PolicyResult.Deny
&& e.Level == PolicyLevel.Advisory);
}
public class PolicyEvaluation
{
public string PolicyName { get; set; }
public PolicyLevel Level { get; set; }
public PolicyResult Result { get; set; }
public string Message { get; set; }
public Dictionary<string, object> Metadata { get; set; } = new();
}
Policy Levels
| Level | Behavior | Override | Example Use Case |
|---|---|---|---|
| Advisory | Warns, does not block | Always allowed | Cost estimation alerts |
| Soft Mandatory | Blocks, can be overridden | With approval | Tagging requirements |
| Hard Mandatory | Blocks, no override | None | Public access prevention |
Policy as code is a critical component of platform engineering because it shifts compliance checks left into the development workflow. Instead of discovering compliance violations during a quarterly audit, violations are caught during terraform plan. This reduces remediation cost by an order of magnitude because fixing an issue in a plan is trivially cheap compared to fixing it after resources have been deployed and are serving production traffic. Sentinel integrates with Terraform Cloud to provide a managed policy evaluation service, but the same pattern can be implemented with Open Policy Agent (OPA) and Rego for teams that prefer open-source alternatives.
18. Terraform Cloud & Enterprise Architecture
Terraform Cloud is the managed SaaS platform from HashiCorp that provides a complete IaC collaboration environment. It includes remote state management, workspace management, VCS integration, policy enforcement, cost estimation, and audit logging in a single hosted platform. Terraform Enterprise is the self-hosted version that runs behind your firewall for organizations with strict data residency or compliance requirements. Understanding the architecture of these platforms provides insight into building your own IaC management layer.
Core Components
| Component | Function | Scale |
|---|---|---|
| Workspace Manager | Isolate state per project/environment | 10,000+ workspaces |
| Run Engine | Execute plan/apply in sandboxed environments | 100+ concurrent runs |
| State Backend | Durable, versioned, encrypted state storage | PB-scale storage |
| Policy Engine | Admission control for infrastructure changes | 1,000+ policies |
| Cost Estimator | Pre-apply cost estimation from Infracost | All major clouds |
| Audit Logger | Track all changes for compliance | 7-year retention |
| VCS Connector | Link to GitHub, GitLab, Bitbucket | Auto-trigger on push |
| Agent Pool | Run Terraform in private networks | Self-managed agents |
Sentinel Integration Flow
Terraform Cloud architecture is fundamentally a job scheduler with state management and policy evaluation bolted on. The run engine spins up isolated containers for each Terraform execution, mounts the configuration from VCS, downloads the state from the backend, runs plan, evaluates policies, and if approved, runs apply. Each run is recorded as an audit event with the full plan diff, policy evaluations, and who approved the apply. This audit trail is essential for compliance frameworks like SOC 2, HIPAA, and PCI DSS that require tracking all infrastructure changes.
For organizations that cannot use the SaaS platform, Terraform Enterprise provides the same functionality deployed on your own infrastructure. It runs as a set of Docker containers on Kubernetes, uses your own PostgreSQL database and Redis cluster for persistence, and connects to your VCS through internal network routes. The self-hosted version adds complexity in terms of operations and upgrades, but it gives you full control over data location, network access, and integration with internal systems like LDAP, SAML SSO, and custom audit pipelines.
19. Multi-Cloud Support & Abstraction
Multi-cloud support is one of Terraform greatest strengths. The provider abstraction allows the same tool, language, and workflow to manage resources across AWS, Azure, GCP, Kubernetes, Cloudflare, Datadog, GitHub, and hundreds of other platforms. However, multi-cloud is not just about supporting multiple providers — it is about providing abstractions that allow teams to define infrastructure once and deploy it across different environments that may use different cloud platforms.
Multi-Cloud Module Pattern
hcl# modules/compute/main.tf — Cloud-agnostic compute module
variable "cloud_provider" {
type = string
validation {
condition = contains(["aws", "azure", "gcp"], var.cloud_provider)
error_message = "Must be aws, azure, or gcp"
}
}
variable "instance_size" {
type = string
default = "medium"
}
variable "subnet_id" {
type = string
}
variable "security_group_ids" {
type = list(string)
}
module "aws_instance" {
source = "./aws"
count = var.cloud_provider == "aws" ? 1 : 0
instance_type = local.aws_instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
}
module "azure_vm" {
source = "./azure"
count = var.cloud_provider == "azure" ? 1 : 0
vm_size = local.azure_vm_size
subnet_id = var.subnet_id
nsg_ids = var.security_group_ids
}
module "gcp_instance" {
source = "./gcp"
count = var.cloud_provider == "gcp" ? 1 : 0
machine_type = local.gcp_machine_type
subnet_id = var.subnet_id
firewall_rules = var.security_group_ids
}
locals {
aws_instance_type = {
small = "t3.small"
medium = "m5.large"
large = "m5.xlarge"
}[var.instance_size]
azure_vm_size = {
small = "Standard_B2s"
medium = "Standard_D2s_v3"
large = "Standard_D4s_v3"
}[var.instance_size]
gcp_machine_type = {
small = "e2-medium"
medium = "e2-standard-2"
large = "e2-standard-4"
}[var.instance_size]
}
output "instance_id" {
value = try(
module.aws_instance[0].instance_id,
module.azure_vm[0].vm_id,
module.gcp_instance[0].instance_id
)
}
Provider Comparison
| Capability | AWS Provider | Azure Provider | GCP Provider |
|---|---|---|---|
| Resource count | 1,200+ | 1,100+ | 400+ |
| Data sources | 800+ | 700+ | 300+ |
| Authentication | IAM roles, profiles | Service principals, MSI | Service accounts |
| State encryption | KMS | Key Vault | KMS (default) |
| Import support | Most resources | Most resources | Growing |
| Custom resources | CloudFormation bridge | ARM template bridge | Deployment Manager |
The key insight for multi-cloud abstraction is that not all cloud resources have equivalents. A VPC on AWS is not the same as a VNet on Azure, which is not the same as a VPC on GCP. The abstraction must work at the level of intent (I need an isolated network with these CIDR ranges) rather than at the level of specific resource types. This is why multi-cloud modules are organized as separate sub-modules per cloud, with a top-level orchestrator that selects the right sub-module based on the target cloud provider.
20. Testing with Terratest
Terratest is an open-source Go library developed by Gruntwork for writing automated tests for Terraform modules. It provides helper functions that run terraform init, plan, apply, and destroy as part of Go test functions, and it provides cloud-specific helpers to verify that the created resources actually work as expected. Terratest is the standard for integration testing in the Terraform ecosystem, and every serious module should have a Terratest suite.
Terratest Example — AWS VPC Module
gopackage test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
awsRegion := "us-east-1"
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"azs": []string{"us-east-1a", "us-east-1b", "us-east-1c"},
"private_subnets": []string{"10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"},
"public_subnets": []string{"10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"},
"environment": "test",
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
require.NotEmpty(t, vpcId)
assert.True(t, aws.IsVpcExists(t, vpcId, awsRegion))
aws AssertVpcHasFlowLogs(t, vpcId, awsRegion)
privateSubnetIds := terraform.OutputList(t, terraformOptions, "private_subnets")
assert.Equal(t, 3, len(privateSubnetIds))
for _, subnetId := range privateSubnetIds {
assert.False(t, aws.IsPublicSubnet(t, subnetId, awsRegion))
}
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnets")
assert.Equal(t, 3, len(publicSubnetIds))
for _, subnetId := range publicSubnetIds {
assert.True(t, aws.IsPublicSubnet(t, subnetId, awsRegion))
}
}
Testing Strategy Matrix
| Test Type | Tool | Scope | Speed | Cost |
|---|---|---|---|---|
| Unit | HCL validate / checkov | Single module | Seconds | Free |
| Integration | Terratest | Module + cloud | Minutes | Cloud costs |
| E2E | Custom Go/Python | Full stack | 30+ minutes | Significant |
| Policy | Sentinel / OPA | Plan output | Seconds | Free |
| Security | tfsec / Checkov | Configuration | Seconds | Free |
| Cost | Infracost | Plan output | Seconds | Free tier |
Terratest tests should follow the Arrange-Act-Assert pattern. Arrange by setting up terraform options with the input variables for your test case. Act by running terraform init and apply. Assert by using cloud-specific helpers to verify the created resources meet expectations. Always defer terraform destroy to ensure cleanup even if the test fails. Use t.Parallel() to run multiple test cases concurrently, but be aware of cloud API rate limits when running many tests simultaneously. Consider using test fixtures that share common infrastructure (like a VPC) across multiple test cases to reduce total execution time and cloud costs.
21. Security Scanning with tfsec
tfsec (now part of Trivy) is a static analysis security scanner for Terraform code. It checks your Terraform configuration against a comprehensive library of security best practices and compliance rules specific to each cloud provider. tfsec identifies issues like unencrypted S3 buckets, publicly accessible databases, overly permissive security groups, missing encryption on EBS volumes, and hardcoded secrets. It can output results in multiple formats and integrate into CI/CD pipelines to catch security issues before they reach production.
tfsec Scanning Implementation
csharppublic class SecurityScanner
{
private readonly ITfsecRunner _tfsecRunner;
private readonly ICheckovRunner _checkovRunner;
public async Task<SecurityScanResult> ScanAsync(
string terraformDir, ScanOptions options, CancellationToken ct)
{
var result = new SecurityScanResult();
var tfsecResults = await _tfsecRunner.RunAsync(
terraformDir, new TfsecOptions
{
Format = OutputFormat.JSON,
ExcludeDownloadedModules = true,
MinimumSeverity = Severity.MEDIUM,
CustomCheckPaths = options.CustomChecksDir
}, ct);
result.TfsecFindings = tfsecResults.Results.Select(r => new SecurityFinding
{
RuleId = r.RuleID,
Severity = r.Severity,
Title = r.RuleDescription,
Message = r.Message,
FilePath = r.Location.Filename,
LineNumber = r.Location.StartLine,
ResourceType = r.ResourceType,
Recommendation = GetRecommendation(r.RuleID)
}).ToList();
var checkovResults = await _checkovRunner.RunAsync(
terraformDir, new CheckovOptions
{
Frameworks = new[] { "terraform" },
OutputFormat = "json",
Checks = options.CheckovChecks
}, ct);
result.CheckovFindings = checkovResults.Results.Select(r => new SecurityFinding
{
RuleId = r.CheckId,
Severity = MapCheckovSeverity(r.CheckResult),
Title = r.CheckName,
Message = r.Resource,
FilePath = r.FilePath,
ResourceType = ExtractResourceType(r.Resource)
}).ToList();
result.TotalFindings = result.TfsecFindings.Count
+ result.CheckovFindings.Count;
result.CriticalCount = result.AllFindings.Count(
f => f.Severity == Severity.CRITICAL);
result.HighCount = result.AllFindings.Count(
f => f.Severity == Severity.HIGH);
return result;
}
private string GetRecommendation(string ruleId)
{
return ruleId switch
{
"aws-s3-enable-bucket-encryption" =>
"Enable server-side encryption with SSE-S3 or SSE-KMS",
"aws-vpc-no-public-ingress-sg" =>
"Remove 0.0.0.0/0 from ingress rules, use specific CIDR blocks",
"aws-ec2-enable-vpn-termination-protection" =>
"Enable termination protection for production instances",
_ => "Review the finding and apply the recommended fix"
};
}
}
Common Security Rules
| Rule | Risk | Severity | Fix |
|---|---|---|---|
| S3 bucket public access | Data exposure | Critical | Set public_access_block to all true |
| RDS public accessibility | Database exposure | Critical | Set publicly_accessible to false |
| Security group 0.0.0.0/0 | Open ingress | High | Restrict to specific CIDR ranges |
| EBS unencrypted volume | Data at rest | High | Enable encrypted = true |
| CloudWatch Logs unencrypted | Log exposure | High | Enable kms_key_id for log group |
| EC2 IMDSv1 | SSRF metadata access | High | Set http_tokens = required |
| Missing resource tags | Cost allocation | Medium | Add required tags |
Security scanning should be integrated at three points in the development workflow. First, run tfsec locally during development so developers get immediate feedback. Second, run it in CI/CD as a required check before merging to the main branch. Third, run it periodically against deployed infrastructure to detect drift that introduced security vulnerabilities. The CI/CD integration is especially important because it prevents security regressions from reaching production. Configure your pipeline to fail on Critical and High findings but allow Medium and Low findings to be tracked as technical debt.
22. Cost Estimation with Infracost
Infracost provides pre-apply cost estimates for Terraform changes. It parses the Terraform plan output, maps resource types to cloud provider pricing APIs, and calculates the monthly cost impact of each change. This enables teams to make informed decisions about infrastructure changes based on their cost implications, preventing surprise bills and enabling FinOps practices. Infracost integrates with CI/CD, PR comments, and Slack to make cost visibility a first-class part of the infrastructure workflow.
Cost Estimation Engine
csharppublic class CostEstimator
{
private readonly IPriceDatabase _priceDb;
private readonly ICloudPricingApi _pricingApi;
public async Task<CostEstimate> EstimateAsync(
ExecutionPlan plan, CancellationToken ct)
{
var estimate = new CostEstimate();
foreach (var change in plan.ResourceChanges)
{
if (change.Action == ChangeAction.NoOp) continue;
var pricing = await _pricingApi.GetPricingAsync(
change.ResourceType, change.After, ct);
if (pricing == null) continue;
var resourceCost = new ResourceCost
{
ResourceAddress = change.ResourceAddress,
Action = change.Action,
MonthlyCostBefore = CalculateMonthlyCost(
pricing, change.Before),
MonthlyCostAfter = CalculateMonthlyCost(
pricing, change.After),
};
resourceCost.MonthlyDelta = resourceCost.MonthlyCostAfter
- resourceCost.MonthlyCostBefore;
estimate.ResourceCosts.Add(resourceCost);
}
estimate.TotalMonthlyBefore = estimate.ResourceCosts.Sum(
c => c.MonthlyCostBefore);
estimate.TotalMonthlyAfter = estimate.ResourceCosts.Sum(
c => c.MonthlyCostAfter);
estimate.TotalMonthlyDelta = estimate.TotalMonthlyAfter
- estimate.TotalMonthlyBefore;
estimate.TotalYearlyDelta = estimate.TotalMonthlyDelta * 12;
return estimate;
}
private decimal CalculateMonthlyCost(
PricingInfo pricing, Dictionary<string, object> attributes)
{
if (attributes == null) return 0;
return pricing.PriceComponents.Sum(component =>
{
var quantity = ExtractQuantity(component, attributes);
return quantity * component.MonthlyPrice;
});
}
}
public class CostEstimate
{
public List<ResourceCost> ResourceCosts { get; set; } = new();
public decimal TotalMonthlyBefore { get; set; }
public decimal TotalMonthlyAfter { get; set; }
public decimal TotalMonthlyDelta { get; set; }
public decimal TotalYearlyDelta { get; set; }
public string Currency { get; set; } = "USD";
public DateTimeOffset EstimatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class ResourceCost
{
public string ResourceAddress { get; set; }
public ChangeAction Action { get; set; }
public decimal MonthlyCostBefore { get; set; }
public decimal MonthlyCostAfter { get; set; }
public decimal MonthlyDelta { get; set; }
}
public class PricingInfo
{
public string Resource_type { get; set; }
public List<PriceComponent> PriceComponents { get; set; } = new();
public List<SubResource> SubResources { get; set; } = new();
}
public class PriceComponent
{
public string Name { get; set; }
public decimal MonthlyPrice { get; set; }
public string Unit { get; set; }
public Func<Dictionary<string, object>, decimal> QuantityExtractor { get; set; }
}
Pricing Examples
| Resource | Configuration | Monthly Cost | Notes |
|---|---|---|---|
| aws_instance (m5.xlarge) | Linux, on-demand, us-east-1 | $140.16 | 3,000 hours/month |
| aws_instance (t3.medium) | Linux, on-demand, us-east-1 | $30.37 | 3,000 hours/month |
| aws_s3_bucket | Standard, 100 GB, 1M PUTs | $5.40 | No data transfer |
| aws_rds_instance (db.r5.large) | Multi-AZ, 100 GB gp3 | $335.40 | Includes storage |
| aws_nat_gateway | us-east-1, 100 GB data | $44.45 | Processing charges extra |
| aws_cloudfront_distribution | 1 TB transfer, 10M requests | $85.00 | Standard pricing |
Cost estimation should be integrated into the PR workflow. When a Terraform PR is opened, the CI/CD pipeline should run terraform plan, pass the plan to Infracost, and post a comment on the PR showing the monthly cost impact. This gives reviewers the cost context they need to make informed decisions. For example, a PR that changes an instance type from t3.medium to m5.xlarge will show a monthly increase of about $110, which may or may not be justified by the performance improvement. Without this visibility, cost increases often go unnoticed until the monthly cloud bill arrives.
23. Collaboration & VCS Integration
Collaboration in Terraform projects requires careful attention to workflow design because infrastructure changes are inherently stateful and can affect shared resources. The standard workflow uses Git branches and pull requests: a developer creates a feature branch, makes Terraform changes, runs terraform plan, commits the plan file, opens a pull request, and waits for review and approval before merging to the main branch. When the PR is merged, a CI/CD pipeline automatically runs terraform apply using the approved plan.
VCS Integration Workflow
CI/CD Pipeline Configuration
yaml# GitHub Actions — Terraform Plan and Apply
name: Terraform
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
env:
TF_VERSION: "1.8.0"
AWS_REGION: "us-east-1"
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: infrastructure/
- name: Terraform Plan
id: plan
run: |
terraform plan -out=tfplan -detailed-exitcode
echo "exitcode=$?" >> "$GITHUB_OUTPUT"
working-directory: infrastructure/
- name: Cost Estimation
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Run Infracost
run: infracost breakdown --path=tfplan --format=json
working-directory: infrastructure/
- name: Security Scan
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: infrastructure/
soft_fail: true
apply:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: infrastructure/
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: infrastructure/
Collaboration Patterns
| Pattern | Description | Best For | Risk |
|---|---|---|---|
| Trunk-based | All changes go to main | Small teams, rapid iteration | Conflicts on shared state |
| Environment branches | dev, staging, prod branches | Regulated environments | Branch divergence |
| Directory per environment | Separate .tf directories | Different env configs | Code duplication |
| Workspaces | Same code, different state | Identical environments | Wrong workspace risk |
| Modules + environments | Module repo + env repos | Large organizations | Coordination overhead |
The most important collaboration rule in Terraform is that state must never be modified outside of the approved workflow. This means no manual terraform state commands, no console-based edits, and no direct backend modifications. All changes must go through the PR and pipeline process. Violating this rule is the single most common cause of state corruption and infrastructure drift in teams. Enforce this by using backend locking, restricting state backend access to the CI/CD service account only, and running drift detection as a safety net.
24. Monitoring & Observability
Monitoring an IaC platform requires tracking both the infrastructure it manages and the platform operational health itself. This includes monitoring plan and apply operations (success rates, durations, resource counts), state health (size, version count, lock contention), provider health (API latency, error rates, rate limit hits), and drift metrics (number of drifted resources, drift age, reconciliation success rate). These metrics provide the operational visibility needed to maintain the platform at scale.
Monitoring Implementation
csharppublic class TerraformMetrics
{
private readonly IMetricsCollector _metrics;
public void RecordPlanOperation(
string workspace, TimeSpan duration,
int resourceCount, int changeCount, bool success)
{
_metrics.Histogram("terraform_plan_duration_seconds",
duration.TotalSeconds,
new Dictionary<string, string>
{
{ "workspace", workspace },
{ "success", success.ToString() }
});
_metrics.Gauge("terraform_plan_resources_total",
resourceCount,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
_metrics.Gauge("terraform_plan_changes_total",
changeCount,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
_metrics.Counter("terraform_plan_operations_total",
1,
new Dictionary<string, string>
{
{ "workspace", workspace },
{ "success", success.ToString() }
});
}
public void RecordApplyOperation(
string workspace, TimeSpan duration,
int created, int updated, int deleted,
int failed, bool success)
{
_metrics.Histogram("terraform_apply_duration_seconds",
duration.TotalSeconds,
new Dictionary<string, string>
{
{ "workspace", workspace },
{ "success", success.ToString() }
});
_metrics.Gauge("terraform_apply_resources_created", created);
_metrics.Gauge("terraform_apply_resources_updated", updated);
_metrics.Gauge("terraform_apply_resources_deleted", deleted);
_metrics.Gauge("terraform_apply_resources_failed", failed);
}
public void RecordStateMetrics(
string workspace, long stateSize,
long serial, int resourceCount)
{
_metrics.Gauge("terraform_state_size_bytes",
stateSize,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
_metrics.Gauge("terraform_state_serial",
serial,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
_metrics.Gauge("terraform_state_resources_total",
resourceCount,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
}
public void RecordDriftMetrics(
string workspace, int totalResources,
int driftedResources)
{
_metrics.Gauge("terraform_drift_total_resources",
totalResources,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
_metrics.Gauge("terraform_drift_drifted_resources",
driftedResources,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
var driftPercentage = totalResources > 0
? (double)driftedResources / totalResources * 100
: 0;
_metrics.Gauge("terraform_drift_percentage",
driftPercentage,
new Dictionary<string, string>
{
{ "workspace", workspace }
});
}
}
Key Metrics to Monitor
| Metric | Alert Threshold | Description |
|---|---|---|
| terraform_apply_duration_seconds | > 1800 | Apply taking too long, investigate resource bottleneck |
| terraform_apply_resources_failed | > 0 | Resource creation/update failed |
| terraform_state_size_bytes | > 50 MB | State growing too large, consider splitting |
| terraform_drift_percentage | > 10% | Significant drift detected |
| terraform_lock_contention_total | > 10/hour | High lock contention, teams blocking each other |
| terraform_provider_api_latency_seconds | > 5 | Cloud API slowing down |
| terraform_plan_operations_total | Spike detection | Unusual plan activity |
Observability for IaC platforms extends beyond infrastructure metrics. You should also track developer experience metrics like plan-to-apply time (how long from plan creation to actual apply), approval wait time (how long PRs sit before review), and change frequency (how often infrastructure changes are deployed). These metrics help identify bottlenecks in the workflow — for example, if approval wait time is consistently high, you may need to add more reviewers or implement tiered approval policies where low-risk changes (tag updates) require fewer approvals than high-risk changes (new database instances).
25. Interview Q&A
Q1: Why does Terraform use a state file instead of just comparing configuration to the cloud?
A: State is necessary for several reasons. First, not all resource attributes are known from configuration — for example, an EC2 instance public IP is assigned by AWS and cannot be predicted. The state file stores these computed values so Terraform can detect changes. Second, state tracks which resources are managed by Terraform versus which are managed by other tools or created manually. Without state, Terraform might try to create resources that already exist or delete resources it should keep. Third, state enables efficient plan generation by allowing Terraform to compare only the attributes it manages, ignoring unrelated changes. Fourth, state enables the dependency graph to be resolved at apply time using the actual IDs stored in state rather than recomputing them from scratch.
Q2: How would you handle a situation where the state file is corrupted or lost?
A: State corruption or loss is one of the most serious incidents in infrastructure management. The recovery strategy depends on the severity. If you have a recent state backup (from S3 versioning or state history), you can restore it using terraform state push. If the state is partially corrupted, you can use terraform refresh to rebuild the state by reading current resource attributes from the cloud APIs, then manually fixing any missing or incorrect entries. If the state is completely lost, you must use terraform import to re-import every resource, which is time-consuming but ensures Terraform knows about all existing infrastructure. The best defense is prevention: use remote state with versioning, enable state locking, run regular state backups, and never edit state manually.
Q3: Explain how Terraform handles concurrent applies to the same workspace.
A: Terraform uses distributed locking to prevent concurrent applies. When terraform apply starts, it attempts to acquire a lock on the state file using the backend locking mechanism (DynamoDB for S3, Consul session for Consul, blob lease for Azure). If another apply is already running, the lock acquisition fails and Terraform waits or errors out depending on the -lock-timeout flag. When the first apply completes, it releases the lock and the waiting apply can proceed. The key invariants are: only one apply can run at a time per workspace, the lock includes the serial number to detect if the state changed during the operation, and lock acquisition is atomic to prevent race conditions. For organizations that need parallel applies, they should use multiple workspaces or split their infrastructure into independent modules that use separate state files.
Q4: What are the tradeoffs between Terraform workspaces and directory-based environment separation?
A: Workspaces share the same configuration and only differ in state, which reduces code duplication but increases the risk of accidentally applying changes to the wrong environment. Directory-based separation duplicates the configuration across environments, which makes it easy to customize each environment independently but increases maintenance burden when changes need to be propagated. Workspaces are best for environments that are structurally identical but differ in sizing and variable values (dev/staging/prod with the same resources but different instance counts). Directory separation is better when environments have structurally different requirements (production has WAF rules and audit logging that staging does not need). Many teams use a hybrid approach: a shared modules repository with environment-specific root configurations that reference those modules with different variables.
Q5: How would you design an IaC platform that supports 1,000 engineers making concurrent changes?
A: At this scale, you need a multi-layer architecture. First, split infrastructure into independent domains (networking, compute, databases, observability) each with their own state files to eliminate cross-domain lock contention. Second, implement a run queue with priority levels so critical changes (security patches) are not blocked by routine changes (tag updates). Third, use workspace-per-developer sandboxes for experimentation, with promotion pipelines that merge changes into shared environments after review. Fourth, implement a state caching layer that reduces backend reads during plan operations. Fifth, use provider connection pooling to avoid exhausting cloud API rate limits when many applies run concurrently. Sixth, implement a global drift detection service that runs asynchronously and alerts when manual changes are detected. The key principle is isolation: minimize shared mutable state while maximizing parallelism.
Q6: Compare Terraform approach to infrastructure management with Kubernetes declarative model.
A: Both use a declarative model where you describe desired state and the system converges to it, but they differ in execution. Kubernetes uses a continuous reconciliation loop — the kubelet constantly checks actual state against desired state and takes corrective action. Terraform uses a two-phase model — plan identifies changes, then apply executes them. Kubernetes reconciliation is eventually consistent and handles drift automatically, while Terraform requires explicit drift detection runs. Kubernetes manages resources within a single cluster, while Terraform manages resources across heterogeneous APIs. Kubernetes supports real-time updates through watch streams, while Terraform uses polling. Kubernetes has a built-in dependency model through owner references and controllers, while Terraform builds a DAG from explicit and implicit dependencies. In practice, teams use both: Terraform to provision the Kubernetes cluster and infrastructure, and Kubernetes to manage the workloads within it.
Q7: Design a migration strategy from CloudFormation to Terraform.
A: The migration should be incremental, not big-bang. First, inventory all CloudFormation stacks and categorize them by complexity and criticality. Start with simple, non-critical stacks to build expertise. Use cfn2tf or cf-to-tf tools to auto-convert CloudFormation templates to Terraform configuration, then manually refine the output. Import existing resources using terraform import for each resource. Validate that the imported state matches reality by running terraform plan with refresh-only mode. Once a stack is fully under Terraform management, delete the CloudFormation stack without deleting resources (using the retain DeletionPolicy). For complex stacks with custom resources, you may need to write custom Terraform providers or use Terraform external data sources to bridge the gap. The entire migration should be version-controlled, tested in a staging environment, and executed during a maintenance window for production stacks.
Q8: How do you handle secrets management in Terraform configurations?
A: Secrets in Terraform require careful handling at multiple levels. Never hardcode secrets in .tf files. Use data sources to reference secrets at plan time from external secret managers like AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault. For secrets that must be passed as variables, use sensitive variable declarations and .tfvars files that are excluded from version control. The state file itself can contain secrets in resource attributes, so state must be encrypted at rest and access must be restricted. Use the sensitive() function to mark values that should not appear in plan output. For CI/CD, inject secrets through environment variables or encrypted variable files rather than storing them in the repository. Consider using a secret rotation strategy where secrets are periodically regenerated and updated in the secret manager, with Terraform reading the latest version at each plan time.
Conclusion
Building a Terraform-style Infrastructure as Code platform is one of the most comprehensive systems design challenges in modern software engineering. It requires deep knowledge of language parsing, graph algorithms, distributed systems, API design, state management, plugin architectures, security, and operational concerns. The platform must be reliable enough to manage production infrastructure, extensible enough to support hundreds of cloud providers, and usable enough that developers can adopt it without extensive training.
The key architectural insights from this guide are: state management is the most critical component because it bridges desired and actual state; the resource graph enables intelligent parallel execution; the provider plugin system provides the extensibility that makes Terraform universally applicable; the plan/apply workflow enables review and policy enforcement; and the module system enables code reuse and standardization. These principles apply not just to Terraform but to any infrastructure management platform, including Cloud Deployment Manager, ARM Templates, Pulumi, and CDK.
As the industry moves toward platform engineering and internal developer platforms, understanding IaC internals becomes increasingly valuable. Platform teams need to build self-service abstractions on top of Terraform, implement policy guardrails, integrate cost estimation, and provide developer-friendly interfaces that hide the complexity of infrastructure management. This guide provides the foundation for doing that work effectively.