Skip to content

Tutorials

OpenTofu fundamentals

Turn the OpenTofu way from Launch your first instances into a project you can reuse: a pinned provider, variables instead of hard-coded values, a module that builds an instance, and state you can read and look after.

Well-Architected

Pillar: Operational excellence. One module builds every instance the same way, and a variable change becomes a reviewed plan instead of a manual edit.

Also touches: Security and sovereignty (keeping state and settings out of version control) and Reliability (rebuilding from code after a failed apply).

What you'll build

The same two instances as 4.1, web-01 and win-01, from a project laid out like this:

File What it holds
versions.tf The OpenTofu and provider versions, and the provider
variables.tf Every value that changes between projects or people
terraform.tfvars Your values for those variables
main.tf The key pair, the security group, and the two instances
outputs.tf The floating IP addresses
modules/instance/ A module that builds one instance: port, root volume, instance and floating IP

Before you start

  • Completed: Launch your first instances, including the OpenTofu way and its clean-up. This tutorial reuses the SSH key from 4.1.
  • OpenTofu 1.13.1, and your clouds.yaml entry set in OS_CLOUD as in 4.1.
  • Quota for 2 instances, 3 vCPUs, 10 GB of memory, 100 GB of storage and 2 floating IPs.
  • Time: about 45 minutes.
  • The code: every file in this tutorial is also in opentofu-fundamentals/ in learn-projects, the course's code repository, at release v1.2.0.

Step 1: Pin the versions

Create a folder called opentofu-fundamentals with versions.tf:

terraform {
  required_version = ">= 1.13"
  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 3.4"
    }
  }
}

provider "openstack" {}

~> 3.4 accepts any 3.x release from 3.4 on, but not 4.0, so a new major version can't change how your configuration behaves without you choosing it. The first tofu init records the exact provider version in .terraform.lock.hcl; keep that file with your configuration so everyone gets the same version.

OpenTofu began as a fork of Terraform and keeps its names: the settings block is terraform, and the files are terraform.tfvars, terraform.tfstate and .terraform.lock.hcl.

Step 2: Declare the variables

variables.tf names every value that differs between projects or people. A variable with a default is optional; one without must be set.

variable "prefix" {
  description = "Prefix for every resource name"
  type        = string
  default     = "tf"
}

variable "my_ip" {
  description = "Your public IP address in CIDR form, for example 203.0.113.10/32"
  type        = string
}

variable "network_name" {
  description = "Network the instances join"
  type        = string
  default     = "default"
}

variable "external_network" {
  description = "External network that floating IPs come from"
  type        = string
}

variable "linux_image" {
  description = "Name of the Linux image"
  type        = string
}

variable "windows_image" {
  description = "Name of the Windows image"
  type        = string
}

variable "public_key_path" {
  description = "Path to your SSH public key"
  type        = string
  default     = "~/.ssh/first-instance.pub"
}

variable "linux_flavour" {
  description = "Flavour for the Linux instance"
  type        = string
  default     = "v1.c1r2"
}

