Terraform Basics: Declarative Infrastructure
Ansible configures machines that exist. Terraform creates them.
The distinction matters because they solve different problems and the common setup uses both: Terraform provisions the servers, Ansible configures what runs on them.
Declarative, and the state file
You describe what should exist. Terraform works out how to get there.
terraform {
required_version = ">= 1.9"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.48"
}
}
}
resource "hcloud_server" "web" {
count = 3
name = "web-${count.index + 1}"
image = "debian-13"
server_type = "cx22"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.deploy.id]
labels = {
role = "web"
env = "prod"
}
}
terraform apply creates three servers. Change count to five and apply again and it creates two more, because it knows the other three already exist.
It knows because of the state file. This is the central concept and the source of most Terraform problems.
State maps configuration blocks to real resources: hcloud_server.web[0] corresponds to server ID 12345678. Without it, Terraform has no idea what it owns and will create duplicates of everything.
Treat state carefully
terraform {
backend "s3" {
bucket = "tf-state-example"
key = "prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "tf-state-lock"
}
}
Three requirements:
Remote. Local state on one laptop means nobody else can apply, and losing that laptop loses your infrastructure’s identity.
Locked. Two people applying simultaneously corrupts state. The DynamoDB table above provides the lock; other backends have their own mechanism.
Encrypted, and never in Git. State contains secrets in plaintext. A database resource records its generated password; a TLS resource records the private key. Committing state to a repository is committing credentials, and this catches people constantly.
Our sops and age guide covers the inputs. State is the output side of the same problem.
The workflow
terraform init # download providers, configure backend
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
Always plan, and read it. A plan looks like this:
# hcloud_server.web[0] must be replaced
-/+ resource "hcloud_server" "web" {
~ image = "debian-12" -> "debian-13" # forces replacement
must be replaced and forces replacement are the words to look for. Terraform is about to destroy and recreate that server, which on a stateful machine means losing whatever was on it.
That is the single most valuable habit: noticing the difference between an in-place update and a replacement, before rather than after.
Passing -out=tfplan and applying that file means you apply exactly what you reviewed, rather than re-planning against a world that may have changed.
Variables and outputs
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
variable "server_count" {
type = number
default = 3
}
output "web_ips" {
value = hcloud_server.web[*].ipv4_address
}
output "db_password" {
value = random_password.db.result
sensitive = true
}
sensitive = true keeps the value out of console output. It is still in state in plaintext, which is worth being clear about, because the marking is about display and not about storage.
The validation block catches bad input at plan time rather than producing a confusing provider error later.
Modules
Once you have three environments doing the same thing:
module "web_cluster" {
source = "./modules/web-cluster"
environment = "prod"
server_count = 5
server_type = "cx32"
}
modules/web-cluster/
├── main.tf
├── variables.tf
├── outputs.tf
└── README.md
The Terraform Registry hosts many public modules. They are arbitrary code that will run with your cloud credentials, so pin versions and read what you are importing, the same caution our Ansible guide applies to Galaxy roles.
Handing off to Ansible
resource "local_file" "inventory" {
filename = "${path.module}/inventory.ini"
content = <<-EOT
[web]
%{ for ip in hcloud_server.web[*].ipv4_address ~}
${ip}
%{ endfor ~}
[all:vars]
ansible_user=admin
EOT
}
Terraform creates the servers and writes the inventory; Ansible configures them. Cleaner than trying to do configuration management with remote-exec provisioners, which HashiCorp itself describes as a last resort.
For the initial bootstrap, cloud-init is the better tool, since it runs at first boot with no connection required.
Importing what exists
import {
to = hcloud_server.legacy
id = "12345678"
}
resource "hcloud_server" "legacy" {
name = "old-web-01"
image = "debian-12"
server_type = "cx22"
}
terraform plan # shows what import would do
terraform apply
Import blocks in configuration are better than the older terraform import command, because the operation appears in a plan and can be reviewed rather than being a side effect someone ran once.
terraform plan -generate-config-out=generated.tf will write a starting configuration for you, which saves a lot of transcription.
OpenTofu
In 2023 HashiCorp relicensed Terraform from MPL to the Business Source License, which is not an open-source licence. OpenTofu is the fork, now under the Linux Foundation.
tofu init
tofu plan
tofu apply
Broadly drop-in compatible, and the two have started to diverge as OpenTofu adds features independently, including built-in linting that Terraform does not have.
For a new project, OpenTofu is the safer licensing choice. For an existing Terraform setup, migration is straightforward and the reason to do it is licensing rather than capability.
When not to use it
One server you set up by hand. The state file and provider configuration exceed the value.
Configuration rather than provisioning. That is Ansible’s job.
Frequently changing, short-lived resources. Terraform’s model assumes resources persist. Things created and destroyed constantly fit an orchestrator better.
Terraform earns its place when infrastructure is large enough that nobody remembers what exists, or when it must be reproducible across environments. Below that, it is machinery you maintain for its own sake.
Frequently Asked Questions
What is the difference between Terraform and Ansible?
Terraform provisions infrastructure, creating servers, networks, and DNS records, and tracks what it made in a state file. Ansible configures machines that already exist. They are complementary rather than competing, and a common pattern is Terraform creating the servers and Ansible configuring them.
What is the Terraform state file and why does it matter?
It records which real resources correspond to which configuration blocks. Terraform compares desired configuration against state to decide what to change, so losing state means Terraform no longer knows it owns anything and will try to create duplicates of everything.
Why should state not be stored in Git?
State frequently contains secrets in plaintext, including database passwords and generated keys, because it records the full attributes of created resources. It also needs locking so two people cannot apply simultaneously, which Git does not provide. Use a remote backend with encryption and locking.
What is OpenTofu and should I use it?
OpenTofu is the fork created after HashiCorp changed Terraform to the Business Source License in 2023. It is under the Linux Foundation, remains open source, and is broadly drop-in compatible. For new projects it is the safer licensing choice, and the two have begun to diverge in features.
What does terraform plan actually do?
It refreshes state against reality, compares that to your configuration, and prints what it would change without changing anything. Reading the plan before every apply is the core discipline, because it is where you notice that a change you thought was an update is actually a destroy and recreate.
How do I import infrastructure I created by hand?
Write the resource block first, then run terraform import to associate it with the existing real resource. Recent versions also support import blocks in configuration, which makes the operation reviewable in a plan rather than being a side-effecting command.