You describe a VPC with three subnets, a security group, and an EC2 instance. Sixty seconds later, Claude Code has written the HCL, validated it, planned it, and is waiting for your go-ahead to apply. Terraform suits an agent well because the files are declarative, validation is instant (terraform validate), and the plan/apply cycle gives you a review step before anything touches real infrastructure.
This guide is part of the Claude Code for DevOps Engineers series. Every demo ran against a real OpenStack cloud, so the plan output and error messages come from actual execution. The Terraform and OpenTofu MCP server section shows how to give the agent live registry docs instead of training-data guesses. The MCP section was run in November 2026 with Claude Code 2.1, OpenTofu 1.13 and HashiCorp’s terraform-mcp-server 1.3.0, while the OpenStack demos come from the original run on OpenTofu 1.11.5.
What You Need
- Claude Code installed and authenticated
- Terraform 1.2+ or OpenTofu 1.6+ installed
- Cloud credentials configured (AWS, GCP, Azure, or OpenStack)
- A non-production environment for testing (the demos create and destroy real resources)
- Node.js with
npxandjq, only for the MCP server section
The Terraform installation guide for Linux covers the binary on any distro. On Ubuntu, the Terraform and OpenTofu install guide for Ubuntu 26.04 gets both binaries on the same box, which is handy because every command below works with either.
Generate a Terraform Module from Scratch
The first demo shows the full cycle: describe what you need, Claude Code generates the HCL, validates, plans, and deploys. One conversation, real infrastructure at the end.
Write a Terraform config that creates:
1. A security group allowing SSH (22), HTTP (80), and HTTPS (443)
2. A compute instance using the Rocky Linux 10 image
3. Outputs for the instance IP and name
Use the OpenStack provider. Validate, then plan it.
Claude Code generates the HCL with proper resource references:
resource "openstack_networking_secgroup_v2" "web" {
name = "claude-demo-web"
description = "Allow SSH and HTTP access"
}
resource "openstack_networking_secgroup_rule_v2" "ssh" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.web.id
}
resource "openstack_networking_secgroup_rule_v2" "http" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 80
port_range_max = 80
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.web.id
}
resource "openstack_compute_instance_v2" "web" {
name = "claude-demo-web"
image_id = "5454c0f2-706c-4237-83df-ecad6bfbbda8"
flavor_id = "10"
key_pair = "jkmutai-mac"
security_groups = [openstack_networking_secgroup_v2.web.name]
network {
uuid = "44a581e7-9dd8-4802-94da-810f59127558"
}
}
output "instance_ip" {
value = openstack_compute_instance_v2.web.access_ip_v4
}
output "instance_name" {
value = openstack_compute_instance_v2.web.name
}
Validation passes on the first try:
terraform validate
The output confirms no issues:
Success! The configuration is valid.
The plan shows exactly what Terraform will create:
terraform plan
Terraform shows every resource it will create with full attribute details:
OpenTofu will perform the following actions:
# openstack_compute_instance_v2.web will be created
+ resource "openstack_compute_instance_v2" "web" {
+ access_ip_v4 = (known after apply)
+ flavor_id = "10"
+ image_id = "5454c0f2-706c-4237-83df-ecad6bfbbda8"
+ key_pair = "jkmutai-mac"
+ name = "claude-demo-web"
+ security_groups = ["claude-demo-web"]
}
# openstack_networking_secgroup_v2.web will be created
+ resource "openstack_networking_secgroup_v2" "web" {
+ description = "Allow SSH and HTTP access"
+ name = "claude-demo-web"
}
# openstack_networking_secgroup_rule_v2.ssh will be created
# openstack_networking_secgroup_rule_v2.http will be created
# openstack_networking_secgroup_rule_v2.https will be created
Plan: 5 to add, 0 to change, 0 to destroy.
Five resources: one security group, three rules (SSH, HTTP, HTTPS), and one compute instance. The plan is clean. Claude Code waits for your confirmation before applying. If the plan shows unexpected destroys or changes, you catch them here, not in production.
After approval, terraform apply creates the resources. The security group and rules create in under 2 seconds. The instance takes 10-15 seconds depending on the cloud provider. Claude Code shows the apply output in real time and reports the final outputs (instance IP, name).
Debug a Broken Terraform Config
This is the highest-value section. Debugging Terraform errors is where most engineers spend their time, and it’s where Claude Code saves the most. Two real error scenarios from actual testing.
Error: “Reference to undeclared input variable”
A config references var.app_image_id but no variable block declares it:
resource "openstack_compute_instance_v2" "app" {
name = "app-server"
image_id = var.app_image_id
# ...
}
Running terraform validate produces:
Error: Reference to undeclared input variable
on main.tf line 3, in resource "openstack_compute_instance_v2" "app":
3: image_id = var.app_image_id
An input variable with the name "app_image_id" has not been declared.
This variable can be declared with a variable "app_image_id" {} block.
Tell Claude Code “terraform validate is failing, fix it” and it reads the error, creates the missing variable block with a description and default value, and re-validates. The fix takes less time than reading the error message yourself.
Error: “Reference to undeclared resource”
A security group reference uses the wrong resource name:
security_groups = [openstack_networking_secgroup_v2.app.name]
But the security group is defined as openstack_networking_secgroup_v2.web, not .app:
Error: Reference to undeclared resource
on main.tf line 6, in resource "openstack_compute_instance_v2" "app":
6: security_groups = [openstack_networking_secgroup_v2.app.name]
There is no managed resource "openstack_networking_secgroup_v2" "app"
definition in the root module.
Claude Code scans all .tf files in the project, finds the actual resource name (.web), and fixes the reference. It also checks for similar mismatches elsewhere in the config, catching errors you might miss if you only fixed the line Terraform reported.
Partial apply failures
Sometimes terraform apply creates some resources but fails on others. In our testing, the security group created successfully but the compute instance entered an ERROR state:
openstack_networking_secgroup_v2.web: Creation complete after 2s [id=dfa9f3b6-b311-...]
openstack_networking_secgroup_rule_v2.ssh: Creation complete after 1s
openstack_networking_secgroup_rule_v2.http: Creation complete after 1s
openstack_networking_secgroup_rule_v2.https: Creation complete after 0s
openstack_compute_instance_v2.web: Creating...
openstack_compute_instance_v2.web: Still creating... [10s elapsed]
Error: Error waiting for instance (d6034548-d35a-...) to become ready:
unexpected state 'ERROR', wanted target 'ACTIVE'
Claude Code diagnoses partial applies by checking terraform state list to see what was created, then investigating the failed resource. For an instance ERROR state, it checks cloud provider quotas, image availability, and network configuration. The fix depends on the root cause: quota exceeded, image deleted, or flavor unavailable in the selected availability zone.
Run terraform state list after a failed apply, because it tells you exactly what exists and what doesn’t. Claude Code runs this automatically when diagnosing apply failures.
Safety Guardrails for Terraform
Terraform can destroy infrastructure with a single command. Claude Code needs explicit rules to prevent accidental damage. Three layers of protection work together.
CLAUDE.md rules
Add these to your project’s CLAUDE.md:
# Terraform Safety Rules
- NEVER run `terraform apply` without `terraform plan` first
- NEVER run `terraform destroy` without explicit user confirmation
- NEVER run `terraform apply -auto-approve` in any environment
- NEVER modify state files directly (no `terraform state rm` or `state mv`)
- ALWAYS run `terraform validate` after editing any .tf file
- ALWAYS use `-out=tfplan` with plan, and apply from the saved plan file
- ALWAYS review the plan output before confirming apply
CLAUDE.md is guidance the model reads, not a hard block, so pair it with the permission rules below.
Permission rules in settings.json
Create .claude/settings.json in your Terraform project and commit it to Git:
{
"permissions": {
"allow": [
"Bash(terraform init*)",
"Bash(terraform validate*)",
"Bash(terraform plan*)",
"Bash(terraform fmt*)",
"Bash(terraform show*)",
"Bash(terraform output*)",
"Bash(terraform state list*)",
"Bash(terraform state show*)"
],
"deny": [
"Bash(terraform destroy*)",
"Bash(terraform apply -auto-approve*)",
"Bash(terraform state rm*)",
"Bash(terraform state mv*)",
"Bash(terraform state push*)"
]
}
}
Read-only operations run without prompting. Plan runs freely (it doesn’t change anything). Apply prompts for approval. Destroy and state manipulation are blocked entirely. Every team member who clones the repo inherits these rules automatically.
Validation hooks
A PostToolUse hook can validate every .tf file right after Claude Code writes it. Claude Code passes the edited path to the hook as JSON on stdin (tool_input.file_path), and for a plain command hook, exit code 2 is what puts its stderr in front of the model, so both details matter. The hook also needs jq on your PATH, because without it the path comes back empty and the hook silently does nothing. Add this to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path // empty'); case \"$FILE\" in *.tf) tofu -chdir=\"$(dirname \"$FILE\")\" validate -no-color >&2 || exit 2;; esac"
}
]
}
]
}
}
That’s the exact file we ran with OpenTofu; replace tofu with terraform if that’s your binary, and run init once in the directory first so validate has the provider schema. An earlier version of this guide showed a hook that read a $CLAUDE_FILE_PATHS variable and ended in || true. We re-ran it in November 2026 and Claude reported no feedback at all after writing a broken file, because the variable isn’t set and the hook always exited 0. With the hook above, the same test came back like this:

