Part 2 of my homelab OpenStack series. I’d built a private cloud on a mini PC and provisioned VMs by hand. This is how I turned that entire tenant into reproducible Infrastructure as Code — and the wall I hit connecting Terraform to a self-hosted cloud.
Where Part 1 Left Off
In the first post, I built a full OpenStack private cloud on a single mini PC: every service in Docker via Kolla-Ansible, the dashboard public through a Cloudflare Tunnel, a Neutron router with floating IPs, and direct SSH from my laptop into a real Ubuntu VM.
But everything was built by hand. Every network, router, security group, and VM came from typing openstack ... create one command at a time. If my cloud got wiped, I'd be rebuilding from memory.
That’s the “pet” model — nurse each resource individually. The next step up is the “cattle” model: describe what you want in code, and let a tool make reality match. Destroy everything, run one command, and the whole tenant rebuilds identically in about a minute.
That tool is Terraform. This post is how I codified the entire tenant — and why connecting Terraform to a self-hosted OpenStack was trickier than the official docs suggest.
The Mental Model: Two Layers
The thing that finally made this click for me: there are two completely different layers of cloud automation, and people conflate them.
- Building the cloud itself — installing Keystone, Nova, Neutron. That was Kolla-Ansible’s job. It’s the layer AWS hides from you entirely.
- Using the cloud to create resources — networks, VMs, floating IPs on the running cloud. That’s Terraform’s job.
On AWS you only ever see the second layer, because Amazon already did the first. Running your own private cloud means you’re suddenly responsible for both. Terraform talks to OpenStack’s APIs — the same ones the CLI uses — so everything I’d done manually had a direct Terraform equivalent.
Key insight: the Terraform you write against OpenStack is structurally identical to Terraform against AWS, GCP, or Azure. Same init → plan → apply workflow, same HCL language — only the provider changes. Learn it once, use it everywhere.
Setting Up (the Safe Way)
I’m cautious about anything that could touch a working setup, so I leaned hard on Terraform’s safety features:
- terraform plan is a dry run. It shows exactly what it would create/change/destroy and changes nothing. You can run it endlessly.
- terraform apply asks for confirmation. Nothing happens until you type yes.
- Terraform only manages what it created. It keeps a state file of its own resources. My hand-built VM and networks were invisible to it — it literally cannot touch what it doesn’t know about.
So I named every new resource tf-* and used a different tenant CIDR (10.10.0.0/24 vs my existing 10.0.0.0/24), guaranteeing it would sit alongside my manual setup, never replace it.
Install + repo skeleton
# Terraform via HashiCorp's apt repo
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
# git-ready project folder
mkdir -p ~/openstack-terraform && cd ~/openstack-terraform
git init
Critical first step — the **.gitignore.** Terraform generates state and credential files that contain secrets and must never hit GitHub:
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.tfvars
clouds.yaml
Pushing a terraform.tfstate full of passwords is a classic, dangerous beginner mistake. Block it from line one.
The Wall: Connecting Terraform to a Self-Hosted Cloud
This is where I lost the most time, and it’s the part tutorials gloss over because they assume a “normal” cloud. Here’s the chain of issues I hit, in order:
1. Wrong cloud name
The provider reads credentials from clouds.yaml. My first error:
Error: cloud admin does not exist in clouds.yaml
Kolla names its cloud entry kolla-admin, not admin. Fixed the provider block to match.
2. The public FQDN trap
Next, terraform plan hung for a full minute then failed:
Get "http://dashboard.example.com:9696/v2.0/networks": connection error
The problem: my clouds.yaml pointed at my public Cloudflare domain. But Cloudflare only tunnels the dashboard (ports 80/443) — not the OpenStack API ports like 9696 (Neutron) or 5000 (Keystone). So Terraform was trying to reach Neutron through a tunnel that has no route for it.
The fix: point Terraform at the internal VIP directly. Since Terraform runs on the host, it can reach the internal endpoints with no tunnel:
# clouds.yaml
auth_url: http://192.168.0.250:5000
3. The service catalog still pointed public
Even after fixing auth_url, it failed again on :9696 at the public domain. Why? After authenticating, Terraform asks Keystone "where are the other services?" via the service catalog — and the catalog had the public FQDN registered for Neutron.
Checking the endpoints confirmed it:
openstack endpoint list --service network
# public → http://dashboard.example.com:9696 (the one that hangs)
# internal → http://192.168.0.250:9696 (the one I want)
The clean fix is endpoint overrides in the provider, forcing Terraform straight to internal URLs:
provider "openstack" {
cloud = "kolla-admin"
endpoint_overrides = {
"identity" = "http://192.168.0.250:5000/v3/"
"network" = "http://192.168.0.250:9696/v2.0/"
"compute" = "http://192.168.0.250:8774/v2.1/"
"image" = "http://192.168.0.250:9292/"
}
}
Lesson: the public FQDN is for the human-facing dashboard only. Programmatic API access (Terraform, CLI) uses internal endpoints. That separation is normal and correct — but you have to wire it explicitly when the catalog defaults to public.
Once that was right, terraform plan read my existing external network and returned its ID — authenticated, talking to my cloud, creating nothing. The "hello world" of IaC.
Writing the Whole Tenant as Code
With the connection solid, I codified everything. A clean Terraform project splits into files by purpose:
- main.tf — provider + all resources
- variables.tf — configurable inputs with defaults
- outputs.tf — values printed after apply (the floating IP, an SSH command)
The resources mirror exactly what I’d built by hand:
# Tenant network + subnet
resource "openstack_networking_network_v2" "tenant_net" {
name = "tf-net"
admin_state_up = true
}
resource "openstack_networking_subnet_v2" "tenant_subnet" {
name = "tf-subnet"
network_id = openstack_networking_network_v2.tenant_net.id
cidr = "10.10.0.0/24"
ip_version = 4
dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}
# Router bridging tenant <-> external
resource "openstack_networking_router_v2" "router" {
name = "tf-router"
external_network_id = data.openstack_networking_network_v2.external.id
}
resource "openstack_networking_router_interface_v2" "router_iface" {
router_id = openstack_networking_router_v2.router.id
subnet_id = openstack_networking_subnet_v2.tenant_subnet.id
}
# Security group + SSH/ICMP rules, keypair, VM, floating IP...
The “aha” moment
Two things make this feel like magic the first time:
Dependencies are automatic. The subnet references tenant_net.id, the router references the subnet, the floating IP associates to the VM. Terraform reads these references, builds a dependency graph, and creates everything in the correct order — I never specify it. Run apply and you watch the keypair, security group, network, subnet, router, and router-interface all materialize in seconds, in dependency order.
Helper functions. cidrhost("10.10.0.0/24", 1) computes .1 of the range. Change the CIDR once and the gateway and allocation pool follow. That's the power of code over clicking.
A terraform plan showed the full picture:
Plan: 11 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ ssh_command = (known after apply)
+ vm_floating_ip = (known after apply)
Eleven resources — my entire tenant — defined, validated, changing nothing until I said so.
The Bugs I Hit Applying It
Because I was working with a current provider version, a few things had drifted from older examples:
Provider version drift. The resource I first used to attach a floating IP didn’t exist anymore:
The provider does not support resource type
"openstack_compute_floatingip_associate_v2".
In the v3 provider, floating IP association moved to the networking side. Swapped to openstack_networking_floatingip_associate_v2, which attaches via a port_id instead of an instance ID. Lesson: provider resources get renamed across major versions; the errors are precise, so fixes are quick once you know the replacement.
The floating IP wouldn’t attach. Even after the swap, the association silently didn’t happen — the vm.network[0].port attribute came back empty. The robust fix was looking up the port explicitly with a data source:
data "openstack_networking_port_v2" "vm_port" {
device_id = openstack_compute_instance_v2.vm.id
network_id = openstack_networking_network_v2.tenant_net.id
}
resource "openstack_networking_floatingip_associate_v2" "fip_assoc" {
floating_ip = openstack_networking_floatingip_v2.fip.address
port_id = data.openstack_networking_port_v2.vm_port.id
}
This queries “which port belongs to this VM on this network” — far more reliable than the array-index attribute.
Copy-paste mangling. A couple of errors came from values losing their variable {} wrapper or a URL dropping a colon (192.168.0.250.9696 instead of :9696). The lesson there: when pasting multi-line config, heredocs (cat > file <<'EOF') preserve formatting that a terminal paste sometimes mangles.
The Payoff: Idempotency
The moment it all clicked was a partial failure. My first apply created 10 of 11 resources, then failed on the VM (an image-name mismatch). I fixed the name and ran apply again.
Terraform didn’t recreate the 10 existing resources. It saw they already existed, left them alone, and built only the missing VM. That’s idempotency — re-running converges to the desired state without redoing finished work. It’s what makes Terraform safe to run over and over.
Final result: apply builds the whole tenant and prints:
ssh_command = "ssh ubuntu@192.168.0.233"
One command, ~60 seconds, the entire manual process — reproducible, version-controlled, in a git repo. And terraform destroy cleanly removes only the tf-* resources, nothing else.
Lessons Worth Keeping
- Public FQDN ≠ API endpoint. Tunnels expose the dashboard; Terraform and the CLI use internal endpoints. Override them explicitly when the catalog defaults to public.
- Check the service catalog (openstack endpoint list) when connections hang — the URL Terraform actually uses comes from there, not just auth_url.
- Provider versions drift. Resources get renamed across major versions. Read the precise error, find the replacement.
- Use data sources for reliable lookups — vm.network[0].port can be empty; an explicit port data source isn't.
- .gitignore your state and credentials first. Never commit *.tfstate or clouds.yaml.
- plan is free and safe. Run it constantly. Name new resources distinctly (tf-*) so they sit beside, never replace, existing infra.
- Idempotency is the whole point. Re-running apply fixes only what's missing.
Why This Was Worth It
I went from “I can click together a cloud” to “I can declare a cloud in code, version it, reproduce it, and destroy it on demand.” That second skill is the one that transfers directly to any cloud platform and any real infrastructure job.
The most valuable part wasn’t the working result — it was debugging the connection layer myself. Self-hosted OpenStack doesn’t hand you a clean managed endpoint like AWS does; you have to understand auth, the service catalog, and internal-vs-public routing to make Terraform work. That understanding is exactly what’s missing when you only ever consume someone else’s cloud.
What’s the trickiest thing you’ve had to wire Terraform into? Drop it in the comments.
Top comments (0)