WebDisk
Public cloud

Managing Apache CloudStack with Terraform: from API keys to a Kubernetes cluster

Published:

WebDisk Blog · category: Public cloud · reading time: ~26 minutes

In short:- Apache CloudStack is an open-source IaaS cloud orchestrator—a single API over KVM servers, storage and networking. Its concepts (zone, offering, network, VPC, project) map almost 1:1 onto Terraform resources.- Configuring the provider takes three values: the API endpoint, a key and a secret. The rest is ordinary Terraform—plan, apply, state kept remotely in S3, with no extra service for locking.- We show a complete example: a Kubernetes cluster on CloudStack described in code—from the VPC and ACL rules to a ready kubeconfig and components deployed with Helm. >Not working from a terminal? Skip the code blocks—the first half of the text describes CloudStack itself and its architecture, and reads fine without them.

It is Monday and a ticket comes in: “we need the same environment as production, only smaller”. In a cloud clicked together by hand, that request means two days of work, a list in a notepad and the quiet certainty that something will end up different anyway—because someone will forget a single firewall rule or pick a different disk offering. In a cloud described in code it means copying a directory, changing four numbers in the variables file and running the pipeline.

This article is about how to reach that second state on Apache CloudStack—the platform that runs our public cloud. We will start with a proper introduction to CloudStack itself: what it is, what it is made of and what concepts it works with—because without that, Terraform is only syntax. Then we move on to configuring the provider, the first resources and a full, real-world example: a Kubernetes cluster that we build entirely from Terraform. At the end—a list of the pitfalls that cost us time, so that you do not have to discover them for yourself. The text is written for two groups: administrators who are only getting to know CloudStack and want to understand its concepts, and teams who know Terraform from the hyperscalers and are looking for the equivalents in an open source cloud.

What exactly is Apache CloudStack?

Apache CloudStack is an open-source platform for building IaaS clouds—software that turns racks full of servers, storage arrays and switches into a cloud with a self-service panel and an API. The user clicks “create a machine” and CloudStack decides which physical host to run it on, creates its disk in storage, plugs it into the right network, assigns an IP address and hands back a console in the browser. It is the layer that hyperscalers call the “control plane”—except that here you can download it, read it and run it yourself.

The project has a long history: it started as a commercial product from Cloud.com, moved to Citrix, and in 2012 was handed over to the Apache Software Foundation, where it became a top-level project in 2013. Since then it has been developed as a classic ASF project—with a public mailing list, votes on releases and the Apache 2.0 license, with no “enterprise edition” and no dual-licensing trap. It powers public clouds, telco clouds and private environments in companies that do not want to give up control over the virtualization layer.

It is worth placing CloudStack in context right away, because the most common question is: “how is this different from OpenStack?”. In short: the philosophy of complexity. OpenStack is a collection of several dozen cooperating projects (Nova, Neutron, Cinder, Keystone, Glance…), each with its own database, its own API and its own life cycle—which gives enormous flexibility at the cost of a sizeable team maintaining the platform itself. CloudStack is one Java application (the management server) and one MySQL database holding the entire cloud model. The installation fits into a handful of packages; an upgrade means swapping the package and migrating the database schema. The price of that simplicity is less modularity—it is harder to replace a single subsystem with your own. For a mid-sized operator that is usually a good trade.

CloudStack does not virtualize anything itself—it drives hypervisors (a hypervisor is the software that runs virtual machines on a physical server). It supports KVM (the most popular choice, and ours), VMware vSphere, XCP-ng/XenServer and a few that matter less today. For storage it speaks NFS, iSCSI, local disks and—crucially for us—Ceph RBD.

Infrastructure hierarchy: zone, pod, cluster, host

To talk to CloudStack you have to adopt its way of dividing the world. The layers run from the largest to the smallest:

  • Zone—the largest unit, in practice a single data center or a single availability zone. A zone has its own public network, its own secondary storage and its own set of system VMs. It is the first thing you specify when creating anything at all.
  • Pod—usually a single rack or a row of racks, in practice one broadcast domain for the management network.
  • Cluster—a group of hosts running the same hypervisor type and sharing the same primary storage. It is the boundary within which CloudStack can freely live-migrate machines.
  • Host—a single physical server with a hypervisor and the CloudStack agent.

On top of that, two kinds of disk space that should not be confused:

  • Primary storage—this is where the disks of running machines live. It has to be fast and visible to the hosts in the cluster. For us these are Ceph RBD pools (one on NVMe, a separate one on capacity drives).
  • Secondary storage—the store for system templates, ISO images, snapshots and exported volumes. It does not have to be fast; it has to be capacious and reachable across the whole zone. Classically NFS.

System VMs, or a cloud that serves itself

One of CloudStack’s more distinctive traits: it delivers part of its own functionality through virtual machines that it creates for itself. There is no large, centralized software router here and no separate cluster of network services—there is a handful of small VMs, one per job:

  • Virtual Router (r-*-VM)—the heart of a tenant’s network, that is, of a single customer or project isolated from the rest. For every isolated network or VPC, CloudStack starts a router that provides DHCP, DNS, SNAT (source address translation, thanks to which machines on a private network reach the internet through one shared public address), port forwarding, a firewall, a load balancer and site-to-site VPN. It can be ordered in a redundant variant: a pair of routers shares one address over VRRP, so when the active one dies, the other takes it over within seconds.
  • Secondary Storage VM (s-*-VM)—handles downloading and registering templates, uploading ISOs, snapshots and copying images between zones.
  • Console Proxy (v-*-VM)—tunnels VNC from the hosts to the browser; it is what makes the “Console” button in the panel work.

