Graft is a CLI tool that lets you customize any Terraform module — override values, inject resources, remove blocks, and absorb drift — allowing you to apply declarative patches to third-party modules at build time.
No forking required. Your main.tf keeps pointing to the upstream registry module; Graft applies your changes on top at build time.
Graft allows you to surgically modify any Terraform module, even if you don't own the source code.
- Override Hardcoded Values
Change any attribute inside a module that wasn't exposed as a variable.
Example: Force
vm_size = "Standard_D2s_v3"on a module that hardcoded"Standard_B1s". - Inject New Logic
Add new resources, data sources, or outputs to an existing module context.
Example: Inject a
azurerm_monitor_diagnostic_settingor an extraazurerm_role_assignmentinto a community AKS module. - Remove Attributes & Resources
Delete unwanted resources or blocks from upstream modules—something native Terraform overrides cannot do.
Example: Remove a default
azurerm_network_security_rulethat violates your company policy. - Absorb External Drift
Automatically generate override manifests from Terraform plan drift, so external changes (Azure Policy, manual edits) become part of the config.
Example: Azure Policy added
CreatorandDateCreatedtags to your resources. Rungraft absorb plan.jsonto accept them. - Zero-Fork Maintenance
Keep your
main.tfpointing to the official upstream version (e.g., v5.0.0). When upstream updates, you just bump the version; your patches are re-applied automatically.
brew tap ms-henglu/graft
brew install graftOr in a single command:
brew install ms-henglu/graft/graftgo install github.com/ms-henglu/graft@latestDownload the appropriate binary for your platform from the Releases page.
Check out the examples directory for practical scenarios:
- Basic Overrides
- Injecting New Logic
- Remove Attributes & Resources
- Referencing Source Values
- Handling For-Each Resources
- Multi-Layer Overrides
- Scaffolding a Manifest
- Lifecycle: Ignore Changes
- Lifecycle: Prevent Destroy
- Mark Values as Sensitive
- Nested Block Override
- Absorb Tag Drift
- Absorb Security Rule Drift
- Absorb Indexed Resource Drift
- Absorb Indexed Block Drift
- Absorb Public Module Drift
For a detailed step-by-step guide on the absorb command, see How to Use graft absorb.
graft operates as a Build-Time Transpiler for your infrastructure code.
[Upstream Registry] [Graft Manifest]
| |
(Downloads) (Defines)
| |
v v
+------------------------------------------+
| graft CLI |
+------------------------------------------+
| |
(Generates) (Updates)
| |
v v
[.graft/ Directory] [.terraform/modules/modules.json]
|
v
[ Terraform ]
- Vendor: It downloads/copies the specified upstream module to a local
.graft/directory. - Patch: It parses the graft manifest (
*.graft.hcl) and applies your modifications inside the vendored directory through three specific mechanisms:- Generation of
_graft_add.tf: New resources or blocks defined in your manifest are written to this file, effectively appending them to the module. - Generation of
_graft_override.tf: Attribute overrides are written to this file, leveraging Terraform's native override behavior to merge configurations. - Source Modification: Changes that cannot be handled by overrides—such as removing resources or specific attributes—are applied directly to the source files within the vendored directory.
- Generation of
- Link: It updates
.terraform/modules/modules.jsonto point module paths to the local.graft/directory. This allowsmain.tfto remain unchanged (pointing to the original Registry version) while Terraform executes your patched code. - Run: You run
terraform plan/applyas normal. To Terraform, it looks like it's running the registry module, but it's actually running your local graft.
Vendors modules, applies local patches, and configures Terraform to use them using the "Linker Strategy".
# Vendors modules and redirects .terraform/modules/modules.json to point to them
# Auto-discovers all *.graft.hcl files in the current directory
graft build
[+] Reading 2 graft manifests...
[+] Vendoring modules...
- linux_servers (v5.3.0) [Cache Hit]
- linux_servers.os (Local)
- network (v5.3.0) [Cache Hit]
[+] Applying patches...
- linux_servers: 1 override
- linux_servers.os: 1 override
- network: 1 override
[+] Linking modules...
✨ Build complete!You can also specify a single graft manifest explicitly:
graft build -m custom.graft.hcl- Behavior:
- Vendor: Copies modules to
.graft/build/. - Patch: Applies
overriderules. - Link: Updates
.terraform/modules/modules.jsonto point the moduleDirto the local.graft/build/path.
- Vendor: Copies modules to
Interactively scans your project modules and generates a graft manifest (scaffold.graft.hcl).
It automatically discovers all module calls in your project, displays a tree view of the module hierarchy, and generates a boilerplate manifest with placeholder overrides for every resource found.
# Generate scaffold for all modules
graft scaffold
[+] Discovering modules in .terraform/modules...
root
├── linux_servers (registry: registry.terraform.io/Azure/compute/azurerm, 5.3.0)
│ ├── [18 resources]
│ └── linux_servers.os (local: ./os)
│ └── [0 resources]
└── network (registry: registry.terraform.io/Azure/network/azurerm, 5.3.0)
└── [3 resources]
-> Tip: Run 'graft scaffold <MODULE_KEY>' to generate a manifest for a specific module.
-> Example: graft scaffold linux_servers.os
✨ Graft manifest saved to ./scaffold.graft.hclYou can also scaffold for a specific module:
graft scaffold linux_servers.osAbsorbs drift from a Terraform plan into a graft manifest (absorb.graft.hcl).
This command analyzes a Terraform plan JSON file to identify resources with "update" actions (drift) and generates override blocks to match the current remote state. When a providers schema is available, it improves the accuracy of the generated configuration.
# Workflow:
# 1. Generate a plan and convert to JSON
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
# 2. Absorb drift into a graft manifest
graft absorb plan.json
[+] Fetching providers schema...
[+] Reading Terraform plan JSON...
[+] Found 2 resource(s) with drift...
- azurerm_resource_group.test
- azurerm_network_security_group.test
[+] Generating manifest...
✨ Manifest saved to ./absorb.graft.hcl
# 3. Build and verify
graft build
terraform plan # should show zero changesYou can also provide a pre-generated providers schema file:
terraform providers schema -json > providers.json
graft absorb -p providers.json plan.json- Behavior:
- Parse: Reads the Terraform plan JSON and identifies resources with drift.
- Generate: Produces an
absorb.graft.hclmanifest with override blocks that align the Terraform state with the current remote state.
Cleans up graft artifacts and resets module redirection to upstream.
graft clean
[+] Removing build artifacts...
- .graft directory
- _graft_override.tf
[+] Resetting module links...
- modules.json updated
-> Next Step: Run 'terraform init' to restore original paths.
✨ Clean complete!- Behavior:
- Removes
.graft/directory. - Removes
_graft_override.tf. - Resets
.terraform/modules/modules.jsonto point back to original sources.
- Removes
The graft manifest (typically manifest.graft.hcl or any *.graft.hcl file) acts as an enhanced version of Terraform Override Files. It retains standard Terraform behavior, while introducing powerful capabilities for adding, modifying, and removing infrastructure elements.
Graft supports splitting your manifest across multiple *.graft.hcl files. When you run graft build, all graft manifests in the current directory are automatically discovered, sorted alphabetically, and deep-merged together.
Merge Behavior:
- Files are processed in alphabetical order (e.g.,
a.graft.hclbeforeb.graft.hcl). - For conflicting attributes, last write wins (later files override earlier ones).
- Blocks are merged by type and labels (e.g., two
resource "azurerm_virtual_network" "main"blocks are merged, not duplicated).
A graft manifest uses module blocks to navigate the dependency tree and override blocks to apply changes.
# filename: manifest.graft.hcl
# Root module override
override {
}
# Target a module by its name in the upstream source
module "networking" {
# Apply overrides within this module's context
override {
resource "azurerm_virtual_network" "main" {
tags = { Environment = "Production" }
}
}
}Standard Terraform override files can only modify existing resources. The graft manifest extends this by allowing you to define new top-level blocks (resources, outputs, providers, locals) inside an override block. These are appended to the target module.
override {
# This resource does not exist in the upstream module; it will be added.
resource "azurerm_storage_account" "extra_logs" {
name = "myapplogs"
}
# This output does not exist in the upstream module; it will be added.
output "new_output" {
value = "This is a new output added by graft"
}
}Graft introduces the _graft block to perform destructive actions, a capability not present in native Terraform overrides. You can remove attributes, nested blocks, or entire resources.
The remove argument accepts a list of strings, each representing the name of an attribute, nested block, or resource to be removed. It can also accept the special value "self" to indicate the entire block should be removed. You can also use dot notation to remove attributes inside nested blocks.
override {
resource "azurerm_virtual_network" "web" {
# Remove specific attributes, nested blocks, or nested attributes (using dot notation)
_graft {
remove = ["description", "ingress", "timeouts.create"]
}
}
module "legacy_db" {
# Remove the entire module call
_graft {
remove = ["self"]
}
}
}In native Terraform overrides, defining an attribute completely replaces the original value (e.g., overriding tags wipes out the original tags).
Graft solves this by introducing the graft.source expression, which references the original value defined in the upstream module. This allows you to append to lists or merge maps instead of overwriting them.
override {
resource "azurerm_virtual_network" "app" {
# Native override would delete original tags.
# graft.source lets us keep them and append a new one.
tags = merge(graft.source, {
"PatchedBy" = "Graft"
})
}
}This generates the following _graft_override.tf inside the vendored module:
# filename: _graft_override.tf
resource "azurerm_virtual_network" "app" {
tags = merge(
{
"Environment" = "Staging"
},
{
"PatchedBy" = "Graft"
}
)
}We'll consider to add more advanced features in future releases, such as build-time variables and glob matching.
Graft extends Terraform's native override behavior to provide more intuitive merging for nested blocks.
In native Terraform overrides, nested blocks are replaced entirely. This means if you override a single attribute in a nested block, you lose all other attributes from the source.
Graft performs deep merge on nested blocks, preserving original attributes while applying your overrides:
# Source module (main.tf)
resource "azurerm_virtual_network" "main" {
subnet {
name = "subnet1"
address_prefixes = ["10.0.1.0/24"]
}
}
# Graft manifest
override {
resource "azurerm_virtual_network" "main" {
subnet {
default_outbound_access_enabled = false # Add new attribute
}
}
}
# Generated _graft_override.tf (deep merged)
resource "azurerm_virtual_network" "main" {
subnet {
address_prefixes = ["10.0.1.0/24"] # Preserved from source
name = "subnet1" # Preserved from source
default_outbound_access_enabled = false # Added from override
}
}Graft also handles dynamic blocks correctly. When you override attributes in a dynamic block, Graft merges into the content block:
# Source module with dynamic block
resource "azurerm_virtual_network" "main" {
dynamic "subnet" {
for_each = var.subnets
content {
name = subnet.value.name
address_prefixes = subnet.value.address_prefixes
}
}
}
# Graft manifest
override {
resource "azurerm_virtual_network" "main" {
subnet {
default_outbound_access_enabled = false
}
}
}
# Generated _graft_override.tf
resource "azurerm_virtual_network" "main" {
dynamic "subnet" {
for_each = var.subnets
content {
address_prefixes = subnet.value.address_prefixes
name = subnet.value.name
default_outbound_access_enabled = false # Merged into content
}
}
}Certain Terraform meta-argument blocks have special override semantics and are not deep merged by Graft:
lifecycle- Merged on an argument-by-argument basis by Terraform (e.g., overridecreate_before_destroypreserves existingignore_changes)connection- Completely replaced by the override blockprovisioner- Override blocks replace all original provisioner blocks entirely
These blocks are passed through to the override file as-is, letting Terraform handle them according to its native rules.
The current deep merge implementation has some limitations:
-
No selective targeting: When a resource has multiple nested blocks of the same type, the override is applied to all of them. There is currently no way to target a specific nested block by index or label.
-
Single override block: If your manifest contains multiple blocks of the same nested type within an override, only the first one is used. Additional blocks are ignored.
-
Full replacement workaround: If you need to completely replace all nested blocks (native Terraform override behavior), you can use
_graftto remove the existing blocks first, then define the new blocks in the override:override { resource "azurerm_virtual_network" "main" { # First, remove all existing subnet blocks _graft { remove = ["subnet"] } # Then define the new subnet blocks subnet { name = "new-subnet-1" address_prefixes = ["10.0.10.0/24"] } subnet { name = "new-subnet-2" address_prefixes = ["10.0.20.0/24"] } } }
Why do we modify .terraform/modules/modules.json instead of generating a simple override.tf to point to the local path?
Native Terraform override.tf has a critical limitation: Conflict between Version Constraints and Local Paths.
- The Scenario: Your
main.tfuses a public module:module "network" { source = "Azure/network/azurerm" version = "5.3.0" }
- The Failed Override Approach: If we generated an
override.tfpointing to a local patched folder:Terraform would fail with:module "network" { source = "./.graft/network" }
Error: Cannot apply a version constraint to module "network" because it has a relative local path. - The Limitation: You cannot "delete" or "unset" the
versionargument frommain.tfusing an override file. - The Solution: The Linker Strategy. By updating Terraform's internal map (
modules.json), we trick Terraform into believing it is satisfying the Registry requirement (source+version match), while physically loading the files from our local patched directory.