Terraform — Architecture#
Architectural style#
Graph-Walking Engine with Plugin-Based Providers, Layered CLI, and Parallel Machine API
Terraform’s architecture is built around a declarative graph execution engine: every operation (plan, apply, destroy, import, validate) is modeled as a directed acyclic graph (DAG) walk. The graph is constructed fresh for each operation through a transform pipeline (series of GraphTransformer steps), then walked in parallel respecting dependency edges. Providers — the components that actually interact with cloud APIs — are isolated as out-of-process gRPC subprocesses reached through the hashicorp/go-plugin framework.
A layered structure sits above the engine: a CLI layer (command/) handles user interaction, a backend layer (backend/) abstracts state storage and whether to run locally or in HCP Terraform, and a config layer (configs/) parses HCL source into in-memory structures the engine can consume.
A parallel architectural track — the Stacks system — reimplements the same engine concept but targets a machine-facing gRPC API (rpcapi) rather than the CLI, uses a promise-based async evaluator, and has its own address types, config parser, and wire protocol.
Evidence: internal/terraform/graph_builder.go, internal/dag/walk.go, internal/providers/interface.go, commands.go, internal/rpcapi/server.go.
Component diagram (textual)#
┌────────────────────────────────────────────────────────────────────┐
│ main.go (realMain) │
│ CLI Bootstrap: OTel → terminal → cliconfig → credentials → │
│ providerSrc → backendInit.Init() → cli.CLI.Run() │
└──────────────────────────┬─────────────────────────────────────────┘
│
┌────────────▼────────────┐
│ command.Meta (shared) │ (value-copied per command)
│ WorkingDir / Streams │
│ Services / ProviderSrc │
└────────────┬────────────┘
│ instantiates
┌─────────────────────┼──────────────────────────┐
│ command.*Command │ (50+ subcommands) │
│ ApplyCommand │ PlanCommand │
│ InitCommand │ ValidateCommand │
└──────────┬──────────┴──────────────────────────┘
│ selects and delegates to
┌──────────▼───────────────────────────────────────┐
│ backend.Backend / backendrun.Running │
│ local.Local | remote.Remote | cloud.Cloud │
└──────────┬────────────────────────────────────────┘
│ calls
┌──────────▼────────────────────────────────────────────────┐
│ terraform.Context │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Plan() → planGraph() → PlanGraphBuilder │ │
│ │ Apply() → applyGraph() → ApplyGraphBuilder │ │
│ │ Import() → context_import.go │ │
│ │ Validate() → context_validate.go │ │
│ └────────────────┬─────────────────────────────────────┘ │
│ │ builds DAG via transform pipeline │
│ ┌────────────────▼─────────────────────────────────────┐ │
│ │ dag.Graph + dag.Walker (parallel walk) │ │
│ │ Each vertex: GraphNodeExecutable.Execute(ctx, op) │ │
│ └────────────────┬─────────────────────────────────────┘ │
│ │ nodes call │
│ ┌────────────────▼─────────────────────────────────────┐ │
│ │ EvalContext / BuiltinEvalContext │ │
│ │ (providers, provisioners, state, named values) │ │
│ └────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
│
┌──────────▼─────────────────────────────────────────────────┐
│ providers.Interface (gRPC subprocess) │
│ plugin5 (tfplugin5.proto) + plugin6 (tfplugin6.proto) │
│ grpcwrap: providers.Interface → gRPC server impl │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ Parallel Track: Stacks (machine-facing) │
│ rpcapi → stacksruntime → stackeval (promise-based) → │
│ stacksplugin (stacksproto1.proto) │
│ Entry: "rpcapi" CLI command → go-plugin server (gRPC) │
└────────────────────────────────────────────────────────────────────┘Core components#
1. CLI Bootstrap (main.go + commands.go)#
- Package: root
main - Responsibility: Initialize the full application: OpenTelemetry span, terminal detection, CLI config loading, credentials, provider installation source, backend registry. Register all subcommands into a
map[string]cli.CommandFactory. Delegate tohashicorp/cli. - Key types:
realMain(),initCommands(),command.Meta(shared across all commands as value copy) - Dependencies:
internal/command,internal/backend/init,internal/terminal,internal/logging,internal/rpcapi
2. Command Layer (internal/command)#
- Package:
internal/command - Responsibility: Implement each user-facing CLI subcommand. Each command embeds
command.Meta, which provides access to the working directory, backend selection, provider source, terminal streams, and output views. Commands parse flags via stdlibflag, delegate heavy lifting to the backend or directly toterraform.Context. - Key types:
Meta(shared base struct),ApplyCommand,PlanCommand,InitCommand,TestCommand(and ~45 more).StateMetafor state-modifying commands. - Dependencies:
internal/backend,internal/terraform,internal/configs/configload,internal/command/views,internal/tfdiags - Notable sub-packages:
views/(human vs JSON output abstraction),arguments/(typed argument structs per command),format/(legacy human-readable formatters),json*/(machine-readable JSON output renderers)
3. Backend Layer (internal/backend)#
- Package:
internal/backend,internal/backend/backendrun,internal/backend/local,internal/backend/remote - Responsibility: Abstract where state lives and where operations execute. The
Backendinterface handles state management. Thebackendrun.Runninginterface handles plan/apply execution. The local backend runs the graph walk in-process; the remote backend streams operations to HCP Terraform. - Key types:
backend.Backend(interface: ConfigSchema, PrepareConfig, Configure, StateMgr, Workspaces),backendrun.Running(interface: LocalRun, PlanWithUI, ApplyWithUI),local.Local(default in-process runner) - Dependencies:
internal/terraform,internal/states/statemgr,internal/configs/configload - Notable: Nine remote-state backends (S3, GCS, Azure, Consul, k8s, PG, OCI, COS, OSS) are separate Go modules with their own
go.mod, wired viareplacedirectives to avoid cross-contaminating cloud SDK dependencies.
4. Core Engine (internal/terraform)#
- Package:
internal/terraform - Responsibility: The heart of Terraform. Implements the
Contexttype whose methods (Plan,Apply,Import,Validate,Refresh) each build a specialized DAG and walk it. Graph nodes encapsulate the logic for each HCL object type (resource, provider, variable, output, module, etc.). The walk is parallel with dependency ordering enforced by the DAG. - Key types:
Context— orchestrator; holds plugin registry (contextPlugins), parallelism semaphore, stop hookGraphBuilderinterface —Build(ModuleInstance) (*Graph, tfdiags.Diagnostics)BasicGraphBuilder— runs a list ofGraphTransformersteps to build the graphPlanGraphBuilder,ApplyGraphBuilder,EvalGraphBuilder,InitGraphBuilder— operation-specific buildersGraphNodeExecutable—Execute(EvalContext, walkOperation) tfdiags.DiagnosticsGraphNodeDynamicExpandable— nodes that expand into sub-graphs at walk time (forcount/for_each)EvalContext/BuiltinEvalContext— per-module evaluation context threaded through graph walkContextGraphWalker—dag.Walkercallback implementation that routes each node to itsEvalContextHook— observer interface for UI progress reporting (pre/post resource CRUD)
- Dependencies:
internal/dag,internal/configs,internal/addrs,internal/states,internal/plans,internal/providers,internal/provisioners,internal/lang,internal/tfdiags
5. DAG Engine (internal/dag)#
- Package:
internal/dag - Responsibility: Pure graph library: add/remove vertices and edges, topological sort, Tarjan’s SCC (cycle detection), transitive reduction, parallel walk. No Terraform-specific logic — this is a reusable foundation.
- Key types:
Graph,AcyclicGraph,Walker(parallel walk withWalkFunccallback),Edge,Vertex(interface{}),NamedVertex(optional interface for display names) - Dependencies:
internal/tfdiags(for diagnostic collection during walk); nothing else internal - Notable:
Walkercreates2Vgoroutines — one per vertex for execution, one per vertex as a dependency waiter. Walk-time graph mutations are supported.
6. Provider Plugin System (internal/providers, internal/plugin, internal/plugin6, internal/grpcwrap)#
- Package:
internal/providers,internal/plugin,internal/plugin6,internal/grpcwrap,internal/tfplugin5,internal/tfplugin6 - Responsibility: Isolate provider implementations as subprocesses.
providers.Interfacedefines the 30-method contract for any provider (GetProviderSchema, ValidateProviderConfig, PlanResourceChange, ApplyResourceChange, ReadResource, etc.). Providers are launched viahashicorp/go-plugin(gRPC over stdin/stdout). Two wire protocols are maintained simultaneously: Protocol 5 (tfplugin5.proto) and Protocol 6 (tfplugin6.proto). Thegrpcwrappackage adaptsproviders.Interfaceimplementations to serve as gRPC servers (for test providers embedded in Terraform itself). - Key types:
providers.Interface,providers.Factory(func() (Interface, error)),plugin.GRPCProvider(Protocol 5 client),plugin6.GRPCProvider(Protocol 6 client) - Dependencies:
hashicorp/go-plugin, protobuf-generated stubs,internal/configs/configschema
7. Config Parser (internal/configs)#
- Package:
internal/configs - Responsibility: Parse HCL source files into in-memory Go types. Handles
terraform {},resource,data,provider,variable,output,locals,module,moved,import,check,removedblocks. Module tree loading (from disk or registry) is ininternal/configs/configload. - Key types:
Config(tree of modules),Module(single directory),Resource,Provider,Variable,Output,ModuleCall - Dependencies:
hashicorp/hcl/v2,internal/addrs,internal/tfdiags
8. Address Types (internal/addrs)#
- Package:
internal/addrs - Responsibility: Define a rich type system for every referenceable object in Terraform:
ResourceInstance,Provider,Module,ModuleInstance,InputVariable,OutputValue,LocalValue,PathAttr, etc. (~40 distinct address types). Used everywhere as typed identifiers — prevents address confusion bugs. - Key types:
AbsResourceInstance,AbsProviderConfig,ModuleInstance,Provider,RootProviderConfig - Dependencies:
github.com/zclconf/go-cty/cty,hashicorp/hcl/v2
9. State / Plan Data (internal/states, internal/plans)#
- Package:
internal/states,internal/plans - Responsibility: In-memory representations of current state and planned changes.
states.Stateis the live resource inventory;plans.Changesis the diff.states/statemgrdefinesStateMgrinterfaces (Locker, Reader, Writer, Refresher) implemented by each backend. Statefile v4 JSON serialization instates/statefile. - Key types:
states.State,states.SyncState(mutex-wrapped for concurrent access),plans.Plan,plans.Changes,plans.ChangesSync,statemgr.Full
10. Diagnostics (internal/tfdiags)#
- Package:
internal/tfdiags - Responsibility: Unified diagnostic type system (wrapping HCL diagnostics and adding Terraform-specific ones). Diagnostics carry source locations, severity (error/warning), and rich structured context. Used as the primary error-propagation mechanism throughout the codebase instead of bare
error. - Key types:
Diagnostics(slice),Diagnostic(interface),AttributeValue,WholeContainingBody
11. Stacks Runtime (internal/stacks, internal/rpcapi)#
- Package:
internal/stacks,internal/rpcapi - Responsibility: A parallel execution model for multi-configuration orchestration (Stacks). Uses a promise-based concurrent evaluator (
stackeval), its own HCL config format (.tfstack.hcl), its own address types (stackaddrs), and communicates via gRPC (stacksproto1.proto) rather than CLI. Entry point is therpcapi“hidden” command which runs Terraform as a go-plugin server. - Key types:
rpcapi.ServePlugin(),stacks/stackruntime,promising.Promise[T](generic future type)
Data flow#
Classic terraform plan#
User runs: terraform plan
1. main.go:realMain()
└── terminal.Init() // detect TTY, width
└── cliconfig.LoadConfig() // ~/.terraformrc
└── credentialsSource() // API tokens
└── providerSource() // build installation search path
└── backendInit.Init(services) // register backend constructors
└── initCommands() // build Commands map
└── cli.CLI.Run() // dispatch to "plan"
2. command.PlanCommand.Run(args)
└── arguments.ParsePlan(args) // typed flag struct
└── meta.Backend(backendConfig) // instantiate + configure backend
└── backend/init registry lookup // name → constructor
└── backend.PrepareConfig()
└── backend.Configure()
└── backendrun.Operation{...} // package up the operation request
└── b.RunOperation(op) // local or remote
3. backend/local.Local.RunOperation(op)
└── local.LocalRun(op)
└── configload.LoadConfig(dir) // parse all .tf files → configs.Config
└── statemgr.Lock() // acquire state lock
└── stateMgr.RefreshState() // read current state from backend
└── local.opPlan(op, lr, sm)
└── terraform.NewContext(opts) // create core engine
└── c.Plan(config, state, opts)
4. terraform.Context.Plan()
└── c.planGraph(config, state, opts)
└── PlanGraphBuilder{...}.Build(root)
└── BasicGraphBuilder.Steps[] // ~15 GraphTransformer steps
// Steps add: resource nodes, provider nodes, output nodes,
// variable nodes, module expand nodes, dependency edges,
// provider inheritance edges, transitive reduction, etc.
└── dag.Walker.Update(graph)
└── dag.Walker.Wait() // parallel walk begins
// For each vertex (in dependency order, parallel when possible):
└── ContextGraphWalker.EnterPath() // get/create BuiltinEvalContext
└── node.Execute(ctx, walkPlan)
// e.g. NodeAbstractResourceInstance.Execute():
└── ctx.Provider(addr) // get/launch provider subprocess
└── provider.GetProviderSchema() // fetch schema (cached)
└── evaluate HCL expressions // resolve references from namedvals
└── provider.PlanResourceChange(req) // gRPC call to provider
└── ctx.Changes().AppendResourceInstanceChange(change)
5. Result: plans.Plan{Changes, Config, State, ...}
└── planfile.Create() // write .tfplan file (zip)
└── views.Plan.Render(plan) // human or JSON outputPlugin lifecycle within a graph walk#
context_plugins.go: contextPlugins.startProvider(addr)
└── Factory()(providers.Factory function)
└── plugin.NewGRPCProvider(clientConfig) // for Protocol 5
└── hashicorp/go-plugin: exec subprocess
└── negotiate gRPC handshake over stdin/stdout
└── grpc.Dial() to plugin's listener
└── return providers.Interface (gRPC client stub)
└── cached in ContextGraphWalker.providerCache[addr]
Subsequent calls: provider.PlanResourceChange() → gRPC → subprocess provider binaryInitialization / Bootstrap#
The bootstrap sequence in main.go:realMain() is strictly linear and manual — no dependency injection framework:
- OpenTelemetry —
openTelemetryInit(): optional OTLP exporter ifTF_CLI_TELEMETRY_EXPORTER_OTLP_ENDPOINTset - Terminal —
terminal.Init(): detect stdout/stderr/stdin TTY status and column width - CLI Config —
cliconfig.LoadConfig(): parse~/.terraformrcor$TERRAFORM_CONFIG_FILE - Service Discovery —
disco.NewWithCredentialsSource(credsSrc): HTTPS host service discovery (for registry, remote backend) - Provider Source —
providerSource(config, services): build provider installation search path (registry, local mirror, filesystem mirror) - Backend Registry —
backendInit.Init(services): register all backend constructors into a global map (name →InitFn) - Command Map —
initCommands(ctx, wd, streams, config, services, providerSrc, ...): construct singlecommand.Metavalue; buildCommandsmap (50+cli.CommandFactoryclosures capturingmetaby value-copy) - Checkpoint —
go runCheckpoint(ctx, config): async version check against HashiCorp’s checkpoint service - CLI Dispatch —
cli.CLI{Args, Commands}.Run(): parse subcommand, run factory, execute
Dependency injection pattern: Manual. The command.Meta struct is populated once and value-copied into every command struct at registration time. There is no wire/dig/fx. The terraform.Context receives providers and provisioners as map-of-factories at construction time (ContextOpts.Providers map[addrs.Provider]providers.Factory).
Configuration#
Configuration exists at three levels:
1. CLI Configuration (~/.terraformrc)#
- Parser:
internal/command/cliconfig(HCL) - Key settings:
credentialsblocks (API tokens per host),provider_installationblock (filesystem/registry mirrors, network exclusions),plugin_cache_dir,plugin_cache_may_break_dependency_lock_file - Override:
TERRAFORM_CONFIG_FILEenvironment variable,TF_CLI_ARGS_*for per-command flag injection
2. Root Module Configuration (.tf files in working directory)#
- Parser:
internal/configs(HCL v2 viahashicorp/hcl/v2) - Consumed by:
terraform.Context.Plan(config, ...)— the parsed*configs.Configtree is the configuration input to every operation - Variables: Set via
-var,-var-file,TF_VAR_*environment variables, or interactive prompt
3. Backend Configuration (in .tf files + .terraform/terraform.tfstate)#
- Parser:
internal/backend(backend.Backend.ConfigSchema()+ HCL) - Persistence: Backend selection is recorded in
.terraform/terraform.tfstate(not to be confused with resource state) - Workspace isolation: Each workspace has its own state; backends control the naming convention
Feature flags / experiments#
- Compile-time:
main.experimentsAllowedldflagcontrols whether experimental features are available - Runtime:
internal/experimentspackage holds the experiment registry; individual experiments can be enabled per-module in HCLterraform { experiments = [...] }
Key design decisions#
1. Graph-per-operation (not a single universal graph)#
Each operation builds a fresh, operation-specific graph via a different GraphBuilder. The PlanGraphBuilder and ApplyGraphBuilder differ in which node types they add (e.g., ApplyGraphBuilder includes OrphanResourceInstanceTransformer and respects create_before_destroy ordering). This avoids a single god-graph that handles all modes with conditional logic scattered through nodes. The transform pipeline ([]GraphTransformer steps) makes the construction composable and traceable via GRAPH_TRACE logs.
Evidence: internal/terraform/graph_builder_plan.go, internal/terraform/graph_builder_apply.go, internal/terraform/graph_builder.go
2. Out-of-process providers (no in-process loading)#
Providers are always subprocesses — there is no in-process provider loading. The providers.Interface is implemented by a gRPC client stub that calls the provider binary over the go-plugin protocol. This provides complete memory and crash isolation (a provider panic cannot kill terraform), but has overhead: each gRPC call crosses process boundaries. The PreloadedProviderSchemas optimization in ContextOpts amortizes the schema-fetching overhead when running many operations in sequence.
Evidence: internal/plugin/grpc_provider.go, internal/providers/interface.go
3. Dual protocol versions in production simultaneously#
Terraform maintains Protocol 5 and Protocol 6 simultaneously with full feature parity. Providers compiled against either SDK work without modification. The grpcwrap adapters translate between the two wire formats. This reflects the real-world constraint of a large provider ecosystem where not all providers can be upgraded atomically.
Evidence: internal/tfplugin5/, internal/tfplugin6/, internal/grpcwrap/
4. All-internal architecture (zero exported packages)#
Every package outside the root main is internal/. This is a deliberate and permanent decision: Terraform core is not intended to be embedded as a library. External tooling integrates through the gRPC plugin protocol or the rpcapi interface. This eliminates API stability obligations and allows fearless refactoring of internal APIs without semver concerns.
Evidence: Structure analysis; internal/rpcapi docs note it is the intended machine-facing entry point
5. Diagnostics as the primary error type#
Terraform uses tfdiags.Diagnostics (a slice of Diagnostic) rather than error as its primary error propagation mechanism throughout the internal stack. This allows accumulating multiple errors in a single pass (a graph walk can discover 50 resource errors simultaneously) and attaching source location context (file/line from the HCL parser). Bare error returns appear only at the Go standard library boundary and in lower-level utility packages.
Evidence: internal/tfdiags/, virtually every internal package’s function signatures
6. Stacks as a parallel architecture (not an evolution)#
The Stacks system in internal/stacks/ is not a refactoring of the classic engine — it is a parallel implementation sharing only the DAG library, address primitives, and provider protocol. Stacks use promise-based async evaluation (internal/promising) instead of the goroutine-walker model, communicate via rpcapi gRPC instead of CLI, and have entirely separate config, state, and plan data types. This co-existence design allows HCP Terraform’s orchestration layer to use Terraform’s core as a library-via-RPC while the CLI experience remains unchanged.
Evidence: internal/stacks/stackruntime/, internal/promising/, internal/rpcapi/