Terraform for developers: a complete beginner's guide
By Flavio Copes
Learn what Terraform is, how infrastructure as code works, and how to create, change, inspect, and destroy infrastructure safely.
Terraform lets you describe infrastructure in files and create that infrastructure with commands.
Instead of opening a cloud dashboard and clicking buttons, you write what you want:
resource "docker_container" "web" {
name = "my-web-server"
image = docker_image.nginx.image_id
}
Terraform reads this file, compares it with what already exists, and shows you what it wants to change.
You review the plan.
Then Terraform talks to the relevant APIs and makes the change.
This approach is called infrastructure as code.
If you are a developer who has never managed infrastructure, Terraform can look more complicated than it is. There are providers, resources, state files, modules, backends, and many new commands.
We do not need all of that on day one.
In this guide we’ll build a clear mental model, run a real example locally, and learn the small part of Terraform you need to get started.
What problem does Terraform solve?
Imagine you need a server for an application.
You open your cloud provider’s dashboard. You create a virtual machine, choose a region, configure a network, add a firewall rule, and create a DNS record.
This works.
But now imagine doing it again for a staging environment.
Did you choose the same machine size? Did you remember the firewall rule? Is the staging database configured like production?
Then another developer joins the team. They need to understand how everything was created. The dashboard shows the result, but not always the reasoning or the exact sequence of changes.
Manual infrastructure creates several problems:
- it is difficult to reproduce
- changes are difficult to review
- environments slowly become different
- nobody remembers every setting
- rebuilding after a failure takes time
Terraform moves those settings into text files.
You can keep those files in Git. You can review changes in a pull request. You can use the same configuration to create another environment.
The infrastructure becomes part of the project instead of a collection of remembered clicks.
What is infrastructure as code?
Infrastructure as code means managing infrastructure through files that can be read and versioned.
The files describe resources such as:
- virtual machines
- networks
- databases
- DNS records
- storage buckets
- load balancers
- monitoring rules
- GitHub repositories
- services from many other platforms
Terraform is not limited to servers.
If a service has an API and a Terraform provider, Terraform can often manage it.
This is the first useful idea:
Terraform turns infrastructure changes into code changes.
You can see who changed a resource, why they changed it, and what the previous configuration looked like.
Terraform is declarative
Terraform configuration is declarative.
You describe the result you want, not every step required to produce it.
Suppose you want two application servers.
In an imperative script, you might write instructions like this:
- Check how many servers exist.
- If there are none, create two.
- If there is one, create another.
- If there are three, delete one.
With a declarative tool, you say:
instances = 2
Terraform works out how to move from the current situation to that result.
This is similar to writing HTML or CSS. You describe what the page should be, and the browser handles much of the work needed to render it.
The difference is that Terraform changes real infrastructure, which may cost money or serve users. This is why reviewing its plan matters.
What can Terraform manage?
Terraform uses plugins called providers.
A provider teaches Terraform how to communicate with a particular API.
There are providers for AWS, Microsoft Azure, Google Cloud, Cloudflare, Kubernetes, Docker, GitHub, and thousands of other services in the Terraform Registry.
Each provider makes different resource types available.
The AWS provider offers resources for EC2 instances, S3 buckets, VPC networks, and other AWS services.
The Cloudflare provider offers resources for DNS records, zones, Workers, and other Cloudflare services.
In our first example we’ll use the Docker provider. It lets us learn the Terraform workflow without creating a cloud account or spending money.
What Terraform does not do
Terraform creates and manages infrastructure.
It does not replace your application code.
It also does not normally build the application, run its tests, or decide when a feature is ready to ship.
A typical deployment system might work like this:
- GitHub Actions tests your application.
- Docker builds a container image.
- Terraform creates the network, server, and database.
- A deployment tool releases the new application version.
The exact division changes from team to team.
Terraform can run commands, but using it as a general-purpose scripting or deployment tool usually makes a project harder to understand.
My advice is to use Terraform for long-lived infrastructure resources.
How Terraform works
There are five pieces to understand:
- Configuration describes what you want.
- Terraform Core reads the configuration and builds a plan.
- A provider talks to an external API.
- A resource represents one managed object.
- State connects your configuration to real objects.
Let’s look at each one.
Configuration
Terraform reads files ending in .tf from the current directory.
Those files use the HashiCorp Configuration Language, usually called HCL.
HCL is designed to be readable:
resource "docker_image" "nginx" {
name = "nginx:stable-alpine"
}
This block declares a resource.
docker_image is the resource type. It comes from the Docker provider.
nginx is the local name we chose. Terraform uses it when another resource needs to refer to this image.
The name argument tells the provider which image we want.
Terraform Core
Terraform Core is the main Terraform program.
It reads your files, checks dependencies, reads state, asks providers about existing resources, and calculates a plan.
If a container needs an image, Terraform knows it must prepare the image first.
You do not need to tell it the order.
Providers
Providers are separate plugins.
Terraform downloads them when you run terraform init.
The provider understands the remote system. Terraform Core does not need built-in knowledge of every AWS service or every Docker operation.
This separation is why Terraform can work with so many platforms.
Resources
A resource is one object Terraform manages.
It might be a server, bucket, database, container, or DNS record.
Every resource has an address inside Terraform.
For this block:
resource "docker_image" "nginx" {
name = "nginx:stable-alpine"
}
the address is:
docker_image.nginx
You will see resource addresses in plans, state commands, and error messages.
State
Terraform needs to remember which real object belongs to each resource in your files.
It stores that mapping in state.
For a local project, Terraform stores state in a file named terraform.tfstate.
You can think of state as Terraform’s memory.
Your configuration might say docker_container.web. The state records the ID of the real Docker container created for that resource.
The next time you run Terraform, it can compare three things:
- your configuration
- its saved state
- the real infrastructure
State is essential. We will return to it later because it needs careful handling.
The core Terraform workflow
The official Terraform workflow has three main stages:
- Write
- Plan
- Apply
In practice, you will usually run a few more commands:
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
Here is what they do:
initprepares the directory and downloads providersfmtformats your.tffilesvalidatechecks the configurationplanpreviews changesapplyperforms the changes
There is one more command you should know from the beginning:
terraform destroy
It deletes the infrastructure managed by the current configuration.
Be careful with that command.
Install Terraform
Follow the official Terraform installation guide for your operating system.
After installing it, check the command:
terraform version
You also need Docker for the example in this guide.
If Docker is new to you, start with my introduction to Docker, then install Docker Desktop.
Make sure Docker is running:
docker version
Now we can create our first Terraform project.
Create your first Terraform project
Create a new directory:
mkdir terraform-docker-demo
cd terraform-docker-demo
Create a file named main.tf.
Add this configuration:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.2"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:stable-alpine"
keep_locally = false
}
resource "docker_container" "web" {
name = "terraform-web"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8080
}
}
This example follows the same structure as HashiCorp’s official Docker tutorial.
Let’s understand the file before running it.
The terraform block
The first block configures Terraform itself:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.2"
}
}
}
It says this project needs the provider published at kreuzwerker/docker.
The version constraint allows compatible releases in the 4.x series, starting from 4.2.
Provider versions matter.
A provider can change its available resources or behavior between releases. A version constraint makes the project more predictable.
The provider block
This block configures the Docker provider:
provider "docker" {}
The empty block means the provider can use its default connection settings.
On macOS and Linux, those defaults usually connect to the local Docker daemon.
Cloud providers need more configuration. They may need a region, account information, or credentials.
Do not put long-lived secrets directly in a committed .tf file. Providers usually support environment variables or workload identity systems for credentials.
The image resource
This resource makes sure the Nginx image is available:
resource "docker_image" "nginx" {
name = "nginx:stable-alpine"
keep_locally = false
}
We named the Terraform resource nginx.
This is not the Docker image name. It is the name Terraform uses inside this configuration.
The container resource
This resource creates the running container:
resource "docker_container" "web" {
name = "terraform-web"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8080
}
}
Notice this line:
image = docker_image.nginx.image_id
The container refers to the image resource.
This creates a dependency. Terraform knows it must prepare the image before creating the container.
The port block maps port 8080 on your computer to port 80 in the container.
Initialize the directory
Run:
terraform init
Terraform downloads the Docker provider into a hidden .terraform directory.
It also creates .terraform.lock.hcl.
The lock file records the exact provider version Terraform selected. Commit this file to Git.
Do not commit the .terraform directory. It contains downloaded files that Terraform can recreate.
You need to run terraform init when you first clone a Terraform project. Run it again after changing provider or module requirements.
Format the configuration
Run:
terraform fmt
Terraform formats every configuration file in the current directory.
This removes style discussions from code reviews and keeps all files consistent.
I run terraform fmt before every commit.
Validate the configuration
Run:
terraform validate
Terraform checks that the configuration is valid and internally consistent.
A successful result looks like this:
Success! The configuration is valid.
Validation does not prove that a cloud account has enough permissions or that every API call will succeed.
It catches configuration problems before Terraform starts changing resources.
Preview the changes
Run:
terraform plan
Terraform reads the configuration, checks the current situation, and prints an execution plan.
You should see a summary similar to this:
Plan: 2 to add, 0 to change, 0 to destroy.
Terraform plans to create the image and the container.
Plan output uses symbols that resemble a code diff:
+means create~means update in place-means destroy-/+means replace
Pay close attention to replacement.
Some properties cannot be changed on an existing resource. Terraform must delete the old resource and create another one.
For a local container this is harmless. For a production database it may be a serious operation.
Never approve a plan by looking only at the final numbers.
Read the resources Terraform wants to change.
Apply the configuration
Run:
terraform apply
Terraform calculates a plan and asks for confirmation.
Type:
yes
Terraform pulls the image and creates the container.
When it finishes, open http://localhost:8080 in your browser.
You should see the Nginx welcome page.
We described a running web server in a text file and Terraform created it.
Inspect what Terraform manages
Run:
terraform state list
You should see:
docker_container.web
docker_image.nginx
These are the resource addresses stored in state.
You can inspect one resource:
terraform state show docker_container.web
Terraform prints the values it knows about the container.
You can also use:
terraform show
This displays the current state in a readable form.
These commands are useful for learning and debugging. Avoid manually editing the state file.
Change the infrastructure
Infrastructure as code becomes useful when something changes.
Open main.tf and change the external port:
ports {
internal = 80
external = 9090
}
Run another plan:
terraform plan
Read the output.
The Docker provider may need to replace the container because its port configuration changed.
Apply the change:
terraform apply
After approval, visit http://localhost:9090.
The old configuration is gone and the new one is active.
Your file now documents why the container uses port 9090.
Git will also show the exact line that changed.
What happens if you run apply again?
Run:
terraform apply
If nothing changed, Terraform should report:
No changes. Your infrastructure matches the configuration.
This property is important.
Running the same configuration again does not create endless duplicate resources. Terraform uses state and provider data to understand that the desired objects already exist.
What is infrastructure drift?
Drift happens when real infrastructure no longer matches its Terraform configuration.
For example, someone might change the container manually:
docker stop terraform-web
Now the configuration says the container should be running, but reality is different.
Run:
terraform plan
Terraform checks the resource and plans the change needed to restore the declared configuration.
Cloud dashboards make manual changes tempting. If Terraform manages a resource, prefer changing its Terraform configuration.
Otherwise the next Terraform run may undo the manual change.
Destroy the example
When you finish, remove the resources:
terraform destroy
Terraform shows a destruction plan and asks for confirmation.
Read it, then type yes.
The container and image managed by this configuration are removed.
Destroying test resources is a good habit, especially when you start using paid cloud services.
Add an input variable
Hard-coded values are fine for a first example.
Reusable configurations need inputs.
Create a file named variables.tf:
variable "external_port" {
description = "Port used to access Nginx from the host"
type = number
default = 8080
}
Now use the variable in main.tf:
ports {
internal = 80
external = var.external_port
}
Run:
terraform plan
Terraform uses the default value.
You can provide another value from the command line:
terraform plan -var="external_port=9090"
Variables make configuration flexible without duplicating it.
Terraform supports strings, numbers, booleans, lists, maps, objects, and other types. Start with simple types and add complexity only when you need it.
Add an output
An output exposes useful information after an apply.
Create outputs.tf:
output "web_url" {
description = "URL of the local Nginx server"
value = "http://localhost:${var.external_port}"
}
Apply the configuration:
terraform apply
Terraform prints the output at the end.
You can read it again with:
terraform output web_url
Outputs are useful when one system needs information created by another.
A network configuration might output a subnet ID. An application configuration can then use that value.
How Terraform uses files
Terraform reads all .tf files in the current directory as one configuration.
These filenames are common:
main.tf
variables.tf
outputs.tf
versions.tf
The names are conventions, not special instructions.
You could put everything in main.tf, and Terraform would still work.
Splitting a larger configuration helps people find things. Do not split a five-line example into ten files.
What should go into Git?
Commit these files:
.tfconfiguration files.terraform.lock.hcl- documentation such as
README.md
Do not commit:
- the
.terraformdirectory - local state files
- plan files
- files containing secret variable values
A small .gitignore can look like this:
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
Not every .tfvars file contains secrets, but many do. Decide which variable files are safe before committing them.
The provider lock file is different. Commit .terraform.lock.hcl so every environment selects consistent provider versions.
Why state needs special care
State can contain infrastructure IDs, addresses, output values, and sensitive data returned by providers.
Do not commit terraform.tfstate to a shared Git repository.
For a team, store state in a remote backend designed for shared use.
Depending on your setup, this could be HCP Terraform or a supported cloud storage backend.
A team backend solves two important problems:
- everyone reads the same current state
- state locking can prevent two applies from running at once
If two people change the same infrastructure using different local state files, Terraform can make dangerous decisions.
Local state is fine for this tutorial. Shared production infrastructure needs remote state.
That is enough state knowledge for a beginner.
You do not need to learn state surgery commands now.
What is a Terraform module?
A module is a collection of Terraform resources used together.
Every Terraform configuration already has a module. The directory where you run Terraform is called the root module.
You can also call reusable modules.
For example, a team might create a module that always builds:
- a virtual network
- two private subnets
- standard security rules
- logging settings
An application team can use that module without copying every resource block.
Modules are similar to functions or packages in application code. They accept inputs, create related resources, and return outputs.
Do not turn every resource into a module.
Start with plain resources. Create a module when you find a useful boundary or repeated pattern.
How a team uses Terraform
A simple team workflow looks like this:
- A developer changes the Terraform configuration.
- They run
terraform fmtandterraform validate. - Automation runs
terraform planon the pull request. - The team reviews the code and the plan.
- After approval, trusted automation runs
terraform apply.
The plan is part of the review.
A one-line configuration change can replace an important resource. The code diff tells you what the developer requested. The Terraform plan tells you what Terraform expects to do.
Review both.
Also avoid running production applies from random laptops. A controlled environment creates a clearer audit trail and avoids differences between machines.
Terraform and environment variables
Providers often read credentials from environment variables.
For example, a provider might read an API token without placing it in main.tf.
This is better than hard-coding the token, but environment variables still need care.
Do not print them in logs. Do not save them in shell history. In CI, use the platform’s encrypted secret storage.
Terraform variables marked sensitive hide values in some command output, but the values may still exist in state.
Treat Terraform state as sensitive data.
Terraform versus a shell script
A shell script tells a computer which commands to run.
Terraform describes resources and remembers their identities.
You can make a shell script create a server, but then you must decide what happens when:
- the server already exists
- one property changes
- the resource was deleted manually
- several resources depend on each other
- you want to remove everything
Terraform has a model for these problems.
Shell scripts are still useful. They are just solving a different problem.
Terraform versus Docker
Docker packages and runs applications in containers.
Terraform creates and configures resources.
In our tutorial, Terraform used Docker’s API to create an image and container. Docker did the container work. Terraform described and tracked it.
The tools complement each other.
Terraform versus Kubernetes
Kubernetes runs and coordinates containerized applications across a cluster.
Terraform can create the cluster, networks, service accounts, and other surrounding infrastructure.
Kubernetes then manages the application workloads inside the cluster.
Terraform also has a Kubernetes provider, but that does not mean every Kubernetes object must be managed by Terraform. Many teams use Kubernetes manifests or Helm for application releases and Terraform for the cluster itself.
Keep the ownership boundary clear.
Terraform versus cloud-specific tools
AWS CloudFormation, Azure Bicep, and similar tools are tied closely to one cloud platform.
Terraform uses a common workflow across many platforms.
Neither approach is always better.
A cloud-specific tool may expose new platform features earlier. Terraform may be more familiar if your infrastructure crosses multiple services.
Choose based on the team and project, not on the idea that one tool must win everywhere.
Common beginner mistakes
Applying without reading the plan
Do not treat terraform apply like a routine confirmation dialog.
Read which resources will be created, changed, replaced, or destroyed.
Editing generated state
The state file is not a configuration file.
Use normal configuration changes and Terraform commands. Manual state changes are an advanced recovery tool.
Committing secrets
Do not put API keys and passwords directly in committed Terraform files.
Also remember that secrets may end up in state.
Making manual dashboard changes
Manual changes create drift and may be reversed by the next apply.
If Terraform manages a resource, change its configuration through Terraform.
Using one state for everything
A single state containing an entire company creates a large failure boundary.
You do not need to design a complex state structure on day one. Just know that unrelated systems should not grow forever inside one configuration.
Copying a large module without understanding it
Public modules can save time, but they can also create many resources.
Read the module’s inputs, outputs, resources, and version requirements before using it.
Assuming a successful plan guarantees a successful apply
A plan is a preview based on current information.
Permissions may be missing. APIs may fail. Another process may change the infrastructure before the apply.
The plan is valuable, but it is not a promise.
A small Terraform command reference
Initialize a project:
terraform init
Format its files:
terraform fmt
Validate the configuration:
terraform validate
Preview changes:
terraform plan
Apply changes:
terraform apply
List tracked resources:
terraform state list
Show outputs:
terraform output
Destroy managed resources:
terraform destroy
These commands cover most of your first week with Terraform.
When should you use Terraform?
Terraform is useful when infrastructure must be repeatable, reviewable, and maintained over time.
It is a good fit when:
- a project has several related infrastructure resources
- you need staging and production environments
- multiple people manage the same infrastructure
- you want changes reviewed in Git
- rebuilding manually would be slow or risky
Terraform may be unnecessary for a tiny experiment with one disposable resource.
There is a setup cost. The configuration and state need maintenance.
Use it when repeatability is worth that cost.
A useful mental model
When Terraform feels confusing, return to this loop:
- The configuration says what you want.
- State remembers what Terraform manages.
- Providers read and change the real systems.
- Plan shows the difference.
- Apply performs the approved actions.
That is Terraform.
Everything else builds on this loop.
You can learn modules, remote backends, workspaces, import operations, policy systems, and CI automation later.
First become comfortable reading a small configuration and its plan.
Then create something, change it, and destroy it.
That practical loop will teach you more than memorizing every Terraform term.