🤖 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:
- resource "TYPE" "NAME" is one object. Its attributes sit inside the braces. data blocks only look something up (the image by name, the public network).
- References are the clever part. openstack_networking_network_v2.net.id means "the ID of that network, whatever it turns out to be". Terraform reads these references to work out the order: the subnet needs the network, so the network comes first. You never write the order down.
- count makes copies. var.server_count is a variable with a default of 1, and everything per-server (port, instance, floating IP, association) repeats that many times. count.index numbers them.
- Each server gets an explicit port (page 5). The floating IP is then attached to the port.
- depends_on adds an order Terraform cannot see by itself. Two of them are in the file, and the second one exists because of what happened in step 4.
- A volume and its attachment are separate resources, as on page 4.
⚠️ 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.
- The plan was only the four new objects. The first server, the network and the router were not touched.
- Running apply again with the same input is a no-op: "No changes". That property is called idempotent, and it is why you can run the same file every day.
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
- A server deleted by hand: Terraform noticed on the next plan and rebuilt it: the plan was exactly that one object. Its port, floating IP and association were still there and were reused.
- A rule added by hand to a group Terraform owns: "No changes". Terraform manages the rules it declared (each one is its own resource), and an extra rule it never heard of is invisible to it. So a Terraform-built firewall can quietly be a hole in practice. This is true for the way I wrote the file (separate rule resources); I did not test the alternative.
- -replace forces a destroy-and-create of one object, which is how you rebuild a server you suspect is broken. (I only planned it, I did not apply it.)
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)
- 18 objects (the 14 resources in the file, with count expanded to two servers). Terraform deleted them in reverse dependency order: the floating IP associations and security group rules first, the router interface, subnet, network and router last. This is the order I had to work out by hand at the end of page 5.
- The attached volume was deleted too, and its data with it. It is in the file, so destroy destroys it. For something you must keep, keep it outside the file or protect it with lifecycle { prevent_destroy = true } (not tested here).
- Afterwards the cloud holds only what it started with: private, public, router1.
What this page did not cover
- Heat, OpenStack's own template service: not installed, not run.
- Remote state, locking, workspaces, modules, terraform import for objects that already exist.
- Cloud-init user data to configure the server on first boot (see cloud-init), and real Linux images: only CirrOS was used.
- In-place changes and what the provider can and cannot change on a running server (resize, security groups) were not exercised, except for the scale-out above.
- prevent_destroy, ignore_changes, and OpenTofu (the open-source fork, which I did not run).
- Ansible and other tools that do the same job.
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 →