🤖 Automate Your OpenStack Cloud with Terraform: A Whole Network and Server from One File

Pages 3 to 5 were a lot of typing. Put it all in one file instead: network, router, security group, key pair, server, floating IP and a volume. Plan it, apply it, scale it from one server to two, break it by hand, and destroy it. Run for real against the DevStack cloud, including the apply that failed the first time

💡 How this page was made. Everything shown as output was run for real on 8 October 2026 against the DevStack cloud from the earlier pages (stable/2026.1), as the ordinary demo user, using Terraform 1.16.5 and the OpenStack provider 3.4.0, installed on the DevStack machine itself. Long plans are cut down to their headers and IDs are shortened; the transcripts say so. Everything the page builds was destroyed at the end. If Terraform is new to you, read Terraform Basics first: this page only adds the OpenStack part.

Why a file instead of commands

On the previous pages you built things one command at a time. That is fine for learning and terrible for repeating: you forget a step, the second environment differs from the first, nobody knows what exists. Infrastructure as code fixes this: you write down what you want, and a tool makes the cloud match it.

OpenStack has its own tool for this, Heat (templates called stacks). It is a normal OpenStack service, with its own API and templates. It is not installed in this DevStack (openstack stack list says 'stack list' is not an openstack command, and the service catalog has no orchestration entry), so this page does not use it and shows nothing about how it behaves. This page uses Terraform, which needs nothing installed in the cloud: it only talks to the ordinary OpenStack APIs, from any machine that can reach them. The same tool works on AWS, Azure, Oracle and the rest, which is why many people pick it even on OpenStack.

Step 1: get Terraform and the provider

The provider is the plug-in that knows how to talk to OpenStack. It reads the same clouds.yaml as the openstack command, so the password never goes into the file.

$ curl -sO https://releases.hashicorp.com/terraform/1.16.5/terraform_1.16.5_linux_amd64.zip
$ curl -sO https://releases.hashicorp.com/terraform/1.16.5/terraform_1.16.5_SHA256SUMS
$ grep linux_amd64.zip terraform_1.16.5_SHA256SUMS | sha256sum -c
terraform_1.16.5_linux_amd64.zip: OK
$ unzip -oq terraform_1.16.5_linux_amd64.zip terraform
$ ./terraform version
Terraform v1.16.5
on linux_amd64
$ ./terraform init
Initializing provider plugins...
- Finding terraform-provider-openstack/openstack versions matching "~> 3.0"...
- Installing terraform-provider-openstack/openstack v3.4.0...
- Installed terraform-provider-openstack/openstack v3.4.0 (self-signed, key ID 4F80527A391BEFD2)
...
Terraform has been successfully initialized!
(trimmed: the signing and lock file paragraphs)

The download is checked against HashiCorp's published checksums. terraform init fetched the provider from the public registry (~> 3.0 means "any 3.x", it picked 3.4.0) and wrote a lock file that pins the exact version. Keep that file with your code.

Step 2: the file

This one main.tf is the final, working version. It builds the whole of page 3 to page 5 at once:

terraform {
  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 3.0"
    }
  }
}

# credentials come from clouds.yaml, entry "devstack" (the demo user)
provider "openstack" {
  cloud = "devstack"
}

variable "server_count" {
  type    = number
  default = 1
}

data "openstack_images_image_v2" "cirros" {
  name = "cirros-0.6.3-x86_64-disk"
}

resource "openstack_networking_network_v2" "net" {
  name = "tf-net"
}

resource "openstack_networking_subnet_v2" "sub" {
  name            = "tf-subnet"
  network_id      = openstack_networking_network_v2.net.id
  cidr            = "192.168.70.0/24"
  dns_nameservers = ["1.1.1.1"]
}

data "openstack_networking_network_v2" "public" {
  name = "public"
}

resource "openstack_networking_router_v2" "rtr" {
  name                = "tf-router"
  external_network_id = data.openstack_networking_network_v2.public.id
}

resource "openstack_networking_router_interface_v2" "rtr_if" {
  router_id = openstack_networking_router_v2.rtr.id
  subnet_id = openstack_networking_subnet_v2.sub.id
}

resource "openstack_networking_secgroup_v2" "sg" {
  name        = "tf-ssh-ping"
  description = "ssh and ping, made by Terraform"
}

resource "openstack_networking_secgroup_rule_v2" "ssh" {
  security_group_id = openstack_networking_secgroup_v2.sg.id
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
}

resource "openstack_networking_secgroup_rule_v2" "icmp" {
  security_group_id = openstack_networking_secgroup_v2.sg.id
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "icmp"
}