Set your values in terraform.tfvars, which OpenTofu reads on every run (in learn-projects it's terraform.tfvars.example, to copy). Use openstack image list and openstack network list --external for the names:

my_ip            = "<your-ip>/32"
external_network = "<external-network>"
linux_image      = "<linux-image>"
windows_image    = "<windows-image>"

Step 3: Write a module

A module is a folder of OpenTofu files that you call with inputs, like a function. This one builds one instance, the same way every time. Create modules/instance/ with four files.

modules/instance/versions.tf tells the module which provider it uses:

terraform {
  required_providers {
    openstack = {
      source = "terraform-provider-openstack/openstack"
    }
  }
}

modules/instance/variables.tf declares the module's inputs:

variable "name" {
  type = string
}

variable "image_name" {
  type = string
}

variable "flavour" {
  type = string
}

variable "volume_size" {
  description = "Root volume size in GB"
  type        = number
}

variable "key_pair" {
  description = "Key pair name, or null for none"
  type        = string
  default     = null
}

variable "network_id" {
  type = string
}

variable "security_group_ids" {
  type = list(string)
}

variable "external_network" {
  type = string
}

modules/instance/main.tf builds the port, the instance with its root volume, and the floating IP:

data "openstack_images_image_v2" "this" {
  name        = var.image_name
  most_recent = true
}

resource "openstack_networking_port_v2" "this" {
  name               = var.name
  network_id         = var.network_id
  security_group_ids = var.security_group_ids
}

resource "openstack_compute_instance_v2" "this" {
  name        = var.name
  flavor_name = var.flavour
  key_pair    = var.key_pair

  block_device {
    uuid                  = data.openstack_images_image_v2.this.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = var.volume_size
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.this.id
  }
}

resource "openstack_networking_floatingip_v2" "this" {
  pool       = var.external_network
  port_id    = openstack_networking_port_v2.this.id
  depends_on = [openstack_compute_instance_v2.this]
}

modules/instance/outputs.tf hands the address back to whoever called the module:

output "address" {
  description = "The instance's floating IP address"
  value       = openstack_networking_floatingip_v2.this.address
}

Step 4: Call the module

main.tf builds what both instances share, then calls the module once for each. for_each creates one security group rule per entry in the map.

data "openstack_networking_network_v2" "this" {
  name = var.network_name
}

data "openstack_networking_secgroup_v2" "default" {
  name = "default"
}

resource "openstack_compute_keypair_v2" "this" {
  name       = "${var.prefix}-key"
  public_key = file(pathexpand(var.public_key_path))
}

resource "openstack_networking_secgroup_v2" "access" {
  name = "${var.prefix}-access"
}

resource "openstack_networking_secgroup_rule_v2" "access" {
  for_each = { ssh = 22, rdp = 3389 }

  security_group_id = openstack_networking_secgroup_v2.access.id
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = each.value
  port_range_max    = each.value
  remote_ip_prefix  = var.my_ip
}

module "web_01" {
  source = "./modules/instance"

  name               = "${var.prefix}-web-01"
  image_name         = var.linux_image
  flavour            = var.linux_flavour
  volume_size        = 20
  key_pair           = openstack_compute_keypair_v2.this.name
  network_id         = data.openstack_networking_network_v2.this.id
  security_group_ids = [data.openstack_networking_secgroup_v2.default.id, openstack_networking_secgroup_v2.access.id]
  external_network   = var.external_network
}

module "win_01" {
  source = "./modules/instance"

  name               = "${var.prefix}-win-01"
  image_name         = var.windows_image
  flavour            = "v1.c2r8"
  volume_size        = 80
  network_id         = data.openstack_networking_network_v2.this.id
  security_group_ids = [data.openstack_networking_secgroup_v2.default.id, openstack_networking_secgroup_v2.access.id]
  external_network   = var.external_network
}

outputs.tf passes the module outputs on:

output "web_01_address" {
  value = module.web_01.address
}

output "win_01_address" {
  value = module.win_01.address
}

The Windows instance has no key pair: the module's key_pair defaults to null, and Windows signs in through the console as in 4.1.

Step 5: Check, plan and apply

Set OS_CLOUD as in 4.1, then:

tofu init
tofu fmt -check -recursive
tofu validate
tofu plan
tofu apply
  • init downloads the provider and sets up the module.
  • fmt -check reports files that aren't in OpenTofu's standard layout; tofu fmt -recursive fixes them.
  • validate checks the configuration without contacting Breqwatr Cloud.
  • plan shows 10 resources to add. Type yes when apply asks.

Sign in to the instances as in 4.1, using the addresses from tofu output.

Step 6: Read the state

OpenTofu records what it created in terraform.tfstate. That file is how the next plan knows what already exists.

tofu state list
tofu state show module.web_01.openstack_compute_instance_v2.this

state list shows every resource, with module resources under their module's name. state show prints one resource's attributes, including its ID on Breqwatr Cloud.

If an apply stops partway, for example on a network timeout, run tofu apply again. OpenTofu marks a resource it couldn't finish as tainted, and the next plan replaces it.

Look after the state:

  • Don't edit it by hand. Use tofu commands, which keep it consistent.
  • Keep it out of version control, with the files below in .gitignore. Commit .terraform.lock.hcl and your .tf files.
  • Keep one copy. Two people applying with separate state files each think they own the resources. When a team shares a configuration, it keeps state in a shared backend instead of on one laptop.
.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars

terraform.tfvars is ignored here because it holds your own IP address and names; each person keeps their own.

Step 7: Change something

Resize the Linux instance by changing a variable for one run:

tofu plan -var linux_flavour=v1.c1r4
tofu apply -var linux_flavour=v1.c1r4

The plan shows flavor_name changing from v1.c1r2 to v1.c1r4, updated in place: the instance is resized and restarts, and keeps its root volume and addresses. To keep the change, set linux_flavour in terraform.tfvars instead.

Check it worked

  • tofu plan after the apply says "No changes. Your infrastructure matches the configuration."
  • tofu state list lists 14 entries: 10 resources and 4 data sources.
  • openstack --os-cloud <cloud-name> server show tf-web-01 -c flavor shows v1.c1r4 after step 7.

Clean up

tofu destroy

Type yes when asked. OpenTofu deletes the 10 resources, and tofu state list is then empty.

What you learned

  • Pinning versions and keeping the lock file make a configuration behave the same for everyone.
  • Variables keep values out of the code, and a module builds the same thing every time it's called; see GUI, API and infrastructure as code.
  • State is OpenTofu's record of what exists. Keep it safe and in one place, and rerun apply if one stops partway.
  • A change to code is reviewed as a plan before it happens; see Operational excellence.

Next