This architecture has two important practical consequences. First: network services scale together with the number of networks, because each one gets its own router—there is no single bottleneck for the whole cloud. Second, and this is a fair warning: the load balancer in CloudStack lives inside the Virtual Router and is a layer-four balancer (TCP, with so-called sticky sessions, meaning a client is pinned to one server for the duration of a session, and—since CloudStack 4.22—with SSL termination on the router itself; in earlier releases SSL offloading required an external appliance). It is not an equivalent of an ALB with path- and header-based routing—a real layer-seven ingress you have to stand up yourself, for example in Kubernetes. The throughput of such an LB is limited by a single router, so with heavy traffic you plan it differently.

The accounting hierarchy: domain, account, user, project

Alongside the infrastructure, CloudStack keeps a second hierarchy—the one that decides who sees what and who pays for it:

  • Domain—a tree-shaped organizational structure. The ROOT domain contains everything, partner domains can hang below it, and customer domains below those, each with its own limits.
  • Account—the owner of resources and the billing boundary. An account can have several users.
  • User—a specific person or integration; it is at the user level that API keys are generated.
  • Project—a container for resources shared by many accounts, with a shared limit and a shared bill.

Projects are particularly important for Terraform, because almost every resource takes a project parameter—and leaving it out means the machine will land in the account’s private space instead of the shared one. It is one of the more frequent reasons for “why can’t I see what I just created”.

Offerings: a menu the user does not compose

Here CloudStack clearly differs from AWS, and it is the difference that trips up everyone arriving from the hyperscalers. In CloudStack you do not define a machine’s parameters in Terraform—you pick an item from a menu composed by the cloud operator. That menu consists of offerings:

  • Service offering—the compute profile: number of vCPUs, clock speed, RAM, optional IOPS limits (disk operations per second) and host tags. It can be fixed (like t3.medium) or customized—in which case you supply the parameters yourself when creating the machine, within the bounds set by the operator.
  • Disk offering—the volume profile: size (or “custom”), storage type, IOPS/throughput guarantees.
  • Network offering—the most interesting of them: it defines which services a network provides. Whether it has its own router with SNAT, whether it has DHCP and DNS, whether it offers a load balancer, a firewall, a VPN, whether it runs in redundant mode. Changing the network offering changes what the network can do, not just its “size”.
  • VPC offering—the same thing one floor up, for an entire private cloud.

The practical conclusion for Terraform: in code you refer to offerings by name or by UUID; you do not describe their parameters. If an offering is of the “customized” type, the concrete values travel in the details field.

Networking: isolated, shared and VPC

CloudStack’s network model looks like this:

  • Isolated network—the private network of one account or project, with its own VLAN or VXLAN (two ways of carving out a separate network on shared cabling; VXLAN is newer and removes the limit of roughly four thousand networks inherent to VLANs) and its own Virtual Router. The router does SNAT to the internet, and you let inbound traffic through with port forwarding, static NAT or LB rules. The firewall works on the public address, is stateful and closed by default.
  • Shared network—a common network for many accounts, usually with a pool of addresses handed out by DHCP. Without a per-tenant router of its own.
  • VPC—a private cloud with multiple tiers (subnets), a shared router and ACL lists between the tiers. The equivalent of a VPC from AWS, with one important difference: ACL rules in CloudStack are stateful, so the reply to an allowed connection comes back without a separate rule; ingress and egress are numbered and configured separately. This is the variant we use for everything more serious.
  • Security groups—a traffic filter attached to the machines themselves rather than to a network. They work in “basic” zones, and also in “advanced” zones as long as the operator enabled them when creating the zone—they then isolate machines within one shared network spanning the whole zone (on KVM; on XenServer/XCP-ng only with the network in bridge mode). They are not combined with isolated networks or with a VPC—there the role of the filter is played by the firewall on the public address and by ACL lists.

The API: single, signed, asynchronous

The whole cloud exposes one HTTP API at /client/api. Calls are signed with the HMAC-SHA1 algorithm using a pair of API key + secret, and you get the response in JSON or XML. The web panel has no “private” endpoints—it calls exactly the same API you do.

The second trait to know before you start automating: most state-changing operations are asynchronous. deployVirtualMachine does not return a ready machine—it returns the identifier of a job whose progress you poll through queryAsyncJobResult. The Terraform provider does this for you, but it explains why some operations take a while, why “eventual consistency” shows up and why you sometimes have to wait explicitly for a service to become ready (we will come back to this in the Kubernetes example).

For manual work with this API there is the official client CloudMonkey (cmk)—excellent for exploration and for pulling out the UUIDs you will later paste into Terraform:

# list of zones and compute offerings—useful when writing your first module
cmk list zones filter=id,name
cmk list serviceofferings filter=id,name,cpunumber,memory
cmk list networkofferings filter=id,name,state

Why describe CloudStack in code instead of clicking through the panel?

The CloudStack panel is complete—you can do everything in it. The problem is not its capabilities, but what the panel by its nature does not provide:

  • Repeatability. A test environment “identical to production” built by hand is never identical. Built from the same module with a different variables file—it is.
  • Reviewing changes before they run. terraform plan shows the difference between the described state and the real one before anything happens. In the panel, the only equivalent is the operator’s own caution.
  • History and accountability. Who added an ACL rule, and why? In a repository that is a single git blame. In the panel—at best an event-log entry with no justification.
  • Drift detection. Did someone change something by hand “just for a moment”? The next plan will show it.
  • Recovery after a disaster. Code is the most trustworthy documentation of an architecture—the kind that is certainly current, because the environment was built from it.