resource "openstack_compute_keypair_v2" "key" {
  name       = "tf-key"
  public_key = file("~/.ssh/lab_ed25519.pub")
}

resource "openstack_networking_port_v2" "web" {
  count              = var.server_count
  name               = "tf-web-${count.index + 1}"
  network_id         = openstack_networking_network_v2.net.id
  security_group_ids = [openstack_networking_secgroup_v2.sg.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.sub.id
  }
}

resource "openstack_compute_instance_v2" "web" {
  count       = var.server_count
  name        = "tf-web-${count.index + 1}"
  image_id    = data.openstack_images_image_v2.cirros.id
  flavor_name = "cirros256"
  key_pair    = openstack_compute_keypair_v2.key.name

  network {
    port = openstack_networking_port_v2.web[count.index].id
  }

  depends_on = [openstack_networking_router_interface_v2.rtr_if]
}

resource "openstack_networking_floatingip_v2" "fip" {
  count = var.server_count
  pool  = "public"
}

resource "openstack_networking_floatingip_associate_v2" "fip" {
  count       = var.server_count
  floating_ip = openstack_networking_floatingip_v2.fip[count.index].address
  port_id     = openstack_networking_port_v2.web[count.index].id

  # the router must be wired up first, or Neutron says the outside is not reachable
  depends_on = [openstack_networking_router_interface_v2.rtr_if]
}

resource "openstack_blockstorage_volume_v3" "data" {
  name = "tf-data"
  size = 1
}

resource "openstack_compute_volume_attach_v2" "data" {
  instance_id = openstack_compute_instance_v2.web[0].id
  volume_id   = openstack_blockstorage_volume_v3.data.id
}

output "web_addresses" {
  value = openstack_networking_floatingip_v2.fip[*].address
}

How to read it:

⚠️ Provider versions rename things. My first draft used the resource openstack_compute_floatingip_associate_v2, which most examples on the internet still show. With this provider version, terraform validate refused it:

` $ ./terraform validate Error: Invalid resource type

on main.tf line 95, in resource "openstack_compute_floatingip_associate_v2" "fip": 95: resource "openstack_compute_floatingip_associate_v2" "fip" {

The provider terraform-provider-openstack/openstack does not support resource type "openstack_compute_floatingip_associate_v2". `

The networking one (openstack_networking_floatingip_associate_v2) replaces it. When copied code fails like this, check the provider's documentation for your version.

Step 3: plan

terraform plan
$ ./terraform plan -no-color | grep -E '^  # |^Plan:'      (the full plan is about 400 lines; only the headers are shown)
  # openstack_blockstorage_volume_v3.data will be created
  # openstack_compute_instance_v2.web[0] will be created
  # openstack_compute_keypair_v2.key will be created
  # openstack_compute_volume_attach_v2.data will be created
  # openstack_networking_floatingip_associate_v2.fip[0] will be created
  # openstack_networking_floatingip_v2.fip[0] will be created
  # openstack_networking_network_v2.net will be created
  # openstack_networking_port_v2.web[0] will be created
  # openstack_networking_router_interface_v2.rtr_if will be created
  # openstack_networking_router_v2.rtr will be created
  # openstack_networking_secgroup_rule_v2.icmp will be created
  # openstack_networking_secgroup_rule_v2.ssh will be created
  # openstack_networking_secgroup_v2.sg will be created
  # openstack_networking_subnet_v2.sub will be created
Plan: 14 to add, 0 to change, 0 to destroy.

Plan compares the file with reality and shows what would change, without touching anything. 14 objects to create, nothing to change or destroy. In a team this is the step you read carefully: a destroy line you did not expect is the whole point of the step.

Step 4: apply, and the first one failed

terraform apply
$ ./terraform apply -auto-approve          (the first run, lines trimmed to the ones that matter)
openstack_compute_keypair_v2.key: Creation complete after 0s
openstack_networking_secgroup_v2.sg: Creation complete after 1s
openstack_networking_network_v2.net: Creation complete after 4s
openstack_networking_secgroup_rule_v2.icmp: Creation complete after 3s
openstack_blockstorage_volume_v3.data: Creation complete after 4s
openstack_networking_floatingip_v2.fip[0]: Creation complete after 6s
openstack_networking_secgroup_rule_v2.ssh: Creation complete after 5s
openstack_networking_subnet_v2.sub: Creation complete after 2s
openstack_networking_router_v2.rtr: Creation complete after 6s
openstack_networking_port_v2.web[0]: Creation complete after 1s
openstack_networking_router_interface_v2.rtr_if: Creation complete after 6s
openstack_compute_instance_v2.web[0]: Creation complete after 23s
openstack_compute_volume_attach_v2.data: Creation complete after 23s

