Read this before apply
terraform apply spends money. Treat the file as a bill.
A terraform config generator in a browser tab has one honest job: emit a starter .tf file you still have to fmt, init, and plan yourself. The file is not documentation. terraform apply talks to a cloud API and creates billable objects. A weekend lab with one instance and a public IP looks cheap until the instance stays up for six weeks because nobody ran destroy.
This page never calls plan. This page never calls apply. Those commands belong on your machine.
If you are replacing a live VPC, close this tab. Open the existing state instead. The builder has never seen your account, your backend, or the IDs already in the world. Sketching a throwaway lab is the job. Rewriting production from a blank form is how duplicate NAT gateways get born.
A concrete walk-through
What the default AWS sketch will try to buy
Leave AWS selected. Keep the seeded aws_region variable. Add one aws_instance named web from the sample. Copy main.tf, drop the file in an empty folder, run terraform init, then terraform plan.
Init downloads hashicorp/aws at the pinned 5.x line and writes .terraform.lock.hcl. Commit the lockfile. Plan then stalls on ami, because the sample points at var.ami_id and no such variable exists yet. That stall is useful. A hardcoded AMI from a blog post is usually from another region, already deregistered, or both.
Fix the stall with a data source, not a guessed AMI string:
data "aws_ami" "debian" {most_recent = true
owners = ["136693071363"]filter {name = "name"
values = ["debian-12-amd64-*"]}}
resource "aws_instance" "web" {ami = data.aws_ami.debian.id
instance_type = "t3.micro"}Plan then shows one instance in us-west-2. Apply creates the instance. Destroy is the only cleanup. Close the laptop without destroy and the instance keeps billing. The sample tags the instance toolexe-lab so the stray is easy to find later in the console.
The part people skip
The state file is the inventory. HCL is the wish list.
Teams new to Terraform treat main.tf as the source of truth. The source of truth is terraform.tfstate. HCL describes the shape you want. The state file records what Terraform believes exists, including IDs you never typed. Delete the state while the resources stay up and the next apply tries to create duplicates. Keep the state and delete the HCL, and Terraform wants to destroy everything in the next plan.
| You change | Terraform believes | Usual result |
|---|---|---|
| HCL only | State still matches the old world | Plan proposes create, update, or destroy |
| Cloud console only | State is stale | Next plan fights you, or ignores the drift until refresh |
| State file deleted | Empty workspace | Apply recreates objects already in the account |
| Backend pointed at the wrong bucket | Someone else's inventory, or none | Two workspaces managing one account |
Remote backends exist for this reason. S3 with a lock table, Terraform Cloud, GCS, Azure Blob. They store state off the laptop and take a lock so two applies do not collide. The generator writes no backend block on purpose. A guessed bucket name is worse than local state, because the next init silently points at the wrong place. After you copy the file, add a backend yourself, run terraform init -migrate-state, and keep state out of git.
Need names for the secrets the backend will read? Sort them in the environment variable manager rather than committing a terraform.tfvars full of keys.
Language, not a form
Four blocks, four jobs
- resource
- Create and manage an object.
aws_instance.webis a resource. Terraform owns the lifecycle. Changing the local name is a destroy and create, not a rename in the cloud. - data
- Read an object you do not own. An AMI lookup belongs here. Putting a lookup in a resource block creates something you meant to find. This page does not emit data sources. Add them after copy, the way the Debian example above does.
- variable
- Input. A default makes apply quiet. No default means Terraform prompts, or a
.tfvarsfile must supply the value. The receipt under the HCL lists variables with no default so you see the prompts before you run plan. - output
- Values other workspaces or humans need, such as an instance id. An output is not a cloud object. Remove one and nothing in AWS dies. Marking one sensitive blanks CLI prints. The value still sits in state.
A fifth type exists. module wraps a folder of the four above. This page never writes module calls. A generated module would guess your interface and then fight you in review. Write the root file here. Extract a module once the same pattern appears three times.
Pick the right generator
Terraform, CloudFormation, and Ansible are not substitutes
Three files, three jobs. Mixing them because "infrastructure as code" is a category in a sidebar wastes a week.
| The work | Reach for | Leave Terraform alone when |
|---|---|---|
| AWS objects, or two clouds | This page, then real modules | You already have a CloudFormation stack with drift you understand |
| AWS objects, AWS only, org already on stacks | AWS CloudFormation generator | You need a provider outside AWS |
| Packages, files, services on a box | Ansible playbook generator | You wanted a VM more than you wanted nginx.conf |
| Cluster objects | Kubernetes YAML generator | You do not already use Terraform as the cluster's source of truth |
A Compose file for local services belongs in the Docker Compose generator. The Docker provider on this page talks to a daemon. Compose talks to a project. Different files, different commands, different failure modes.
A destroy you did not mean
count will retire the wrong machine
count = length(var.names) looks neat. Then you remove the second name from the list. Terraform sees index 1 vanish and index 2 slide into its place. The remaining instances get new addresses. One of them is destroyed and rebuilt. The "wrong" box is the one whose index moved, not the one you deleted from the list.
# address today address after you drop "api" aws_instance.web[0] web aws_instance.web[0] web aws_instance.web[1] api aws_instance.web[1] worker # was [2]aws_instance.web[2] worker
for_each keyed on a map or a set of strings survives the edit, because addresses use the key, not a position. Use count for identical objects with no identity of their own. Use for_each once names, AZs, or environment keys exist. The samples in the builder use neither. Add for_each after you copy, once you know the keys.
Pins
~> 5.0 is a contract with the registry
required_providers plus required_version stop a teammate on Terraform 0.14 from running a file written for 1.6. ~> 5.0 means 5.x, not 6.x. The day the AWS provider ships 6.0, 6.0 stays out until you raise the pin on purpose. Leave the constraint off and init pulls whatever is newest, including a major with renamed resources.
The Azure button says Azure. The file says azurerm. GCP is google. Docker comes from kreuzwerker/docker, not hashicorp/docker. Mixing product names with registry names is the first error most people hit after a copy-paste from two tutorials.
Commit .terraform.lock.hcl. Skip the lockfile and every laptop resolves a slightly different 5.x, then CI fails on an argument your laptop never saw.
A print filter, not a vault
sensitive = true hides CLI output. Encryption lives elsewhere.
Marking an output sensitive blanks the value in terraform output. The value still sits in state in plaintext unless the backend encrypts at rest. Same story for variables. sensitive = true on a variable stops the value echoing in logs. Anyone with the state file still reads the secret.
Do not paste live keys into the form. Feed credentials from the environment with TF_VAR_, or from a vault your runner already trusts. If apply runs in CI, put those keys in repository secrets and write the workflow with the GitHub Actions workflow generator. The HCL should name the variable. The HCL should not contain the password.
Honest limits
Blocks this page refuses to invent
- backend. Bucket names, DynamoDB tables, and Terraform Cloud org names are yours. Guessing them is how two laptops share the wrong state.
- module calls. Source, version, and the variable interface belong to a module you already trust, not a form.
- import, moved, removed. State surgery is a review, not a dropdown.
- provider aliases. Multi-region AWS needs
provider "aws" { alias = "use1" }. Add aliases after you copy, once you know the regions. - IAM policy JSON. A generated policy is either too open or too wrong. Write the JSON by hand, then attach the document to the role.
- plan and apply. No credentials leave this tab, so no API call happens. Run those on a machine you control.
Download main.tf. Run terraform fmt. Run terraform init. Run terraform plan against an empty workspace. Read the plan like a receipt. Then apply, or do not.