The cost is real and worth knowing: code has to be maintained, state has to be protected, and the first rollout will take longer than clicking it through. The return comes with the second environment and with the first outage.

How do you configure Terraform with CloudStack?

Configuration takes four steps: API keys, the provider, a deliberate choice of its release, and moving state to a remote backend. Three of them are done once per project and can then be forgotten. The fourth—choosing the provider—is the decision you come back to most often, so we will give it the most space.

Step 1: API keys

Terraform authenticates with the keys of a CloudStack user. You generate them in the panel: Accounts → select the account → Users → select the user → Generate Keys, then copy the API Key and the Secret Key. You will see the secret in full only when it is generated—afterwards the panel masks it.

Two rules worth keeping from day one:

  • A separate user for automation. Do not use a human’s keys. A technical account is easier to revoke, rotate and trace in the event log.
  • The smallest privileges needed. If Terraform is to manage a single project, do not give it the domain administrator role.

Step 2: the provider and variables

Configuring the provider is literally three values. The key point is that two of them must never reach the repository:

# providers.tf
terraform {
  required_version = "~> 1.10"

  required_providers {
    cloudstack = {
      source = "cloudstack/cloudstack"
      version = "0.6.0"
    }
  }
}

provider "cloudstack" {
  api_url = var.cloudstack_api_url
  api_key = var.cloudstack_api_key
  secret_key = var.cloudstack_secret_key
}

# variables.tf
variable "cloudstack_api_url" {
  description = "CloudStack API address, e.g. https://panel.example.com/client/api"
  type = string
}

variable "cloudstack_api_key" {
  description = "API key of the technical user"
  type = string
  sensitive = true # will not show up in plan/apply logs
}

variable "cloudstack_secret_key" {
  description = "Secret key of the technical user"
  type = string
  sensitive = true
}

You pass the values in environment variables—Terraform reads every variable with the TF_VAR_ prefix:

export TF_VAR_cloudstack_api_url="https://panel.example.com/client/api"
export TF_VAR_cloudstack_api_key="$(pass show cloud/terraform/api-key)"
export TF_VAR_cloudstack_secret_key="$(pass show cloud/terraform/secret-key)"

terraform init
terraform plan

In a pipeline you supply the same three values as masked CI/CD variables. A *.tfvars file holding secrets is the most common way keys end up in git by accident—better simply not to create it.

Step 3: the provider version matters

This is the place where documentation tends to be optimistic, so we will say plainly how it is. The official cloudstack/cloudstack provider is developed in the Apache repository and is the right starting point—though at a rhythm you have to get used to: stable releases appear every year and a half to two years (0.4.0 in February 2022, 0.5.0 in April 2024, 0.6.0 in October 2025). The latest stable release in the registry is 0.6.0, dated 27 October 2025, while the 0.7.0 branch is so far only a release candidate there (0.7.0-rc1 of 19 August 2026, published just before this text was written; as of August 2026). Because of gaps like that, the main branch of the repository builds up a backlog of resources and attributes the stable release does not have yet. The practical conclusion: before you decide the provider is missing something, look into the CHANGELOG—a feature that was not there a year ago may already have made it into a stable release.

A good illustration is the cidrlist attribute in the cloudstack_loadbalancer_rule resource—the ability to restrict, with a list of addresses, who can reach the load balancer rule at all. In release 0.5.0 it was not there yet; it landed in the main branch at the end of August 2025 and is in the stable 0.6.0. The moral is simple and it keeps recurring: a gap you worked around a year ago may already be closed in a stable release today—worth checking before you build a workaround you will then have to maintain.

Practical recommendations:

  • Start with the official cloudstack/cloudstack from the Apache namespace—it is the reference for the documentation and the examples, including those in this article.
  • The provider is your highest-privileged dependency. It is a program you run on your laptop and on the CI runner: with API keys to the whole cloud, with the values of attributes marked as sensitive, and in the same environment where the credentials to the state bucket live. Treat it like any other dependency of that reach: take it from the official namespace in the registry, and if company policy requires it—build it yourself from a verified source and serve it from a local mirror (filesystem_mirror). Be aware of the boundary here: .terraform.lock.hcl protects against a silent swap of the binary after the fact, not against code that was bad from the start.
  • Pin the exact version (version = "0.6.0", not >= 0.6) and commit the .terraform.lock.hcl file. A cloud that “changed by itself” because the pipeline pulled a newer provider is a very unpleasant class of outage.
  • OpenTofu works the same way. In 2023 Terraform moved from an open-source license to BUSL (Business Source License): the code is still public, and internal use—commercial use included—is free, but formally it is no longer open source, because you may not offer Terraform as a service competing with HashiCorp’s paid products. In response, a fork was created under the Linux Foundation; CloudStack providers are ordinary plugins and both tools support them. If the BUSL license is a problem for you—most often because company policy allows open-source licenses only—replacing terraform with tofu in the commands is usually the whole migration.

Step 4: state—and a pleasant surprise for CloudStack operators

Terraform keeps a mapping of everything it created in the state file. Keeping it locally works exactly until the moment a second person or a pipeline runs apply—then the drama begins. So state goes to a remote backend, and here the CloudStack operator is in a comfortable position, because they almost always have S3-compatible storage at hand.

Until recently the s3 backend needed a DynamoDB table for state locking—a service that simply does not exist outside AWS. Since Terraform 1.10 there is native locking based on conditional writes in S3 (use_lockfile = true), and in 1.11 the DynamoDB variant was marked as deprecated. For anyone running on Ceph RGW, MinIO or another S3, that means a fully-fledged backend with shared locking is a dozen or so lines of configuration today:

# backend.tf
terraform {
  backend "s3" {
    bucket = "terraform-states"
    key = "cloudstack/prod.tfstate"

    endpoints = {
      s3 = "https://s3.example.com"
    }

    region = "us-east-1" # formally required; irrelevant for non-AWS S3

    # we turn off the validations that apply to AWS only
    skip_credentials_validation = true
    skip_region_validation = true
    skip_requesting_account_id = true
    skip_s3_checksum = true

    use_lockfile = true # native locking in S3, no DynamoDB (Terraform >= 1.10)
  }
}

You pass the bucket keys at initialization, so that they are not stored in the repository:

terraform init \
  -backend-config="access_key=$S3_ACCESS_KEY" \
  -backend-config="secret_key=$S3_SECRET_KEY"

If you use Object Storage at WebDisk, your Terraform backend is ready—it is the same bucket and the same keys you use for everything else.

Note—this is easy to forget: the state file holds attribute values in the clear, including those marked as sensitive. Treat the bucket with state files as a secret store: private, with server-side encryption, with versioning, and with access limited to those who really need it.

The first resource: a virtual machine

The shortest sensible configuration looks like this. Note that the machine parameters are not invented in code—they are a pointer to an offering:

data "cloudstack_zone" "main" {
  filter {
    name = "name"
    value = "^PL-DCO-1$"
  }
}

data "cloudstack_template" "ubuntu" {
  template_filter = "featured"
  filter {
    name = "name"
    value = "^Ubuntu 24\\.04 LTS$"
  }
}

resource "cloudstack_network" "app" {
  name = "app-net"
  cidr = "10.20.0.0/24"
  network_offering = "DefaultIsolatedNetworkOfferingWithSourceNatService"
  zone = data.cloudstack_zone.main.id
}

resource "cloudstack_instance" "web" {
  name = "web-01"
  display_name = "web-01"
  service_offering = "Medium Instance" # an item from the operator menu
  template = data.cloudstack_template.ubuntu.id
  network_id = cloudstack_network.app.id
  zone = data.cloudstack_zone.main.id
  root_disk_size = 40

  # cloud-init: system configuration on first boot
  user_data = base64encode(file("${path.module}/cloud-init/web.yaml"))

  expunge = true # destroy really removes it, it does not leave it in the recycle bin
}

A few things worth commenting on:

  • The values in filter blocks are regular expressions, not names. The provider compiles them as regexes and matches without anchoring, so Ubuntu 24.04 LTS will also catch Ubuntu 24.04 LTS minimal—and with several hits the template data source will pick the one with the newest creation date, not necessarily the one you meant (the zone data source—simply the last one on the list). Hence the \\. and ^…$ above; with a more cluttered template library it is worth adding a second filter, e.g. { name = "hypervisor", value = "KVM" }.
  • service_offering by name. It works and it is readable, but the name of an offering can change. In environments meant to live long, it is safer to point to a UUID.
  • A “customized” type offering has no parameters baked in—you pass them in the details field, e.g. details = { cpuNumber = "4", cpuSpeed = "2000", memory = "8192" }. Without that CloudStack will reject the request.
  • user_data is the standard cloud-init channel. Watch out for the size limit—with larger configurations base64gzip() helps instead of base64encode().
  • expunge = true means that terraform destroy really deletes the machine, instead of leaving it in the “Destroyed” state until it expires. For ephemeral environments that is the right choice; for production, think it over.

VPC and tiers: ACL rules are not separate resources

More serious environments are built on a VPC. It looks natural: a private cloud, networks inside it, and in front of the networks—an ACL list:

resource "cloudstack_vpc" "main" {
  name = "prod-vpc"
  cidr = "10.0.0.0/16"
  vpc_offering = "Redundant VPC offering"
  zone = data.cloudstack_zone.main.id
  project = var.project
}

resource "cloudstack_network_acl" "app" {
  name = "prod-acl"
  description = "ACL for the application tier"
  vpc_id = cloudstack_vpc.main.id
  project = var.project
}

resource "cloudstack_network_acl_rule" "app" {
  acl_id = cloudstack_network_acl.app.id
  project = var.project

  # NOTE: all the rules of one ACL live in a SINGLE resource
  rule {
    action = "allow"
    cidr_list = ["0.0.0.0/0"]
    protocol = "all"
    traffic_type = "egress"
  }

  # one port or one range per `rule` block—0.6.0 no longer accepts a list
  rule {
    action = "allow"
    cidr_list = var.admin_cidrs # admin networks / VPN
    protocol = "tcp"
    port = "22"
    traffic_type = "ingress"
  }

  rule {
    action = "allow"
    cidr_list = var.admin_cidrs
    protocol = "tcp"
    port = "6443"
    traffic_type = "ingress"
  }

  rule {
    action = "allow"
    cidr_list = ["0.0.0.0/0"]
    protocol = "tcp"
    port = "443"
    traffic_type = "ingress"
  }
}

resource "cloudstack_network" "app" {
  name = "prod-app-tier"
  cidr = "10.0.1.0/24"
  network_offering = "Isolated Network for VPC with SNAT"
  vpc_id = cloudstack_vpc.main.id
  acl_id = cloudstack_network_acl.app.id
  zone = data.cloudstack_zone.main.id
  project = var.project
}

