| name | terraform-ecosystem |
| description | This skill should be used when the user asks to "write terraform", "HCL", "terraform provider development", "terraform-plugin-framework", "custom provider", "state management", "terraform plan", "terraform apply", "lifecycle ignore_changes", "provider schema", "plan modifier", "acceptance test", or works with Terraform/OpenTofu configuration and provider authoring. Provides patterns for both custom provider development (Go, terraform-plugin-framework) and HCL configuration/operations. |
| version | 2.0.0 |
Provide comprehensive patterns for the Terraform ecosystem across two pillars: (1) authoring custom providers with terraform-plugin-framework in Go, and (2) writing and operating HCL configurations (lifecycle management, credential scoping, DNS + hosting composition, CI plan/apply chains, state management). Emphasizes generalized principles over any single service, with concrete providers used only as illustrative examples.
terraform-plugin-framework provider authoring and HCL configuration/operations patterns
Go language syntax, error handling idioms, module layout, table-driven tests, goroutines/context. Provider code is Go; this skill covers only the Terraform-specific surface on top of Go.
Development shell setup, languages.terraform/opentofu, git-hooks (tflint, terraform fmt) for Terraform projects.
Provider/Resource/DataSource interfaces, Schema and attribute definitions, plan modifiers, validators, CRUD lifecycle with 404 state removal, Configure/client injection, ImportState, HTTP retry/error classification for API-backed providers, acceptance testing (TF_ACC, protocol factories); HCL lifecycle meta-arguments, credential-scope troubleshooting, DNS + hosting two-resource composition, per-project state isolation, plan/apply validation chains.
Initialize provider plugins and backend (run per project/workspace)
Preview changes; the primary review artifact in CI
Apply changes to real infrastructure
Canonicalize HCL formatting
Validate configuration internally (no remote calls)
Bring existing infrastructure under management
Lint HCL for provider-specific and generic issues
Run provider unit and acceptance tests (acceptance requires TF_ACC=1)
Analyze .tf files and Go provider source
Modify HCL and provider code
Fetch current terraform-plugin-framework and Terraform documentation
terraform-plugin-framework is the current, recommended way to build providers; it is interface-based (Provider/Resource/DataSource with typed request/response structs). SDKv2 (helper/schema, CreateContext with *schema.ResourceData) is legacy. Do not mix their idioms in one resource; new work should target the framework.
Terraform reconciles configuration (desired) against state (last known) against the real world (Read). Providers must make Read authoritative so plans are accurate and drift is detected.
Attributes are Required, Optional, and/or Computed. Computed values are supplied by the provider (unknown at plan time until stabilized). Correct plan behavior depends on marking these accurately.
The credential Terraform executes with defines what it can do. A config that is syntactically correct can still fail at apply because the token lacks scope for a specific endpoint. Diagnose by endpoint, not by config.
<provider_development>
Authoring a custom provider with terraform-plugin-framework. Provider code is Go; see golang-ecosystem for language idioms. Verify exact symbol names with Context7 (/hashicorp/terraform-plugin-framework) before relying on them, as helper package names differ from the pre-1.0 design docs.
<provider_interface>
The provider is the top-level object. It implements provider.Provider and declares the resources and data sources it offers. Interface satisfaction is compile-checked with a blank var assignment.
<required_methods>
Sets the provider type name (the prefix for resource type names) and version.
Declares provider-level configuration (endpoints, tokens).
Reads provider config, constructs the API client, and passes it to resources/data sources via resp.ResourceData and resp.DataSourceData.
Returns the list of resource constructors.
Returns the list of data source constructors.
</required_methods>
type ExampleProvider struct {
version string
}
var _ provider.Provider = &ExampleProvider{}
func (p *ExampleProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "example"
resp.Version = p.version
}
func (p *ExampleProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewCacheResource,
}
}
</example>
</provider_interface>
<resource_interfaces>
A resource implements resource.Resource (base) plus optional extension interfaces for capabilities. Keeping these as separate interfaces is intentional: the framework only invokes Configure/ImportState when the corresponding interface is satisfied.
Metadata, Schema, Create, Read, Update, Delete
The base interface. Every resource must implement the full CRUD set plus schema and metadata.
Configure
Extension that receives the shared API client from the provider's Configure. Guard against a nil ProviderData (the framework calls Configure with nil during earlier lifecycle phases).
ImportState
Extension that enables terraform import. Without it, import is unsupported.
type CacheResource struct {
client *APIClient
}
var (
_ resource.Resource = &CacheResource{}
_ resource.ResourceWithConfigure = &CacheResource{}
_ resource.ResourceWithImportState = &CacheResource{}
)
</example>
<common_mistake>
Treating Configure and ImportState as if they were methods of resource.Resource. They belong to the ResourceWithConfigure and ResourceWithImportState extension interfaces respectively; the base Resource interface is only Metadata/Schema/CRUD.
</common_mistake>
</resource_interfaces>
<schema_and_attributes>
Schema declares attribute types and behavior. The Required/Optional/Computed triad drives planning; declare it deliberately.
<attribute_flags>
Must be set by the user.
May be set by the user.
Set by the provider; may be unknown at plan time.
User may set it; if unset, the provider supplies a value (default-like behavior).
Redacted in CLI output and logs (tokens, keys).
</attribute_flags>
func (r *CacheResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthBetween(1, 63),
stringvalidator.RegexMatches(
regexp.MustCompile(^[a-z0-9-]+$),
"must contain only lowercase letters, digits, and hyphens",
),
},
},
"uri": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"is_public": schema.BoolAttribute{
Optional: true,
Computed: true,
},
},
}
}
</schema_and_attributes>
<plan_modifiers>
Plan modifiers adjust the planned value of an attribute before apply. The framework ships typed helper packages (stringplanmodifier, boolplanmodifier, int64planmodifier, etc.). Custom modifiers implement the planmodifier interface for the type.
<use_case>Computed attribute that does not change once set. Prevents a Computed value from showing "(known after apply)" on every plan when it is actually stable, reducing plan noise.</use_case>
stringplanmodifier.UseStateForUnknown()
<use_case>Immutable attribute. A change forces destroy-and-recreate instead of an in-place update (e.g. a resource whose backing API has no rename/update endpoint).</use_case>
stringplanmodifier.RequiresReplace() // also boolplanmodifier.RequiresReplace()
<use_case>Conditional replacement based on old/new values, when only some changes are destructive.</use_case>
<decision_tree name="plan_modifier_selection">
How does the attribute behave across updates?
UseStateForUnknown to avoid spurious plan diffs
RequiresReplace
RequiresReplaceIf with explicit logic
No modifier needed
</decision_tree>
</plan_modifiers>
Validators reject invalid configuration at plan time with clear diagnostics, before any API call. Prefer the terraform-plugin-framework-validators helper packages (stringvalidator, int64validator, etc.) over hand-rolled logic.
Enforce string length bounds
Enforce a pattern with a human-readable message
Restrict to an enumerated set
Enforce numeric range
Validate config-time constraints in validators (fast feedback, no side effects). Reserve API round-trips for CRUD.
<crud_operations>
Each CRUD method reads from its typed request and writes to its typed response. Read state/plan with req...Get, write with resp.State.Set, surface problems with resp.Diagnostics.
Read the plan into a model struct: req.Plan.Get(ctx, &plan).
Call the API to create the resource.
If the create response is empty (common), follow with a Read/GET to populate Computed attributes (uri, ids, keys).
Write the final state: resp.State.Set(ctx, &plan).
A "create returns empty body, then GET to hydrate computed fields" flow is a common API shape. Model it explicitly rather than assuming the create response carries all attributes.
Refresh state from the real world. This is where drift detection and the critical 404 handling live.
<critical_pattern name="remove_on_404">
When the backing object no longer exists (HTTP 404), remove it from state so Terraform plans to recreate it, and return WITHOUT adding an error. Adding an error here would wedge the user.
obj, err := r.client.GetCache(ctx, state.Name.ValueString())
if err != nil {
if errors.Is(err, ErrNotFound) { // maps HTTP 404
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Read failed", err.Error())
return
}
// map obj -> state, then resp.State.Set(ctx, &state)
</critical_pattern>
Apply in-place changes. Read plan and prior state, call the update endpoint, then set the new state. Attributes marked RequiresReplace never reach Update (the framework schedules replace instead).
Delete the backing object. On success, the framework automatically removes the resource from state (State.RemoveResource is called on the delete response). Treat a 404 during delete as success (already gone).
</crud_operations>
<configure_and_client>
Configure injects the shared API client built in the provider's Configure into each resource/data source. Always nil-check ProviderData, and type-assert defensively.
func (r *CacheResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return // framework calls Configure with nil in earlier phases
}
client, ok := req.ProviderData.(*APIClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected provider data type",
fmt.Sprintf("expected *APIClient, got %T", req.ProviderData),
)
return
}
r.client = client
}
Identity/account context that many endpoints need (e.g. an account ID resolved from a "current user" lookup) is best resolved once during provider Configure and carried on the client, rather than re-fetched in every CRUD call.
</configure_and_client>
<import_state>
ImportState seeds enough state from an import ID for the subsequent Read to fully hydrate the resource. The simplest form passes the import ID straight into an identifying attribute.
func (r *CacheResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
</import_state>
<http_client>
API-backed providers need a resilient HTTP layer. Keep retry and error-classification concerns in the client, so CRUD methods stay declarative.
<retry_policy>
Retry on 5xx and 429 (rate limit); do not retry 4xx client errors (except 429).
Exponential backoff (base * 2^attempt) capped at a maximum; honor Retry-After when present.
Respect context cancellation/deadline on every attempt so plan/apply can be interrupted.
</retry_policy>
<error_classification>
Authentication/authorization failure. Surface a clear diagnostic; often a credential-scope problem (see HCL pillar).
Not found. In Read, remove from state; in Delete, treat as success.
Rate limited. Retryable with backoff.
Server error. Retryable with backoff.
</error_classification>
</http_client>
<acceptance_testing>
Acceptance tests exercise real plan/apply/destroy cycles against a live API. They are gated behind the TF_ACC environment variable so they never run during ordinary unit testing.
Provider factories wire the in-process provider into the test harness. Use ProtoV6ProviderFactories with providerserver.NewProtocol6WithError for protocol 6 (the framework default); ProtoV5ProviderFactories exists for protocol 5 providers and muxed setups.
Each TestStep supplies HCL Config and a set of Check functions asserting on resulting state.
PreCheck validates required env (credentials) is present before the test runs.
func TestAccCacheResource_Basic(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccCacheConfig("example-cache"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("example_cache.test", "name", "example-cache"),
resource.TestCheckResourceAttrSet("example_cache.test", "uri"),
),
},
{
ResourceName: "example_cache.test",
ImportState: true,
ImportStateVerify: true,
},
},
})
}
<run_command>TF_ACC=1 go test -v ./... (acceptance tests create/destroy real resources; expect cost and side effects)</run_command>
</acceptance_testing>
</provider_development>
<hcl_and_operations>
Writing HCL and operating Terraform in practice: lifecycle control, credential-scope diagnosis, composing multi-provider resources, state isolation, and CI validation.
<lifecycle_ignore_changes>
ignore_changes tells Terraform to stop reconciling specific attributes after create, so out-of-band changes (made by a CI pipeline, another controller, or the platform itself) do not produce perpetual plan diffs.
<when_to_use>
An attribute is set once at creation but subsequently managed elsewhere (e.g. a deploy/build source field updated by an external workflow).
The platform mutates a field server-side in a way Terraform cannot predict.
</when_to_use>
resource "hosting_site" "example" {
name = "example"
deployment {
build_type = "workflow"
domain = "app.example.com"
}
lifecycle {
ignore_changes = [deployment[0].source]
}
}
ignore_changes is a targeted escape hatch, not a default. Ignoring too much hides real drift. Ignore the narrowest path that stops the false diff.
</lifecycle_ignore_changes>
<credential_scope_troubleshooting>
A frequent and confusing failure: some resources apply cleanly while others fail at apply with 404 (or 403) on create. When one resource type works and another does not under the same run, suspect the execution credential's scope before suspecting the configuration.
<diagnostic_procedure>
Identify which endpoint the failing resource hits (e.g. a user/account-level create) versus the working ones (e.g. a project/repo-scoped create).
If the failing endpoint is a different privilege tier than the working ones, the token likely lacks that scope. A 404 on create is a common signal that the credential cannot see/act on that endpoint at all.
Confirm by checking the token's granted scopes against the endpoint's requirement, not by rewriting the resource block.
</diagnostic_procedure>
Provision a credential with the required scope for that endpoint, or use a distinct auth strategy for that resource tier.
If the capability is not needed, remove the out-of-scope resource from configuration. Removing a resource whose state entry does not exist is safe: it destroys nothing. Verify with state inspection (e.g. terraform state list) that the resource is absent from state before removing its config, so apply completes without a destroy.
Separate "the HCL is wrong" from "the credential cannot do this." The second class of failure is invisible in the config and only shows up at apply.
</credential_scope_troubleshooting>
<dns_hosting_composition>
A recurring shape: exposing a service under a custom domain requires two coordinated resources across two providers — the hosting/platform resource that claims the domain, and a DNS record that routes to the platform's target. Model both; one without the other yields a broken or unverified domain.
<general_form>
Declares the custom domain on the platform side (a CNAME/custom-domain field on the hosting resource), telling the platform which hostname to serve and to provision a certificate for.
A CNAME record in the DNS provider pointing the subdomain at the platform's canonical target host.
</general_form>
# Hosting side: claim the custom domain
resource "hosting_site" "example" {
name = "example"
deployment {
build_type = "workflow"
domain = "sub.example.com"
}
}
# DNS side: route the subdomain to the platform target
resource "dns_record" "example_sub" {
zone_id = dns_zone.example.id
name = "sub"
type = "CNAME"
content = "example.hosting-platform.net" # platform's canonical host
ttl = 1 # 1 = automatic
proxied = false # direct CNAME, not proxied
}
</example>
<notes>
<note>Keep the DNS record unproxied when the platform terminates TLS and validates the domain via a direct CNAME; proxying can break domain verification.</note>
<note>Provider/service names above (hosting_site, dns_record) are placeholders; the two-resource composition is the reusable idea, applicable to any static-hosting-plus-DNS pairing.</note>
</notes>
</dns_hosting_composition>
<state_and_isolation>
Organize state so blast radius stays small and plans stay fast.
Isolate independent concerns into separate workspaces/root modules (e.g. DNS, source-control, cloud compute each with their own state), so a plan/apply in one never has to evaluate or risk another.
Use a remote backend (Terraform Cloud/Enterprise or equivalent) as the single source of truth for state; avoid local state for shared infrastructure.
Keep secrets out of state and config. Use a secrets manager or an encrypted secrets file (e.g. SOPS) rather than plaintext in .tf files, and mark provider/resource token attributes Sensitive.
Group related resources into named files by concern within a project to keep large configurations navigable.
</state_and_isolation>
<ci_validation_chain>
A dependable CI chain fails fast on cheap checks before spending time on remote operations.
terraform fmt -check — formatting gate (also enforceable as a pre-commit hook).
terraform validate — internal consistency, no remote calls.
tflint — provider-aware and generic lint rules.
terraform plan — the reviewable artifact; run per isolated project.
terraform apply — gated behind review/approval; scoped to the changed project.
For reproducible tool versions in CI, pin Terraform/tofu, tflint, and provider versions declaratively (a Nix/devenv shell entered via ... --command is one way) so local and CI runs use identical binaries.
</ci_validation_chain>
</hcl_and_operations>
<context7_integration>
Use Context7 to confirm current framework symbol names and Terraform option syntax. The framework's public helper packages (stringplanmodifier, stringvalidator, providerserver) differ from older pre-1.0 design documents, so verify before quoting exact names.
<usage_patterns>
Confirm stringplanmodifier/boolplanmodifier helper names and signatures
Confirm terraform-plugin-framework-validators package APIs
Confirm ProtoV5/V6ProviderFactories and providerserver factory helpers
Confirm ignore_changes / replace_triggered_by / precondition semantics
</usage_patterns>
</context7_integration>
<best_practices>
In a provider's Read, remove the resource from state on 404 and return without error, so Terraform recreates rather than wedges.
Target new provider work at terraform-plugin-framework, not SDKv2; do not mix their idioms in one resource.
Mark immutable attributes with RequiresReplace and stable computed attributes with UseStateForUnknown to keep plans accurate and quiet.
Validate config constraints in validators (fast, side-effect-free); reserve API calls for CRUD.
Nil-check ProviderData in Configure and type-assert defensively.
Diagnose apply-time 404/403 by credential scope, not only by configuration.
Isolate independent infrastructure into separate state/workspaces; keep secrets out of state and config.
Use ignore_changes narrowly for attributes managed out-of-band; never as a blanket.
Gate acceptance tests behind TF_ACC; run cheap checks (fmt/validate/lint) before plan in CI.
</best_practices>
<anti_patterns>
Returning a diagnostic error from Read when the object is gone (404).
Call resp.State.RemoveResource(ctx) and return with no error, so Terraform plans a recreate.
Mixing SDKv2 patterns (helper/schema, *schema.ResourceData, CreateContext) into a plugin-framework resource.
Use framework interfaces and typed request/response structs consistently.
Leaving stable computed attributes without UseStateForUnknown, producing "known after apply" on every plan.
Add UseStateForUnknown to computed attributes that do not change once set.
Wrapping whole blocks in ignore_changes to silence diffs.
Ignore the narrowest attribute path that is genuinely managed elsewhere; let real drift surface.
Rewriting resource blocks to chase a 404/403 that is actually a credential-scope limit.
Check the token's scope against the failing endpoint; fix credentials or remove the out-of-scope resource (after verifying it is absent from state).
Placing tokens/keys in plaintext .tf files or letting them land in state unredacted.
Use a secrets manager or encrypted secrets (e.g. SOPS); mark token attributes Sensitive.
One monolithic state for unrelated infrastructure, so every plan evaluates everything.
Split into per-concern workspaces/root modules with independent state.
Adding a DNS record for a custom domain without claiming the domain on the hosting platform (or vice versa).
Provision both the platform custom-domain resource and the DNS record together.
</anti_patterns>
In provider Read, RemoveResource on 404 and return without error
Target terraform-plugin-framework for new providers; never mix SDKv2 idioms into a framework resource
Keep secrets out of plaintext config and state; mark token attributes Sensitive
Verify framework symbol names with Context7 before quoting exact helper APIs
Mark immutable attributes RequiresReplace; stable computed attributes UseStateForUnknown
Validate config constraints in validators, not in CRUD
Diagnose apply-time 404/403 as credential scope before rewriting HCL
Use ignore_changes narrowly; isolate state per concern; run fmt/validate/lint before plan
Classify the task and gather context
Determine the pillar: provider development (Go) or HCL/operations
Parse request; Read relevant .tf or Go files
Task classification
Identify affected resources, providers, endpoints, or credentials
Read, Grep
Scope definition
Verify current framework/Terraform API names via Context7 when authoring provider code
context7 query-docs
Confirmed symbol names
Write provider code or HCL following the patterns above
For providers: implement interfaces, schema, plan modifiers, validators, CRUD with 404 handling
Edit
Provider changes
For HCL: compose resources, set lifecycle/ignore_changes narrowly, isolate state, keep secrets external
Edit
HCL changes
Verify correctness
Providers: go build, go test (unit); TF_ACC=1 go test for acceptance when credentials available
Bash
Test results
HCL: terraform fmt -check, terraform validate, tflint, terraform plan
Bash
Plan/validation results
<error_escalation inherits="core-patterns#error_escalation">
tflint style warning or fmt drift
Plan shows unexpected diff (often a missing plan modifier or over-broad ignore_changes)
Apply fails with 404/403 on some resources — credential scope limit
Plan proposes destroying resources due to state divergence or a RequiresReplace on a data-bearing attribute
</error_escalation>
Handle 404 in Read by removing from state without error
Verify framework symbol names against current docs before asserting exact APIs
Keep secrets out of config and state
Distinguish credential-scope failures from configuration errors
Mixing SDKv2 and plugin-framework idioms
Blanket ignore_changes
Monolithic state for unrelated infrastructure
<related_skills>
Go language, error handling, module layout, and testing for provider code
Reproducible dev shells for Terraform tooling (languages.terraform, git-hooks)
Navigate provider Go symbols and HCL references efficiently
Fetch current terraform-plugin-framework and Terraform documentation
Evidence-based diagnosis of plan diffs and apply-time failures
</related_skills>
<related_agents>
Locate resources, providers, and references across a Terraform project
Review provider or HCL changes against this skill guidance
Assess credential scope, secret handling, and state exposure
CI/CD plan/apply chains and remote backend configuration
</related_agents>