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.yamlentry set inOS_CLOUDas 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 releasev1.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:
initdownloads the provider and sets up the module.fmt -checkreports files that aren't in OpenTofu's standard layout;tofu fmt -recursivefixes them.validatechecks the configuration without contacting Breqwatr Cloud.planshows 10 resources to add. Typeyeswhenapplyasks.
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.
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
tofucommands, which keep it consistent. - Keep it out of version control, with the files below in
.gitignore. Commit.terraform.lock.hcland your.tffiles. - 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.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:
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 planafter the apply says "No changes. Your infrastructure matches the configuration."tofu state listlists 14 entries: 10 resources and 4 data sources.openstack --os-cloud <cloud-name> server show tf-web-01 -c flavorshowsv1.c1r4after step 7.
Clean up¶
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
applyif one stops partway. - A change to code is reviewed as a plan before it happens; see Operational excellence.
Next¶
- Configuration with Ansible: configure the instances OpenTofu builds.
- OpenStack provider documentation: every resource and data source.
- Modules and state: the OpenTofu reference.