This is where the first real pitfall sits. The cloudstack_network_acl_rule resource does not represent a single rule—it represents the entire rule set of a given ACL. The rule blocks inside it are the complete list. If someone adds a rule by hand in the panel, the next apply will remove it, because Terraform brings the set back to the state described in the code. That behavior is correct and desirable—but it surprises anyone who expects a “one resource, one rule” model.

And straight away a second thing that can catch you out: the ports field is gone. In 0.6.0 it is marked as deprecated, and the provider flatly refuses to create a new rule that uses it—you get the error “The 'ports' field is no longer supported for creating new rules”. Instead of a list you pass port with a single port or a single range ("80" or "80-90"), so two ports are simply two rule blocks. It works just as ruthlessly the other way round: in the 0.5.x branch the port field does not exist yet and only ports applies. A good example of why it pays to pin an exact version and keep .terraform.lock.hcl in the repository.

A public address: reservation first, then NAT and the load balancer

The order is always the same:

resource "cloudstack_ipaddress" "public" {
  vpc_id = cloudstack_vpc.main.id
  zone = data.cloudstack_zone.main.id
  project = var.project
}

# variant 1: a simple port forward to a single machine
resource "cloudstack_port_forward" "ssh" {
  ip_address_id = cloudstack_ipaddress.public.id
  project = var.project

  forward {
    protocol = "tcp"
    private_port = 22
    public_port = 2222
    virtual_machine_id = cloudstack_instance.bastion.id
  }
}

# variant 2: an L4 load balancer on the Virtual Router, with a list of allowed sources
resource "cloudstack_loadbalancer_rule" "api" {
  name = "kubernetes-apiserver"
  description = "Access to the Kubernetes API"
  ip_address_id = cloudstack_ipaddress.public.id
  network_id = cloudstack_network.app.id
  algorithm = "roundrobin"
  private_port = 6443
  public_port = 6443
  protocol = "tcp"
  member_ids = cloudstack_instance.controlplane[*].id
  project = var.project

  cidrlist = var.admin_cidrs # who can reach this rule at all
}

The cidrlist attribute appeared in release 0.6.0—on that version or newer you have it out of the box. In older versions the load balancer rule is open to the world and you have to close it separately with a firewall rule.

A real example: a Kubernetes cluster described entirely in code

Now the most interesting part: what this looks like in a project that is actually running. In our case the whole Kubernetes cluster on Talos Linux—a distribution intended solely for Kubernetes, with no shell and no SSH, configured purely declaratively through an API—is described in Terraform, from an empty CloudStack zone to a working ArgoCD.

Before we show the code, an honest answer to the question anyone who knows CloudStack will ask: yes, CloudStack has a built-in Kubernetes service (CloudStack Kubernetes Service), and the official provider has a cloudstack_kubernetes_cluster resource for it, already present in the stable 0.6.0 release. One resource and the cluster is up. For many use cases that is the right route and there is no reason to avoid it.

We chose the longer one for three reasons. First, the Kubernetes versions in the built-in service are another “operator’s menu”—what is available is whatever the cloud administrator uploaded an image for; we wanted to control the node OS and cluster version with a single URL in a .tfvars file. Second, we wanted our own composition of the system layer—Cilium as the CNI, Ceph RBD for persistent volumes, ArgoCD from the very first run—and the same pattern outside CloudStack as well. Third, Talos gives you a node with no shell and no SSH, that is, a configuration nobody can “fix by hand just for a moment”.

The price of that choice is out in the open: a few hundred more lines of code and maintaining a layer that in the built-in service is run by the cloud operator—node upgrades included. If you do not need that, start with the built-in service.

Repository layout

├── modules/
│ ├── cloudstack/ # VPC, network, ACL, nodes, LB, template
│ └── talos/ # Talos machine configuration + patches
└── environments/
    ├── dev/
    └── prod/
        ├── backend.tf # state in S3 (Ceph RGW)
        ├── providers.tf # cloudstack, talos, helm, kubectl, http
        ├── main.tf # wiring it all together
        ├── cloudstack.tfvars # environment values
        └── infra-deployments/
            ├── charts/ # cilium, CSI, CCM, prometheus, argocd
            └── argocd-apps/ # GitOps applications

The split is classic and works well: modules describe the “how”, environment directories describe the “how much and where”. In practice the difference between dev and prod is the .tfvars file—the values below are illustrative and show what differentiates environments at all, not the sizes of our clusters:

# environments/prod/cloudstack.tfvars
zone = "PL-DCO-1"
project = "..." # CloudStack project UUID

vpc_name = "Prod Redundant VPC"
vpc_cidr = "10.0.0.0/16"
vpc_offering = "Virtual Private Cloud with VR HA"
talos_network_name = "prod-network"
talos_network_cidr = "10.0.1.0/24"
talos_network_offering = "Isolated Network for VPC with SNAT"
talos_image_url = "https://factory.talos.dev/image/<hash>/<version>/cloudstack-amd64.raw.gz"

cluster_name = "prod"
controlplane_vip = "10.0.1.2"

The same file also holds controlplane_count and worker_count—node counts are in practice the only thing that changes when an environment is scaled up or down; we keep the concrete values in the environment variables, not in the body of the module.

An OS template straight from a URL

The first thing that impresses people used to uploading images by hand: CloudStack can register a template by downloading it from any HTTP address, and Terraform can drive that. Talos publishes ready-made CloudStack images through its Image Factory service, so the node operating system version becomes… a variable in a configuration file:

