Terraform and OpenTofu on OpenStack: Getting Started with the OpenStack Provider

· 3 min read · Terraform & OpenTofu Tutorials

OpenStack with Terraform #

OpenStack is open source software to build clouds, not a single cloud. Companies run it in their own data centers (private clouds), and several providers offer public clouds based on it. Every OpenStack cloud has its own endpoint, its own image and flavor names and its own external network, so the same Terraform code needs small changes from one cloud to another. The tutorial shows which values to look up.

Terraform and OpenTofu use the openstack provider, maintained by the community under the terraform-provider-openstack organization. It covers the compute (Nova), networking (Neutron), image (Glance), block storage (Cinder) and identity (Keystone) services. The workflow is the same as for AWS, and the networking is explicit like in a data center: a network, a subnet, a router and a floating IP.

Authentication with clouds.yaml #

Ask your cloud administrator or download from the dashboard (the Horizon web interface) one of these two files:

  • clouds.yaml, the format of the OpenStack SDK, which keeps several clouds in one file.
  • An openrc file, a shell script that sets OS_* environment variables.

With clouds.yaml, saved in ~/.config/openstack/clouds.yaml, choose the cloud by name. An entry looks like this (the values come from your cloud):

clouds.yaml
clouds:
  mycloud:
    auth:
      auth_url: https://keystone.example.com:5000/v3
      application_credential_id: "<id>"
      application_credential_secret: "<secret>"
    region_name: RegionOne
    auth_type: v3applicationcredential

Use an application credential (Identity menu in the dashboard) when your cloud supports it: it can be limited and revoked without changing your password. Then select the cloud with an environment variable:

$ export OS_CLOUD=mycloud

With an openrc file run source openrc.sh instead, and the provider reads OS_AUTH_URL, OS_USERNAME, OS_REGION_NAME and the other variables.

Provider #

providers.tf
terraform {
  required_version = ">= 1.6"

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

provider "openstack" {
  # the cloud is selected with OS_CLOUD (clouds.yaml) or OS_* variables (openrc)
}

variable "external_network_name" {
  type        = string
  description = "Name of the external (public) network of your cloud, see: openstack network list --external"
}

variable "image_name" {
  type        = string
  description = "Image name, see: openstack image list"
}

variable "flavor_name" {
  type        = string
  description = "Flavor name, see: openstack flavor list"
}

variable "floating_ip_pool" {
  type        = string
  description = "Floating IP pool, usually the name of the external network"
}

variable "admin_cidr" {
  type        = string
  description = "Address range that can connect with SSH, for example your public IP as x.x.x.x/32"
}

variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/id_ed25519.pub"
}

The names change in every cloud. The OpenStack command line client shows them: openstack network list --external, openstack image list and openstack flavor list.

Look up existing resources #

data.tf
data "openstack_networking_network_v2" "external" {
  name     = var.external_network_name
  external = true
}

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

data "openstack_compute_flavor_v2" "small" {
  name = var.flavor_name
}

Network, subnet and router #

A tenant network is private. To reach the internet, connect it to a router that has a gateway on the external network:

network.tf
resource "openstack_networking_network_v2" "main" {
  name           = "ditwl-demo"
  admin_state_up = true
}

resource "openstack_networking_subnet_v2" "main" {
  name            = "ditwl-demo"
  network_id      = openstack_networking_network_v2.main.id
  cidr            = "10.0.1.0/24"
  ip_version      = 4
  dns_nameservers = ["1.1.1.1", "9.9.9.9"]
}

resource "openstack_networking_router_v2" "main" {
  name                = "ditwl-demo"
  admin_state_up      = true
  external_network_id = data.openstack_networking_network_v2.external.id
}

resource "openstack_networking_router_interface_v2" "main" {
  router_id = openstack_networking_router_v2.main.id
  subnet_id = openstack_networking_subnet_v2.main.id
}

Security group #

security.tf
resource "openstack_networking_secgroup_v2" "web" {
  name        = "ditwl-demo-web"
  description = "SSH from the admin range"
}

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

A new Neutron security group normally starts with rules that allow outgoing traffic, so only the ingress rule is added here. Check the rules of your cloud with openstack security group rule list.

Key pair, port and instance #

The instance uses a port that is created first, so that the security group and the floating IP are attached to the port:

compute.tf
resource "openstack_compute_keypair_v2" "main" {
  name       = "ditwl-demo"
  public_key = file(pathexpand(var.ssh_public_key_path))
}

resource "openstack_networking_port_v2" "web" {
  name               = "ditwl-demo-web"
  network_id         = openstack_networking_network_v2.main.id
  admin_state_up     = true
  security_group_ids = [openstack_networking_secgroup_v2.web.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.main.id
  }
}

resource "openstack_compute_instance_v2" "web" {
  name      = "ditwl-demo-web"
  image_id  = data.openstack_images_image_v2.os.id
  flavor_id = data.openstack_compute_flavor_v2.small.id
  key_pair  = openstack_compute_keypair_v2.main.name

  network {
    port = openstack_networking_port_v2.web.id
  }
}

Floating IP #

A floating IP is a public address that the cloud maps to the private address of the port:

floating-ip.tf
resource "openstack_networking_floatingip_v2" "web" {
  pool = var.floating_ip_pool
}

resource "openstack_networking_floatingip_associate_v2" "web" {
  floating_ip = openstack_networking_floatingip_v2.web.address
  port_id     = openstack_networking_port_v2.web.id
}

output "web_public_ip" {
  value = openstack_networking_floatingip_v2.web.address
}

The router must be connected to the subnet before the floating IP can be associated. The provider normally works out this order, and if you see an error about the port not being reachable from the external network, add depends_on = [openstack_networking_router_interface_v2.main] to the association.

Run it #

Put the values of your cloud in a terraform.tfvars file. The ones below are examples:

terraform.tfvars
external_network_name = "public"
floating_ip_pool      = "public"
image_name            = "Ubuntu 24.04"
flavor_name           = "m1.small"
admin_cidr            = "203.0.113.25/32"
$ tofu init
$ tofu plan
$ tofu apply
$ tofu destroy

Use terraform instead of tofu if you prefer HashiCorp Terraform. Connect with ssh <user>@<floating ip>, where the user depends on the image (ubuntu for the Ubuntu cloud images).

Next steps #

#Terraform #OpenTofu #Openstack