Error: Error associating openstack_networking_floatingip_associate_v2 floating_ip bfc09258-... with port b9b30307-...: Expected HTTP response code [200] when accessing [PUT http://10.0.2.15/networking/v2.0/floatingips/bfc09258-...], but got 404 instead: {"NeutronError": {"type": "ExternalGatewayForFloatingIPNotFound", "message": "External network 666227c5-... is not reachable from subnet 24eb5b15-....  Therefore, cannot associate Port b9b30307-... with a Floating IP.", "detail": ""}}

  with openstack_networking_floatingip_associate_v2.fip[0],
  on main.tf line 105, in resource "openstack_networking_floatingip_associate_v2" "fip":
apply took 58.87s
(IDs shortened with "...")

Thirteen objects were created, and one was refused. The error is Neutron's: the floating IP could not be attached because the outside was not reachable from the subnet. Why? The association needs the floating IP and the port, but nothing told Terraform it also needs the router interface. Terraform builds independent things in parallel, so it tried to attach the address a few seconds before the router was wired to the subnet (look at the "Creation complete" times: the port was ready after 1 s, the router interface after 6 s).

That is the classic Terraform-on-a-cloud trap: a dependency that exists in the cloud's logic but not in your references. The fix is the second depends_on in the file above. I did not rerun the whole thing to prove the fix from scratch: I ran apply a second time, which created the missing association (the earlier objects were already in place), and then added depends_on so a first run should work. The failure itself is the real one.

After a failed apply, the objects that were created are kept and recorded. Nothing is rolled back:

$ ./terraform plan        (right after the failure; the association is already in the state, created by the second apply)
No changes. Your infrastructure matches the configuration.

(config fixed with depends_on; check from inside the server Terraform built)
$ ssh cirros@172.24.4.128 'hostname; ip -4 -o addr show eth0 | cut -c1-70; lsblk -d -o NAME,SIZE | grep vd'
tf-web-1
2: eth0    inet 192.168.70.204/24 brd 192.168.70.255 scope global dyna
vda    1G
vdb    1G
$ openstack server list -c Name -c Status -c Networks       (plain OpenStack tools see the same objects)
| tf-web-1 | ACTIVE | tf-net=192.168.70.204 |
$ openstack router list -c Name -c Status
| router1   | ACTIVE |
| tf-router | ACTIVE |

Look at what that proves: the server is really there, it has the address from the subnet, and the volume shows up as a second disk (vdb, 1 GB). The ordinary openstack commands see the same objects. Terraform builds them in the normal way, and nothing marks them as "Terraform's" on the cloud side: only its state file knows.

💡 The state file. terraform.tfstate (about 30 KB here) is Terraform's memory of what it built, with the real IDs. Lose it and Terraform no longer knows these objects exist; it would try to create them again. Real teams keep it in shared, locked storage (a "backend"), not on a laptop. It also contains the generated IDs and, with some resources, secrets: do not commit it to git. This page kept it on the local disk of the lab machine.

Step 5: change the file, not the cloud

Want two servers? Change one number. (I passed it on the command line with -var; changing the default in the file does the same.)

$ ./terraform plan -var server_count=2 | grep -E '^  # |^Plan:'
  # openstack_compute_instance_v2.web[1] will be created
  # openstack_networking_floatingip_associate_v2.fip[1] will be created
  # openstack_networking_floatingip_v2.fip[1] will be created
  # openstack_networking_port_v2.web[1] will be created
Plan: 4 to add, 0 to change, 0 to destroy.
$ ./terraform apply -var server_count=2 -auto-approve | grep -E "Creation complete|Apply complete"
openstack_networking_port_v2.web[1]: Creation complete after 2s
openstack_networking_floatingip_v2.fip[1]: Creation complete after 2s
openstack_networking_floatingip_associate_v2.fip[1]: Creation complete after 2s
openstack_compute_instance_v2.web[1]: Creation complete after 22s
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
$ openstack server list -c Name -c Networks
| tf-web-2 | tf-net=172.24.4.148, 192.168.70.225 |
| tf-web-1 | tf-net=172.24.4.128, 192.168.70.204 |
$ ./terraform apply -var server_count=2 -auto-approve | grep -E "No changes|Apply complete"
No changes. Your infrastructure matches the configuration.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Step 6: what if someone changes things by hand?

(someone deletes a server by hand)
$ openstack server delete --wait tf-web-2
$ ./terraform plan -var server_count=2 | grep -E "^  # |^Plan:"
  # openstack_compute_instance_v2.web[1] will be created
Plan: 1 to add, 0 to change, 0 to destroy.
$ ./terraform apply -var server_count=2 -auto-approve | grep -E "Creation complete|Apply complete"
openstack_compute_instance_v2.web[1]: Creation complete after 22s
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

(someone adds a security group rule by hand)
$ openstack security group rule create --proto tcp --dst-port 8080 --ingress tf-ssh-ping
$ ./terraform plan -var server_count=2 | grep -E "No changes|^  # |^Plan:"
No changes. Your infrastructure matches the configuration.

(force a replacement)
$ ./terraform plan -var server_count=2 -replace='openstack_compute_instance_v2.web[1]' | grep -E '^  # |^Plan:|replace'
-/+ destroy and then create replacement
  # openstack_compute_instance_v2.web[1] will be replaced, as requested
Plan: 1 to add, 0 to change, 1 to destroy.

(the files in the working directory; terraform.tfstate lists 16 entries: 14 resource blocks and 2 data sources)
 1430 .terraform.lock.hcl
 3413 main.tf
30599 terraform.tfstate
30645 terraform.tfstate.backup

How does Terraform know a deleted object is gone? It refreshes by asking the API. A very small test shows what that looks like when an object vanishes:

(a throwaway config with one network "drift-net"; then the network is deleted with the CLI)
$ ./terraform apply -auto-approve | grep "Apply complete"
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ openstack network delete drift-net
$ ./terraform plan | sed -n 1,10p
openstack_networking_network_v2.n: Refreshing state... [id=9f2f6966-3b2d-42b9-a443-adae50f76048]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # openstack_networking_network_v2.n will be created

There was no warning: the object was quietly dropped from the state and the plan just says "will be created". Another reason to read the plan.

Step 7: destroy

terraform destroy
$ ./terraform plan -destroy -var server_count=2 | grep -E '^Plan:'
Plan: 0 to add, 0 to change, 18 to destroy.
$ ./terraform destroy -var server_count=2 -auto-approve | grep -E "Destruction complete|Destroy complete"
openstack_networking_floatingip_associate_v2.fip[0]: Destruction complete after 0s
openstack_networking_floatingip_associate_v2.fip[1]: Destruction complete after 1s
openstack_networking_secgroup_rule_v2.icmp: Destruction complete after 1s
openstack_networking_floatingip_v2.fip[0]: Destruction complete after 1s
openstack_networking_floatingip_v2.fip[1]: Destruction complete after 1s
openstack_networking_secgroup_rule_v2.ssh: Destruction complete after 2s
openstack_compute_volume_attach_v2.data: Destruction complete after 10s
openstack_blockstorage_volume_v3.data: Destruction complete after 9s
openstack_compute_instance_v2.web[1]: Destruction complete after 10s
openstack_compute_instance_v2.web[0]: Destruction complete after 10s
openstack_compute_keypair_v2.key: Destruction complete after 0s
openstack_networking_port_v2.web[0]: Destruction complete after 2s
openstack_networking_port_v2.web[1]: Destruction complete after 2s
openstack_networking_secgroup_v2.sg: Destruction complete after 0s
openstack_networking_router_interface_v2.rtr_if: Destruction complete after 2s
openstack_networking_subnet_v2.sub: Destruction complete after 1s
openstack_networking_network_v2.net: Destruction complete after 1s
openstack_networking_router_v2.rtr: Destruction complete after 3s
Destroy complete! Resources: 18 destroyed.
$ openstack network list -c Name -f value ; openstack router list -f value -c Name
private
public
router1
(servers, volumes, floating IPs and key pairs: all lists empty; only the "default" security group is left)

What this page did not cover

The cheat sheet

terraform init                      download the provider, write the lock file (once per directory)
terraform fmt ; terraform validate  tidy the file; check it makes sense
terraform plan                      what WOULD change (read it!)
terraform apply                     do it (-auto-approve skips the question: scripts only)
terraform apply -var name=value     change an input without editing the file
terraform plan -replace='TYPE.NAME' what a forced rebuild would do
terraform state list                what Terraform thinks it manages
terraform output                    show the "output" values (e.g. the floating IPs)
terraform destroy                   delete everything in the file, in the right order
provider "openstack" { cloud = "devstack" }    use clouds.yaml, keep passwords out of the file
depends_on = [...]                  an order Terraform cannot guess from references

Infrastructure as code: because "I clicked around until it worked" is not a disaster recovery plan, and the person who clicked has since left the company.

← Your Own Networks on OpenStack: Subnets, Routers, Ports and Floating IPs (Neutron) · ↑ All tutorials · OpenStack for Real: Deployment Tools, High Availability, Storage, Upgrades and What to Decide First →