resource "cloudstack_template" "talos" {
  name = "Talos-${regex("v[0-9]+\\.[0-9]+\\.[0-9]+", var.talos_image_url)}"
  format = "RAW"
  hypervisor = "KVM"
  os_type = "Other PV Virtio-SCSI (64-bit)"
  url = var.talos_image_url
  zone = var.zone
  project = var.project
}

Bumping Talos to a newer version is a change to a single URL. The template name pulls the version number out of the address (a tag of the form vX.Y.Z) with a regular expression, so a new version creates a new template instead of quietly overwriting the old one.

Nodes: the machine plus configuration in user_data

Talos has no SSH—it receives the node’s entire configuration in user_data on first boot. That plays very well with Terraform: the talos module generates the machine configurations and the cloudstack module injects them into the instances:

resource "cloudstack_instance" "controlplane" {
  count = var.controlplane_count

  name = "${var.cluster_name}-control-${count.index + 1}"
  service_offering = "cs.custom.nvme"
  template = cloudstack_template.talos.id
  network_id = cloudstack_network.talos.id
  zone = var.zone
  project = var.project

  uefi = true
  root_disk_size = 200
  details = { cpuNumber = "2", cpuSpeed = "2000", memory = "16000" }

  user_data = base64gzip(var.talos_controlplane_config[count.index])

  expunge = true
}

base64gzip() instead of base64encode() is not decoration—a Talos configuration with patches can exceed the user_data size limit.

We leave expunge = true here deliberately, despite the warning from the previous section: a cluster node is replaceable and is recreated from the same code, and what protects you from losing the cluster is an etcd backup, not CloudStack’s recycle bin. On a machine that holds data the choice would be the opposite.

The order of events, or where Terraform needs help

The most interesting piece of the whole project is the environment’s main.tf, because it shows something the tutorials do not: bringing up a cluster is a sequence in which some steps have to wait for reality, not just for Terraform’s dependency graph.

Before we walk through the run, one thing needs saying outright: from this point on, several providers work side by side in a single directory. cloudstack creates the infrastructure, talos talks to the nodes through the Talos API (hence the resources prefixed talos_), helm and kubectl install components in the already-running cluster, and http serves a single readiness check. So do not look for talos_* resources in the CloudStack provider documentation—they are not there and never will be. Terraform ties it all together in one run, because the output of one provider is often the input of the next.

The run looks like this:

  1. The VPC is created and a public IP address is reserved.
  2. The talos module generates the machine configurations—already knowing the cluster’s public endpoint, because the address is known before the nodes are created.
  3. The cloudstack module creates the template, network, ACL, nodes and LB rules.
  4. talos_machine_configuration_apply sends the configuration to every node.
  5. talos_machine_bootstrap initializes etcd—the distributed database in which Kubernetes keeps all of its state—on the first control plane node; the remaining nodes join it on their own.
  6. Terraform waits until the Kubernetes API really answers.
  7. Only then is the kubeconfig fetched and the components installed with Helm.

Step six is the one missing from most examples. The bootstrap finishes before kube-apiserver starts serving traffic, so the next resource would hit thin air. A pattern worth remembering—polling the health endpoint with retries, as an ordinary data source:

data "http" "check_kubeapi" {
  depends_on = [talos_machine_bootstrap.cluster]

  url = "https://${cloudstack_ipaddress.lb.ip_address}:6443/healthz"
  insecure = true # a deliberate shortcut—the apiserver certificate is signed by the cluster CA, not a public authority

  retry {
    attempts = 10
    min_delay_ms = 15000
    max_delay_ms = 15000 # fixed interval: up to 2.5 minutes of patience
  }
}

resource "talos_cluster_kubeconfig" "cluster" {
  depends_on = [data.http.check_kubeapi] # this is the whole trick
  client_configuration = module.talos.talos_client_configuration
  node = cloudstack_ipaddress.lb.ip_address
}

This instead of a time_sleep with a number pulled out of the air: it waits exactly as long as it has to, and if the cluster does not come up—it aborts the apply with a clear error instead of quietly carrying on. The explicit max_delay_ms is not redundant here: without it the provider takes the default 30 s ceiling and exponential backoff, so the real wait grows to almost five minutes.

One word about insecure = true, so that nobody carries the habit further: it is a shortcut, not a recommendation. We check only /healthz and send nothing there, and the kube-apiserver certificate is signed by the cluster CA, so the default verification would reject it anyway. It can nevertheless be closed properly—the http provider accepts ca_cert_pem, and the Kubernetes CA already exists in Terraform state by then, because talos_machine_secrets generates it; all it takes is exposing it as a module output.

The Kubernetes layer in the same run

Once the kubeconfig has been fetched, the same apply configures the helm and kubectl providers with data from the cluster that has just been created and installs the system layer:

module "cilium" { source = "./infra-deployments/charts/cilium" }
module "kube-prometheus-stack" { source = "./infra-deployments/charts/kube-prometheus-stack" }
module "cloudstack-kubernetes-provider" { source = "./infra-deployments/charts/cloudstack-kubernetes-provider" /* ... */ }
module "cloudstack-csi" { source = "./infra-deployments/charts/cloudstack-csi" /* ... */ }
module "ceph_csi_rbd" { source = "./infra-deployments/charts/ceph-csi-rbd" /* ... */ }
module "argocd" { source = "./infra-deployments/charts/argocd" }
module "argocd-apps" { source = "./infra-deployments/argocd-apps" }