Claude sees the validate error the moment it writes the file, so it fixes it before moving on instead of carrying a broken config into the plan. For the full configuration system, the .claude directory guide covers settings, hooks, and project-level rules in detail.
What Claude Code Gets Right (and Wrong) with Terraform
| Task | Quality | Notes |
|---|---|---|
| Single-resource configs | Excellent | Security groups, instances, S3 buckets, DNS records |
| Resource references | Excellent | Correct .id vs .name vs .arn usage |
| Provider-specific attributes | Good | Knows AWS, GCP, Azure, OpenStack well. Niche providers less so |
| Provider version pins | Stale without MCP | Pins from training data. The registry MCP servers below fix this |
| Module composition | Good for simple, weak for complex | Single module: reliable. Cross-module references with conditionals: review carefully |
| State operations | Do not use | State manipulation needs human judgment. Block it in permissions |
| IAM policies | Needs review | Generates overly permissive policies. Always tighten manually |
| Variable validation | Good | Adds type constraints and descriptions, sometimes misses validation blocks |
| Backend configuration | Good | Knows S3, GCS, Azure blob, Consul patterns |
The consistent rule: always review the plan. Claude Code generates valid HCL in most cases, but “valid” and “correct for your environment” are different things. The plan is your safety net. Never skip it.
Give Claude Code Live Registry Docs with the Terraform and OpenTofu MCP Servers
Claude Code’s provider knowledge stops at its training data, so the version it pins and the arguments it picks are recall, not lookup. Two MCP servers close that gap: OpenTofu’s registry server and HashiCorp’s terraform-mcp-server. Both answer questions about the public registry (provider versions, resource docs, modules), and neither runs plan or apply, reads your state file or sees your cloud credentials. HashiCorp’s server has a second job, HCP Terraform workspace management, and that part can change things, which is why the tool counts below matter.
Register the servers with claude mcp add
We added three entries from inside the Terraform project directory: OpenTofu’s hosted endpoint, the same server run locally through npx, and HashiCorp’s release binary over stdio. You only need one OpenTofu entry; we registered both to compare them.
claude mcp add opentofu -t http https://mcp.opentofu.org/mcp
claude mcp add opentofu-local -- npx -y @opentofu/opentofu-mcp-server
claude mcp add terraform -- terraform-mcp-server stdio --toolsets=registry
The OpenTofu README writes the first command with -t streamable-http. Claude Code accepted that spelling in our test and saved it as type http, so either works. Without -s, the entries land in local scope (private to you, this project only). With -s project they go into a .mcp.json you can commit, and each teammate’s claude mcp list shows them as “Pending approval” until they approve them in an interactive session.
HashiCorp’s README gives Claude Code a Docker command (claude mcp add terraform -s user -t stdio -- docker run -i --rm hashicorp/terraform-mcp-server) and otherwise only go install. To run it without Docker or Go, we used the release binary instead: HashiCorp publishes zips per platform at releases.hashicorp.com, so put the binary on your PATH and point Claude Code at terraform-mcp-server stdio as above. The Docker form wasn’t tested here. Check that all three connect:
claude mcp list
Each line should end in Connected. The npx entry takes a few seconds longer on the first run while npm fetches the package:

That screenshot is from the first pass, before we added --toolsets=registry to the HashiCorp entry. The next step shows why the flag is there.
Check what each server exposes
You can list a server’s tools without any LLM in the loop by using the official MCP Inspector in CLI mode. Run it against the hosted OpenTofu endpoint first:
npx -y @modelcontextprotocol/inspector --cli https://mcp.opentofu.org/mcp --transport http --method tools/list | jq -r '.tools[].name'
The hosted server returned seven tools. The same call against the npm package (swap the URL for npx -y @opentofu/opentofu-mcp-server and drop --transport) returned five:

The two missing tools are get-provider-versions and get-module-versions, which are exactly the ones an agent needs to pin a version. The npm package was last published in May 2025 and lags the hosted build, so use the hosted endpoint unless your network blocks mcp.opentofu.org. On the local package the agent can still read a provider’s current version from get-provider-details, just less directly.
HashiCorp’s server changes shape depending on whether it finds an HCP Terraform token. Listing it with no token, then with a dummy TFE_TOKEN, then with the token plus --toolsets=registry, gives three different answers:
npx -y @modelcontextprotocol/inspector --cli -e TFE_TOKEN=dummy-not-real terraform-mcp-server stdio --method tools/list | jq '.tools | length'
Without a token you get nine registry tools, all flagged readOnly=true. Set TFE_TOKEN and the count jumps to 56. The build in these captures is the one named in the intro; the 1.4.x releases that followed renamed search_providers to search_provider_docs (a breaking change in the changelog), so a fresh download lists that name instead:

Nineteen of those 56 aren’t marked read-only, including create_run, create_workspace_variable and update_workspace. The binary’s own help says the default toolset is registry, yet with a token in the environment and no --toolsets flag, the build we tested loaded everything. Setting ENABLE_TF_OPERATIONS=true on top adds five more (action_run, delete_workspace_safely, force_unlock_workspace, delete_project, delete_team) for 61 in total. If you export TFE_TOKEN in your shell for other work, Claude Code inherits it, so pass --toolsets=registry unless you actually want the agent working on HCP Terraform workspaces.
| Server and mode | Tools listed | Can it change anything? |
|---|---|---|
OpenTofu, hosted /mcp | 7 | No, public registry reads only |
OpenTofu, npx package | 5 | No, and no version-list tools |
terraform-mcp-server, no token | 9 | No, public registry reads only |
terraform-mcp-server with TFE_TOKEN | 56 | Yes: HCP Terraform workspaces, variables, teams, runs |
Same, plus ENABLE_TF_OPERATIONS=true | 61 | Adds deletes and workspace force-unlock |
terraform-mcp-server --toolsets=registry, token set | 9 | No |
None of these touches a local terraform.tfstate or the AWS, GCP or OpenStack credentials your provider blocks use. The HCP Terraform tools act through the API token you give them, and that token’s permissions are the real limit.
Look up provider versions and resource docs
The lookups the agent makes are ordinary tool calls, so you can run them yourself to see what it will get back. Ask HashiCorp’s server for the latest AWS provider:
npx -y @modelcontextprotocol/inspector --cli terraform-mcp-server stdio --method tools/call --tool-name get_latest_provider_version --tool-arg namespace=hashicorp --tool-arg name=aws | jq -r '.content[0].text'
It answers with a bare version string. OpenTofu’s get-provider-versions returns the same release with publish dates, which helps when you want to know how fresh a pin is:

Both registries agreed on the same release, published six days before our run. Resource docs come back as the provider’s own Markdown. Pulling the argument list for aws_s3_bucket_public_access_block from the hosted OpenTofu server:
npx -y @modelcontextprotocol/inspector --cli https://mcp.opentofu.org/mcp --transport http --method tools/call --tool-name get-resource-docs --tool-arg namespace=hashicorp --tool-arg name=aws --tool-arg resource=s3_bucket_public_access_block | jq -r '.content[0].text' | grep '^\* `' | cut -c1-72
The list includes region and skip_destroy, both newer than most of the S3 examples a model has seen in training:
* `block_public_acls` - (Optional) Whether Amazon S3 should block public
* `block_public_policy` - (Optional) Whether Amazon S3 should block publ
* `bucket` - (Required) S3 Bucket to which this Public Access Block conf
* `ignore_public_acls` - (Optional) Whether Amazon S3 should ignore publ
* `region` - (Optional) Region where this resource will be [managed](htt
* `restrict_public_buckets` - (Optional) Whether Amazon S3 should restri
* `skip_destroy` - (Optional) Whether to retain the public access block
* `id` - Name of the S3 bucket the configuration is attached to
* `bucket` (String) Name of the bucket.
* `account_id` (String) Account ID where this resource is managed.
* `region` (String) Region where this resource is managed.
The last four lines come from the attribute and import identity sections of the same page, so read past the first block when you check arguments by hand.
Compare the HCL with and without the MCP servers
To see what the servers change in practice, we gave Claude Code the same prompt in non-interactive mode twice: once with an empty MCP config and web tools disabled, once with only the two registry servers loaded from a JSON file through --strict-mcp-config --mcp-config, so nothing else on the machine could feed the agent. The prompt:
Write a single main.tf for an S3 bucket named demo-logs-bucket with versioning enabled and SSE-KMS default encryption. Include a terraform block that pins the hashicorp/aws provider to the current latest major and minor version. Reply with only the HCL, no prose.
Without the servers, both runs pinned the provider from memory. The comment line below exists because our own CLAUDE.md tells the agent to flag any version it couldn’t verify, which is a rule worth stealing:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
# UNVERIFIED: registry unreachable from this session; set to `curl -s https://registry.terraform.io/v1/providers/hashicorp/aws | jq -r .version` (major.minor)
version = "~> 6.0"
}
}
}
With the servers attached, the run made one call to get_latest_provider_version and wrote the pin from its answer. The whole run took about 8 seconds over 3 turns:
6.71, then tofu validate passing” class=”wp-image-183280″/>Read that result for what it is. ~> 6.0 isn’t broken, because tofu init installed the same current release for the first no-MCP file and the MCP file. What changes is the floor: ~> 6.71 records the provider the code was written against, so a teammate with an old lockfile can’t quietly run it on a release that predates an argument the agent used. Also notice what the agent didn’t do. It never called a resource-docs tool and wrote the S3 arguments from memory, so if you want arguments checked against the registry, say so in the prompt or in CLAUDE.md.
Error: An argument named “restrict_public_buggers” is not expected here
The first no-MCP run also added a public access block on its own, and spelled one argument wrong. tofu validate caught it straight away:

The correct name is restrict_public_buckets, as the docs lookup above shows, and the second no-MCP run spelled it right. A registry server doesn’t prevent a slip like this, because the agent wasn’t looking anything up at that moment. The validation hook from the guardrails section is what catches it, so keep both: the MCP server for current facts, the hook for whatever the model types.
Add the same servers to OpenCode
OpenCode reads MCP servers from the mcp block of opencode.json, with type set to local (a command array) or remote (a URL), as its MCP server docs describe. This is the exact file we tested in the project root:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"opentofu": {
"type": "remote",
"url": "https://mcp.opentofu.org/mcp",
"enabled": true
},
"terraform": {
"type": "local",
"command": ["terraform-mcp-server", "stdio"],
"enabled": true
}
}
}
Append "--toolsets=registry" to the command array for the same reason as in Claude Code. Then confirm both servers connect:
opencode mcp list
Both show a check mark and connected:

HashiCorp’s README nests the entries one level deeper, under mcp.servers. The OpenCode build we tested connected with that form too, but we’d stick to the layout in OpenCode’s own docs. We only checked the connections in OpenCode; the prompt comparison above was run in Claude Code. For routing Terraform work across models in OpenCode, the OpenCode DevOps agents guide picks up from here, and the OpenCode CLI cheat sheet lists the other opencode mcp subcommands.
Our setup after all this: the hosted OpenTofu endpoint plus terraform-mcp-server stdio --toolsets=registry, both left connected permanently, because in that form neither can change anything and the version lookup costs one call.
Practical Terraform Prompts
Be explicit about the cloud provider and resource types. “Create a VM” is vague. “Create an OpenStack compute instance with a security group allowing SSH and HTTP, using the Rocky Linux 10 image” gives Claude Code everything it needs.
Ask for outputs. Claude Code sometimes generates resources without outputs. Adding “include outputs for the instance IP and security group ID” ensures you get the values you’ll need for other modules or manual verification.
Request variables for environment-specific values. Hardcoded IPs, image IDs, and region names break when you move to a different environment. Asking “use variables for the image ID, flavor, and network” produces portable configs.
For debugging, paste the complete error. Terraform error messages include the file, line number, and resource name. Claude Code uses all of it. Saying “terraform plan failed” gives it nothing. Pasting the full error block gives it everything.
Ask Claude Code to explain the plan before applying. “What does this plan actually do? Are there any unexpected destroys or replacements?” makes Claude Code read the plan like a reviewer, flagging anything that looks wrong.
Part of the Claude Code for DevOps Series
This Terraform spoke connects to the broader series. The SSH server management guide covers configuring the servers Terraform provisions. The Ansible guide covers post-provisioning configuration management.
- Set Up Claude Code for DevOps Engineers (pillar with safety rules and permissions)
- Manage Servers with Claude Code via SSH
- Build and Debug Docker Containers with Claude Code
- Claude Code + Ansible: generate playbooks, multi-OS testing, debug SELinux
- Claude Code + Kubernetes: manifests, Helm charts, debugging CrashLoopBackOff
- Claude Code + GitHub Actions: automated PR review, Terraform validation
The Claude Code cheat sheet covers every command and shortcut for quick reference. For provisioning VMs on other platforms, the Terraform with KVM guide shows the same patterns with a different provider, and the OpenTofu install guide covers the open-source fork used in the MCP tests.
For a multi-model approach to Terraform automation, see AI Coding Agents for DevOps with OpenCode, which routes tasks to Claude, GPT, and other models based on complexity. If you’re still choosing between agents, the OpenCode vs Claude Code vs Cursor comparison lays out the trade-offs.