Skip to content

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.

Each example below assumes these two files alongside it.

providers.tf
terraform {
required_version = ">= 1.5"
required_providers {
nirvana = {
source = "nirvana-labs/nirvana"
version = "~> 1.52"
}
}
}
provider "nirvana" {}
variables.tf
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:

Terminal window
export NIRVANA_LABS_API_KEY="REPLACE_WITH_YOUR_API_KEY"
export TF_VAR_project_id="123e4567-e89b-12d3-a456-426614174000"
export TF_VAR_ssh_public_key="ssh-ed25519 AAAAC3Nza... [email protected]"
export TF_VAR_admin_cidr="203.0.113.0/24"

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.

main.tf
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
}
Terminal window
terraform init
terraform apply
ssh 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.

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.

main.tf
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.

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.

main.tf
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.

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.

main.tf
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.

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.

variables.tf
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 }
}
}
main.tf
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 }
}
Terminal window
terraform fmt -check # formatting is canonical
terraform validate # config is internally consistent
terraform plan # what would actually change

terraform 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.