Three of those components are worth covering separately:

  • cilium—the cluster CNI, that is, the plugin responsible for pod-to-pod networking and traffic policies.
  • cloudstack-kubernetes-provider—the cloud controller manager. Thanks to it a Kubernetes Service of type LoadBalancer actually orders a public address and an LB rule in CloudStack instead of hanging forever in Pending.
  • cloudstack-csi—the storage driver. It lets a PersistentVolumeClaim create a real CloudStack volume and attach it to the right node.

The boundary of responsibility here is deliberate: Terraform brings up the cluster and its system layer, and GitOps takes over from the applications onwards—a model in which application state is described in a git repository and an agent running inside the cluster (ArgoCD in our case) continuously compares it with reality and removes the differences. Terraform is excellent at creating infrastructure and poor as a loop of continuous application reconciliation—mixing those roles ends in pipelines that take a quarter of an hour.

The pipeline: plan automatically, apply behind a manual gate

The whole thing runs in GitLab CI in a layout we recommend for any production infrastructure:

stages: [validate, plan, apply]

image:
  name: hashicorp/terraform:1.10.3
  entrypoint: [""]

before_script:
  - cd $BASE_PATH
  - terraform init
      -backend-config="access_key=$S3_ACCESS_KEY"
      -backend-config="secret_key=$S3_SECRET_KEY"

validate:
  stage: validate
  script: [terraform validate]

iac-scan: # static security analysis of the IaC code
  stage: validate
  image:
    name: aquasec/trivy:latest
    entrypoint: [""] # without this GitLab will not start a shell in this image
  before_script: [] # there is nothing to initialize here
  script: [trivy config --exit-code 1 $BASE_PATH]

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan -var-file cloudstack.tfvars
    - terraform show -json tfplan > tfplan.json
  artifacts:
    paths: [$BASE_PATH/tfplan, $BASE_PATH/tfplan.json]
    access: developer # the plan carries the same secrets as the state (GitLab >= 16.7)
    expire_in: 7 days # it has to outlive the manual gate before `apply`

apply:
  stage: apply
  script: [terraform apply tfplan]
  when: manual
  dependencies: [plan]
  manual_confirmation: "Confirm that you want to deploy these changes"

The plan artifact is exactly as sensitive as the state file. A saved tfplan—and tfplan.json all the more so, because terraform show -json prints values marked sensitive in the clear—contains the same secrets as the state, together with a copy of the state from before the change. By default GitLab artifacts can be downloaded by anyone who can see the job, and they sit around for weeks, so restrict access (access: developer) and shorten retention (expire_in)—remembering that the artifact has to outlive the manual gate before apply, because without it apply has nothing to deploy.

Three things do the real work here:

  • apply executes the saved plan rather than recomputing it—Terraform treats passing a plan file as approval in itself, so -auto-approve is redundant here. You deploy exactly what somebody read and accepted.
  • A manual gate with a confirmation prompt. Infrastructure is no place for an apply that runs automatically after a merge.
  • An IaC security scan in the validation stage. trivy config walks the .tf files and catches the usual sins before they become reality—in our case most often secrets pasted into user_data. Two things are worth knowing here. First: we scan with Trivy, because tfsec was absorbed into it and no longer receives new rules. Second, honestly: there are few ready-made rules written specifically for the CloudStack provider—the heavier set (ports open to the world, encryption turned off) applies to the hyperscaler providers. It is a cheap gate all the same, in the same spirit as the piece we wrote on the open-source security stack.

Pitfalls that cost time

Collected from practice, in order from the most frequent:

  1. Provider version. Features arrive with releases, and releases come out rarely. Before you conclude that “CloudStack can’t do this”, check whether it is your provider version that can’t—the cloud API has usually had the feature for a long time.
  2. Hardcoded UUIDs. Copying a network identifier from the panel and pasting it into the code works—until the first time you rebuild the environment. Everything that can be looked up with a data source, look up with a data source; keep the rest in variables, not in the body of the module. This is a classic piece of debt, by the way: we have one such place in our own code and we know it will bite us one day.
  3. An asynchronous API. depends_on guarantees the order in which resources are created, but not the readiness of a service. Wherever you are waiting on reality (the Kubernetes API, a service inside a machine, DNS propagation), use a check with retries—like the data "http" with a retry block above.
  4. ACL as a single resource. All the rules of a list live in one Terraform resource. A manual change in the panel will be reverted on the next apply.
  5. “Customized” offerings require a details field with cpuNumber, cpuSpeed and memory. Without it CloudStack will reject the request, and the error message will not always point to the reason directly.
  6. expunge = true makes destroy irreversible—you will not recover the machine from the “Destroyed” state. Safe wherever the machine is replaceable (such as a cluster node), risky everywhere it holds data.
  7. The load balancer is layer-four and lives in the Virtual Router. Do not plan path-based routing or heavy HTTP traffic on it—an ingress in the cluster or a separate machine with a reverse proxy is what that is for.
  8. Secrets in the state—and in the plan. API keys, passwords and Ceph keys land in tfstate in the clear, and together with it in a saved tfplan and in tfplan.json. Secure the state bucket like a password store, and treat the plan artifact in CI identically: restricted access and short retention.
  9. Parallel pipelines. Without state locking, two simultaneous apply runs can knock an environment out of alignment. use_lockfile = true costs one line.

CloudStack and Terraform in WebDisk Cloud

Our public cloud runs on Apache CloudStack with the KVM hypervisor and storage on a Ceph cluster—primary storage is RBD pools (NVMe separately, capacity separately), and Object Storage is RGW with an S3-compatible API. Everything described above therefore applies to the same platform on which you can run your own environment.

