Worked Compute Examples
The provider reference
lists every attribute of every compute resource, but an attribute list does not
tell you how the pieces fit together — which resource has to exist first, where
a subnet ID comes from, or what a working data_volumes block looks like. These
are complete configurations you can copy, fill in a few variables, and apply.
Every example on this page was run through terraform validate against provider
1.52.x and is terraform fmt canonical.
Shared Setup
Section titled “Shared Setup”Each example below assumes these two files alongside it.
terraform { required_version = ">= 1.5"
required_providers { nirvana = { source = "nirvana-labs/nirvana" version = "~> 1.52" } }}
provider "nirvana" {}variable "project_id" { description = "Nirvana Labs project ID." type = string}
variable "ssh_public_key" { description = "SSH public key to install on the VM." type = string}
variable "region" { description = "Region to deploy into." type = string default = "us-sva-2"}
variable "admin_cidr" { description = "Network-aligned CIDR allowed to reach SSH. Use a VPN, office, or bastion range." type = string}An empty provider "nirvana" {} block takes the API key from
NIRVANA_LABS_API_KEY. Supply the required variables however you prefer:
export NIRVANA_LABS_API_KEY="REPLACE_WITH_YOUR_API_KEY"export TF_VAR_project_id="123e4567-e89b-12d3-a456-426614174000"export TF_VAR_admin_cidr="203.0.113.0/24"A Complete VM Stack
Section titled “A Complete VM Stack”VPC, firewall rules for SSH and HTTP/HTTPS, one public VM, and the outputs you need to reach it. This is the smallest configuration that produces a VM you can actually log into.
resource "nirvana_networking_vpc" "main" { name = "web-vpc" project_id = var.project_id region = var.region subnet_name = "web-subnet" tags = ["production", "web"]}
resource "nirvana_networking_firewall_rule" "ssh" { vpc_id = nirvana_networking_vpc.main.id name = "allow-ssh" protocol = "tcp" source_address = var.admin_cidr destination_address = nirvana_networking_vpc.main.subnet.cidr destination_ports = ["22"] tags = ["production", "web"]}
resource "nirvana_networking_firewall_rule" "web" { vpc_id = nirvana_networking_vpc.main.id name = "allow-web" protocol = "tcp" source_address = "0.0.0.0/0" destination_address = nirvana_networking_vpc.main.subnet.cidr destination_ports = ["80", "443"] tags = ["production", "web"]}
resource "nirvana_compute_vm" "web" { name = "web-1" project_id = var.project_id region = var.region subnet_id = nirvana_networking_vpc.main.subnet.id instance_type = "n1-standard-2" os_image_name = "ubuntu-noble-2026-05-18" public_ip_enabled = true
boot_volume = { size = 64 type = "abs" }
ssh_key = { public_key = var.ssh_public_key }
tags = ["production", "web"]}
output "vm_public_ip" { description = "Public IP of the web VM." value = nirvana_compute_vm.web.public_ip}
output "vm_private_ip" { description = "Private IP of the web VM." value = nirvana_compute_vm.web.private_ip}
output "subnet_cidr" { description = "CIDR assigned to the VPC subnet." value = nirvana_networking_vpc.main.subnet.cidr}terraform initterraform applyssh ubuntu@"$(terraform output -raw vm_public_ip)"Note destination_address = nirvana_networking_vpc.main.subnet.cidr. Firewall
rules need a network-aligned CIDR, and the subnet’s assigned CIDR is always
one — hardcoding a guess like 10.0.0.0/25 will not match the subnet the
platform actually hands you.
A VM with Data Volumes
Section titled “A VM with Data Volumes”Data volumes declared inline in data_volumes are created and attached with the
VM, so there is no separate attach step. This is the right shape when the
volumes belong to the VM for its whole life.
resource "nirvana_networking_vpc" "db" { name = "db-vpc" project_id = var.project_id region = var.region subnet_name = "db-subnet"}
resource "nirvana_compute_vm" "db" { name = "postgres-1" project_id = var.project_id region = var.region subnet_id = nirvana_networking_vpc.db.subnet.id instance_type = "n1-highmem-8" os_image_name = "ubuntu-noble-2026-05-18" public_ip_enabled = false
boot_volume = { size = 64 type = "abs" tags = ["boot"] }
data_volumes = [ { name = "postgres-data" size = 500 type = "nvme" tags = ["data", "postgres"] }, { name = "postgres-wal" size = 100 type = "nvme" tags = ["wal", "postgres"] }, ]
ssh_key = { public_key = var.ssh_public_key }
tags = ["production", "database"]}
output "data_volume_ids" { description = "IDs of the data volumes created with the VM." value = nirvana_compute_vm.db.data_volume_ids}type is nvme or abs per volume, so a VM can mix them — an abs boot
volume with nvme data volumes, as above. public_ip_enabled = false keeps
the VM private; reach it from another host in the VPC.
Attaching a Standalone Volume
Section titled “Attaching a Standalone Volume”When a volume needs to outlive the VM, or move between VMs, declare it as its
own nirvana_compute_volume and point vm_id at the VM.
resource "nirvana_networking_vpc" "app" { name = "app-vpc" project_id = var.project_id region = var.region subnet_name = "app-subnet"}
resource "nirvana_compute_vm" "app" { name = "app-1" project_id = var.project_id region = var.region subnet_id = nirvana_networking_vpc.app.subnet.id instance_type = "n1-standard-4" os_image_name = "ubuntu-noble-2026-05-18" public_ip_enabled = true
boot_volume = { size = 64 type = "abs" }
ssh_key = { public_key = var.ssh_public_key }}
resource "nirvana_compute_volume" "scratch" { name = "app-scratch" project_id = var.project_id region = var.region size = 200 type = "nvme" vm_id = nirvana_compute_vm.app.id tags = ["scratch"]}
output "volume_id" { value = nirvana_compute_volume.scratch.id}The difference matters on destroy: an inline data_volumes entry is part of the
VM resource, while a nirvana_compute_volume is a separate resource with its
own lifecycle. Detach it by removing vm_id, or move it by pointing vm_id at
a different VM.
Looking Up Images and Instance Types
Section titled “Looking Up Images and Instance Types”OS image names embed a build date and change as new images are published, so a
hardcoded os_image_name goes stale. The data sources resolve both at plan
time.
data "nirvana_compute_vm_os_images" "ubuntu" { name = "ubuntu-noble" sort = "position:asc"}
data "nirvana_instance_types" "standard_4plus" { region = var.region family = "standard" vcpu_min = 4 sort = "vcpu:asc"}
locals { os_image_name = data.nirvana_compute_vm_os_images.ubuntu.items[0].name instance_type = data.nirvana_instance_types.standard_4plus.items[0].name}
resource "nirvana_networking_vpc" "main" { name = "lookup-vpc" project_id = var.project_id region = var.region subnet_name = "lookup-subnet"}
resource "nirvana_compute_vm" "app" { name = "app-1" project_id = var.project_id region = var.region subnet_id = nirvana_networking_vpc.main.subnet.id instance_type = local.instance_type os_image_name = local.os_image_name public_ip_enabled = true
boot_volume = { size = 64 type = "abs" }
ssh_key = { public_key = var.ssh_public_key }
lifecycle { precondition { condition = length(data.nirvana_compute_vm_os_images.ubuntu.items) > 0 error_message = "No OS image matched the name filter. List the catalog and adjust the filter." }
precondition { condition = length(data.nirvana_instance_types.standard_4plus.items) > 0 error_message = "No instance type matched the filters. Loosen vcpu_min or check the region." } }}
output "resolved_os_image" { value = local.os_image_name}
output "resolved_instance_type" { value = local.instance_type}sort = "position:asc" orders by the catalog’s intended display position. That
is the most sensible ordering available — sorting by name is lexicographic
rather than newest-first, because the version is embedded in the string — but
position is a presentation order, not a contract that items[0] is the newest
or the recommended image. Treat this as “whatever the catalog lists first for
this filter”, and if you need a specific image, narrow the name filter or pin
os_image_name outright and update it deliberately.
Both data sources return an items list, so remember to index it. Filters
narrow the list; they do not reduce it to a single object, and they do not
guarantee a match — a filter that matches nothing yields an empty list, and
items[0] on it fails the plan with an unhelpful index error. The
precondition blocks above turn that into a diagnostic that says which filter
came back empty. Use length(...) guards like these whenever you index a data
source you did not pin.
Several VMs at Once
Section titled “Several VMs at Once”for_each over a map keeps each VM addressable by name, which means adding or
removing an entry does not churn the others the way a count index would.
variable "workers" { description = "Worker VMs to create, keyed by name." type = map(object({ instance_type = string boot_volume_size = optional(number, 64) }))
default = { "worker-1" = { instance_type = "n1-highcpu-4" } "worker-2" = { instance_type = "n1-highcpu-4" } "worker-3" = { instance_type = "n1-highcpu-8", boot_volume_size = 128 } }}resource "nirvana_networking_vpc" "workers" { name = "workers-vpc" project_id = var.project_id region = var.region subnet_name = "workers-subnet"}
resource "nirvana_compute_vm" "worker" { for_each = var.workers
name = each.key project_id = var.project_id region = var.region subnet_id = nirvana_networking_vpc.workers.subnet.id instance_type = each.value.instance_type os_image_name = "ubuntu-noble-2026-05-18" public_ip_enabled = false
boot_volume = { size = each.value.boot_volume_size type = "abs" }
ssh_key = { public_key = var.ssh_public_key }
tags = ["worker", each.key]}
output "worker_private_ips" { description = "Private IP of each worker, keyed by name." value = { for k, vm in nirvana_compute_vm.worker : k => vm.private_ip }}Verifying Before You Apply
Section titled “Verifying Before You Apply”terraform fmt -check # formatting is canonicalterraform validate # config is internally consistentterraform plan # what would actually changeterraform validate needs terraform init first but no credentials. plan
does reach the API, so it needs NIRVANA_LABS_API_KEY set.
Creates are asynchronous on the API side — a create returns an operation and the
resource becomes ready afterwards. You do not have to poll for this in
Terraform: nirvana_compute_vm, nirvana_compute_volume,
nirvana_networking_vpc, and nirvana_networking_firewall_rule all wait for
the operation to finish before returning, so a completed apply means the
resources are ready. The wait has a 10 minute ceiling, which compute resources
sit comfortably inside.