What this means in practice:

  • You generate API keys yourself in the panel—there is no need to order them from us. The same key will serve Terraform, CloudMonkey and your own scripts.
  • A bucket for Terraform state can sit with us next to the machines—with native S3 locking, without adding any external service whatsoever.
  • There is no intermediate layer here. You talk to the same CloudStack API that the panel uses—and because this is an Apache Software Foundation project, your code is not tied to a single vendor. That is a concrete, measurable way of limiting vendor lock-in, which we covered at greater length separately.
  • The scope of self-service is broader than it seems: VPC and tiers, ACL lists, public addresses, port forwards, LB rules, templates, snapshots, volumes—all of these are Terraform resources.

And if what you are looking for is a ready cluster with support rather than your own Terraform—the project described above is exactly what we do as part of Kubernetes technical support; as for why standing up a cluster is the easiest stage, we wrote about that in a separate article.

Frequently asked questions

How does Apache CloudStack differ from OpenStack? Above all in complexity and deployment model. OpenStack is a collection of several dozen cooperating projects with separate APIs and life cycles—it gives maximum flexibility, but it requires a team maintaining the platform itself. CloudStack is a single Java application with a single MySQL database, covering the entire cloud model: installation and upgrades are considerably simpler, at the cost of less modularity. Both are open-source projects under the Apache 2.0 license, both have a Terraform provider.

Is there an official Terraform provider for CloudStack? Yes—cloudstack/cloudstack, developed in the Apache repository. You do need to know its pace, though: the latest stable release in the registry is 0.6.0 from October 2025, and the 0.7.0 branch is present there for now as a release candidate (0.7.0-rc1, August 2026). The practical conclusion: pin the exact version, commit .terraform.lock.hcl and look into the CHANGELOG—a feature that was missing a year ago may already have made it into a stable release.

Where do I get API keys for CloudStack? From the panel: Accounts → account → Users → user → Generate Keys. The secret is shown in full only at generation time. For automation, create a separate technical user with the smallest privileges needed—it is easier to rotate and to trace in the event log than keys assigned to a person.

Where should I keep the Terraform state file if I don’t have AWS? In any S3-compatible storage—Ceph RGW, MinIO or Object Storage at a provider. The s3 backend supports a custom endpoint, and since Terraform 1.10 it can lock state natively, with conditional writes in S3 (use_lockfile = true), without a DynamoDB table. Remember that the state contains secrets in the clear—secure the bucket accordingly.

Can I use OpenTofu instead of Terraform? Yes. OpenTofu is a fork of Terraform under the Linux Foundation, created after Terraform’s license was changed to BUSL (Business Source License)—the sources remain public, but formally this is no longer an open-source license. The CloudStack providers are ordinary plugins and work in both tools; the configuration stays the same, the command changes (tofu instead of terraform). If the BUSL license is a problem for you, that is usually the entire migration.

Since CloudStack has a built-in Kubernetes service (CKS), why stand up a cluster with your own code? There is no reason to, if the built-in service covers your case—the official provider has a cloudstack_kubernetes_cluster resource for it already in the stable 0.6.0 release, and that is the shortest route to a working cluster. You choose your own cluster (ours on Talos Linux) when you need control over the version and configuration of the nodes, your own set of CNI and storage drivers, or the same pattern across several platforms at once. The price is more code and full responsibility for a layer that in the built-in service is maintained by the cloud operator.

Why is my Service of type LoadBalancer in Kubernetes on CloudStack stuck in the Pending state? Because the cluster is missing the component that will request an address and a rule in the cloud. That is the job of cloudstack-kubernetes-provider, the cloud controller manager for CloudStack—without it Kubernetes has no way to ask for a public IP. By analogy, cloudstack-csi is responsible for volumes.

Will the CloudStack load balancer replace an Application Load Balancer? No, and it is not worth treating it as one. The LB in CloudStack runs in the Virtual Router, is a layer-four balancer with sticky sessions and—since release 4.22—with SSL termination on the router itself, and its throughput is limited by a single router. If your cloud runs on an older release, SSL offloading on the router simply is not there. For routing by path, headers or host names, use an ingress in a Kubernetes cluster or your own reverse proxy—CloudStack then only supplies them with a public address.

Will Terraform cope with a cloud that already exists? Yes, through importing existing resources into the state (terraform import or import blocks), but be realistic: in a larger environment this is days of work, not hours, and it requires patiently matching the code to reality until plan stops proposing changes. More often it pays off to cover what is new with code and to migrate the old as part of a rebuild.

Summary

Apache CloudStack has a quality that works well with infrastructure as code: it is built around one consistent API and one readable object model. Zone, offering, network, VPC, ACL, template, project—these are concepts that describe the panel just as well as they describe .tf files. Once you understand them, Terraform stops being a translation between two worlds and simply becomes a second way of saying the same thing.

There are three things worth taking away from this text. First: starting is cheap—three values in the provider configuration, a backend on S3 that you probably already have, and a first machine in a dozen or so lines. Second: scale pays off—an entire Kubernetes cluster with pod networking, storage and GitOps can be described in code and recreated with a single command, and the difference between dev and prod comes down to a variables file. Third: know the limitations—rare provider releases, offerings defined by the operator, an asynchronous API and a layer-four load balancer are not defects but properties of the platform, which have to be taken into account when designing an environment.

If you are starting from scratch, a good order is this: generate API keys, describe one machine and one network, move the state to S3, add a pipeline with a manual gate before apply, and only then reach for modules and environments. Each of these steps makes sense on its own—and none of them requires rewriting what already works.

Wondering whether your environment can be described in code and where to start? Write to us—the WebDisk team will gladly walk through the first module with you.