DEV Community

Cover image for Multipass + Terraform: Modern VM Automation Guide
todoroff
todoroff

Posted on Edited on

Multipass + Terraform: Modern VM Automation Guide

Multipass + Terraform: Modern VM Automation Guide

Updated for todoroff/multipass 2.0.1, October 2026.

Multipass creates Ubuntu virtual machines on your workstation. The todoroff/multipass Terraform provider lets you describe those machines in code, along with their networks, host mounts, aliases, snapshots, and file transfers.

This guide starts with one VM and then covers resizing, cloud-init, multiple network interfaces, static addresses, and configuration workflows with Ansible or a CI system.

The provider runs the multipass CLI on the machine running Terraform. That machine needs Multipass installed and access to its daemon; a hosted Terraform runner does not automatically have access to the VMs on your laptop.

1. Install the prerequisites

You need:

  • Terraform 1.6 or newer.
  • Multipass 1.13 or newer, installed and available on PATH.
  • A working Multipass driver for your operating system. Networking, snapshots, and resizing also depend on the capabilities of that driver.

Check that the CLI can reach the daemon:

multipass version --format json
multipass list --format json
Enter fullscreen mode Exit fullscreen mode

Go is only needed when building the provider from source.

2. Create a VM and a shell alias

Create an empty directory and save the following as main.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    multipass = {
      source  = "todoroff/multipass"
      version = "~> 2.0"
    }
  }
}

provider "multipass" {}

resource "multipass_instance" "dev" {
  name   = "dev-box"
  image  = "24.04"
  cpus   = 2
  memory = "4G"
  disk   = "15G"
}

resource "multipass_alias" "shell" {
  name     = "dev-shell"
  instance = multipass_instance.dev.name
  command  = "bash"
}

output "dev_ips" {
  value = multipass_instance.dev.ipv4
}
Enter fullscreen mode Exit fullscreen mode

Then initialize, review, and apply the configuration:

terraform init
terraform plan
terraform apply
multipass dev-shell
Enter fullscreen mode Exit fullscreen mode

The alias opens Bash inside the VM. The ipv4 attribute contains the addresses reported by Multipass.

~> 2.0 permits provider versions from 2.0.0 up to, but excluding, 3.0.0. Commit .terraform.lock.hcl to retain the selected version across runs. Use terraform init -upgrade when you intentionally want to update within that constraint.

This example selects the Ubuntu 24.04 release. You can use image = "lts" to follow Multipass's current LTS alias for new launches. An image alias does not pin an exact image build or upgrade an existing guest operating system.

The empty provider block uses these defaults:

Setting Default Purpose
multipass_path multipass Find the CLI on PATH.
command_timeout 600 Timeout in seconds for CLI operations.
default_image lts Image used when an instance omits image.

Set multipass_path only if your binary is elsewhere. Paths differ between operating systems and installation methods.

The remaining sections are optional additions or edits to this configuration. When an example says to replace a resource block, keep only one block with that resource type and Terraform name.

3. Resize an existing VM

Provider 2.0 changed the default behavior of allocation updates. CPU and memory changes, and disk growth, now resize the existing VM.

For example, replace the earlier multipass_instance.dev block with:

resource "multipass_instance" "dev" {
  name          = "dev-box"
  image         = "24.04"
  cpus          = 4
  memory        = "8G"
  disk          = "30G"
  resize_policy = "in_place"
}
Enter fullscreen mode Exit fullscreen mode

Run terraform plan and terraform apply again.

Change in_place (default) replace
CPU or memory allocation Resize the existing VM Recreate the VM
Disk growth Expand the existing disk Recreate the VM
Disk shrink Reject during apply, before mutation Recreate with a smaller disk

A running VM is gracefully stopped and restarted for an in-place resize, so expect downtime. A stopped VM remains stopped. Start or stop a suspended or transitional VM before resizing it.

Use resize_policy = "replace" when you want a fresh VM after allocation changes. Changing only the policy does not resize, restart, or replace the VM. The provider never silently switches a failed in-place resize to replacement.

Removing cpus, memory, or disk from an existing resource keeps its current allocation. New instances that omit them default to 1 CPU, 1G memory, and 5G disk. Equivalent sizes such as 1G and 1024M do not trigger a resize.

Increasing the virtual disk does not guarantee that the guest partition and filesystem expand with it. Check guest capacity after growth and follow Multipass's instance modification guide if another step is needed.

Name, image, network, and cloud-init changes still force replacement, regardless of resize_policy. Review the plan before applying those changes. See the instance documentation for resize deadlines, recovery, and allocation readback limits.

4. Share a host directory

To mount a project directory, add this block inside your existing multipass_instance.dev resource. Replace the host path with an existing directory on your machine:

mounts {
  host_path     = "/home/your-user/projects"
  instance_path = "/workspace"
}
Enter fullscreen mode Exit fullscreen mode

On Windows, an HCL path can use forward slashes, such as C:/Users/YourName/projects. On macOS, it might be /Users/your-name/projects.

Mounts can be added and removed without recreating the VM. Leave read_only omitted or set it to false: the Multipass CLI does not support read-only mounts, and the provider rejects true.

5. Bootstrap with cloud-init

Use cloud-init for packages, users, and configuration that must exist when a VM is first created. There are two mutually exclusive inputs:

  • cloud_init_file: a path to a cloud-init YAML file.
  • cloud_init: YAML supplied as a Terraform string, including the result of file() or templatefile().

For a templated example, save this as cloud-init.yaml.tftpl next to main.tf:

#cloud-config
users:
  - default
  - name: ${jsonencode(username)}
    groups: [sudo]
    shell: /bin/bash
    sudo: ["ALL=(ALL) NOPASSWD:ALL"]
    ssh_authorized_keys:
      - ${jsonencode(ssh_public_key)}

packages:
  - git

write_files:
  - path: /etc/motd
    permissions: "0644"
    content: ${jsonencode(motd)}
Enter fullscreen mode Exit fullscreen mode

The default entry preserves the image's default user alongside the new account. jsonencode() produces quoted strings that are also valid YAML, including when a value contains quotes or line breaks.

Add the following to main.tf:

variable "ssh_public_key" {
  type        = string
  description = "Your SSH public key, used for guest access."
}

resource "multipass_instance" "runner" {
  name   = "ci-runner"
  image  = "24.04"
  cpus   = 2
  memory = "3G"
  disk   = "15G"

  cloud_init = templatefile("${path.module}/cloud-init.yaml.tftpl", {
    username       = "ci-runner"
    ssh_public_key = var.ssh_public_key
    motd           = "CI host ready.\n"
  })

  wait_for_cloud_init = true

  timeouts {
    create = "20m"
  }
}
Enter fullscreen mode Exit fullscreen mode

Set ssh_public_key in terraform.tfvars to the contents of your .pub file. Keep the private key on your host. This example creates the account and installs Git; installing and registering your CI agent is a separate configuration step.

wait_for_cloud_init = true makes Terraform wait for cloud-init before reporting successful creation. Use it when later resources depend on packages, files, or networking configured during bootstrap. If cloud-init fails or times out, creation reports an error and dependent resources do not run; the created VM remains recorded in Terraform state.

Cloud-init configures a new VM. Changing inline or rendered cloud-init forces replacement. With cloud_init_file, changing the path forces replacement, but editing the file at the same path does not itself change that Terraform argument. Use cloud_init = file(...) when file-content changes should appear in the plan.

6. Add network interfaces

Start by finding networks on your own host:

multipass networks
Enter fullscreen mode Exit fullscreen mode

You can also expose the list through Terraform:

data "multipass_networks" "host" {}

output "host_networks" {
  value = data.multipass_networks.host.networks
}
Enter fullscreen mode Exit fullscreen mode

Each networks block becomes a multipass launch --network argument. Additional interfaces are attached alongside the default interface that Multipass uses to communicate with the VM.

For a VM with two extra interfaces, add:

variable "lan_network" {
  type        = string
  description = "A usable host network name from multipass networks."
}

variable "second_network" {
  type        = string
  description = "A second usable host network name."
}

resource "multipass_instance" "router" {
  name   = "lab-router"
  image  = "24.04"
  cpus   = 2
  memory = "2G"
  disk   = "10G"

  networks {
    name = var.lan_network
    mode = "auto"
    mac  = "52:54:00:4b:ab:01"
  }

  networks {
    name = var.second_network
    mode = "auto"
    mac  = "52:54:00:4b:ab:02"
  }
}
Enter fullscreen mode Exit fullscreen mode

Set the network variables in terraform.tfvars using names available on your host. Use a unique MAC for each interface on a network. A fixed MAC is useful for a DHCP reservation; mode = "auto" lets Multipass configure the interface automatically. Manual mode requires guest-side configuration.

Attaching two interfaces does not enable IP forwarding or configure routing between them. That is a separate guest configuration step if you want this machine to act as a router.

Networking support and bridge setup depend on your operating system and Multipass driver. Prepare any required host bridge before an unattended Terraform run. See Multipass's custom networking guide.

Changing a resource's networks blocks replaces that VM. Decide on its network layout before storing data in it.

7. Configure a static address

A static address combines a fixed MAC, manual networking, and guest configuration. Choose an unused address on the subnet of the selected host network, outside its dynamic DHCP pool or reserved appropriately. The address below is an example; change it to match your network.

This example reuses lan_network and ssh_public_key from the previous sections. If using it on its own, copy those variable declarations along with the provider configuration.

locals {
  static_cidr = "192.168.1.210/24"
  static_ip   = split("/", local.static_cidr)[0]
  static_mac  = "52:54:00:4b:ab:03"
}

resource "multipass_instance" "static" {
  name   = "lab-node-1"
  image  = "24.04"
  cpus   = 2
  memory = "2G"
  disk   = "10G"

  networks {
    name = var.lan_network
    mode = "manual"
    mac  = local.static_mac
  }

  cloud_init = <<-EOT
    #cloud-config
    ssh_authorized_keys:
      - ${jsonencode(var.ssh_public_key)}
    write_files:
      - path: /etc/netplan/10-custom.yaml
        permissions: "0600"
        content: |
          network:
            version: 2
            ethernets:
              extra0:
                match:
                  macaddress: "${local.static_mac}"
                dhcp4: false
                addresses: ["${local.static_cidr}"]
    runcmd:
      - [netplan, apply]
  EOT

  wait_for_cloud_init = true

  timeouts {
    create = "20m"
  }
}

output "static_ip" {
  description = "The address configured for the additional interface."
  value       = local.static_ip
}

output "reported_ips" {
  value = multipass_instance.static.ipv4
}
Enter fullscreen mode Exit fullscreen mode

The default interface remains available for Multipass and outbound connectivity. The extra interface gets the static address. For an isolated lab network, create a host bridge on its own subnet first; Canonical's static-IP guide shows that approach.

Waiting for cloud-init ensures the Netplan command completes before the provider reads the final instance information. It does not prove the address is reachable from every machine on your network.

Do not assume ipv4[0] is the static address. The provider returns Multipass's reported addresses without selecting one by interface. Here, static_ip is the configured value, while reported_ips shows what Multipass observed.

8. Upload and download files

The provider has separate resources for uploads and downloads. Both use source and destination; the resource type determines the direction.

For a complete round trip, add this example using the original dev VM:

resource "multipass_file_upload" "config" {
  instance    = multipass_instance.dev.name
  destination = "/home/ubuntu/demo/app.conf"
  content     = "PORT=8080\nLOG_LEVEL=info\n"

  create_parents = true

  lifecycle {
    replace_triggered_by = [multipass_instance.dev]
  }
}

resource "multipass_file_download" "config_copy" {
  instance       = multipass_instance.dev.name
  source         = multipass_file_upload.config.destination
  destination    = "${path.module}/downloads/app.conf"
  create_parents = true
  overwrite      = true

  triggers = {
    payload = multipass_file_upload.config.content_hash
  }

  lifecycle {
    replace_triggered_by = [multipass_file_upload.config]
  }
}
Enter fullscreen mode Exit fullscreen mode

The upload uses a path writable by the default Ubuntu user. Referencing the upload's destination makes the download wait for it. The hash trigger refreshes the local copy when the payload changes.

The lifecycle rules also repeat the transfers after a planned VM update or replacement. Multipass instance IDs are names, so a rebuilt VM can have the same ID string. Referencing the whole resource in replace_triggered_by follows its planned updates and replacements, including those where its name stays the same. See Terraform's lifecycle reference.

To upload an existing local file, replace content with source = "${path.module}/files/app.conf" and create that file before planning. Provide exactly one of source or content. For directories, use recursive = true on both the upload and download resources and give each resource a dedicated destination directory.

Transfers do not run with automatic sudo. Use cloud-init to write files under privileged paths such as /etc/nginx, or explicitly arrange a separate privileged installation step. Uploading a config file also does not install or restart its application.

What file resources track

  • Uploads hash the local payload and transfer it when that hash changes. Guest-side edits to the uploaded file are not detected.
  • Downloads do not continuously watch the guest file. Change triggers or request replacement when you want a fresh copy.
  • Destroying an upload removes its remote destination. Destroying a download removes its local managed payload. A recursive download owns its destination directory, so keep unrelated files elsewhere.

For example, to fetch the current remote copy explicitly:

terraform apply -replace=multipass_file_download.config_copy
Enter fullscreen mode Exit fullscreen mode

Use these resources for files whose creation and deletion should follow Terraform's lifecycle. Choose a separate collection process for logs or backups that must outlive terraform destroy.

9. Use the address with Ansible

For the static-IP example, this output exposes the intended address and guest user without relying on address-list ordering:

output "ansible_hosts" {
  value = {
    lab_node_1 = {
      ansible_host = local.static_ip
      ansible_user = "ubuntu"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Read it with:

terraform output -json ansible_hosts
Enter fullscreen mode Exit fullscreen mode

This is input for an inventory generator, not a complete Ansible dynamic inventory. Render your preferred inventory format, or adapt the JSON to Ansible's inventory schema.

The static-IP example installs your public key for the default Ubuntu user. Ansible still needs the matching private key and network access from the machine where it runs. Terraform's wait_for_cloud_init covers bootstrap completion; use Ansible's connection waiting when your workflow also needs to account for SSH becoming reachable.

10. Manage snapshots and existing instances

The provider can manage named snapshots, but the target VM must be stopped. A dependency on the instance only establishes ordering; it does not stop it.

To snapshot the existing dev-box, stop it first:

multipass stop dev-box
Enter fullscreen mode Exit fullscreen mode

Then add this resource and apply the configuration:

resource "multipass_snapshot" "backup" {
  instance = multipass_instance.dev.name
  name     = "pre-upgrade"
  comment  = "Before upgrading the development environment"
}
Enter fullscreen mode Exit fullscreen mode

Run this step separately from pending file-transfer operations that need a running guest. Start the VM again when the snapshot has completed:

multipass start dev-box
Enter fullscreen mode Exit fullscreen mode

Existing objects can also be imported. Add the corresponding resource configuration first, then import it instead of creating it:

terraform import multipass_instance.dev dev-box
terraform import multipass_alias.shell dev-shell
terraform import multipass_snapshot.backup dev-box.pre-upgrade
terraform import multipass_file_upload.config dev-box:/home/ubuntu/demo/app.conf
Enter fullscreen mode Exit fullscreen mode

These are alternative commands for objects not already managed at those Terraform addresses. Review the next plan after import. File downloads cannot be imported.

For inspection without lifecycle ownership, use the multipass_instance data source. Other data sources list available images, host networks, and snapshots. See the provider documentation.

11. Recovery and cleanup

For an instance that might be soft-deleted outside Terraform, add auto_recover = true to its resource. Add auto_start_on_recover = true if it should also be started after recovery. Recovery does not recreate a purged VM or restore files from a backup.

When the lab is no longer needed:

terraform plan -destroy
terraform destroy
Enter fullscreen mode Exit fullscreen mode

Review everything managed in that configuration, including VM disks, snapshots, aliases, uploaded files, and downloaded copies. Keep data that must survive teardown outside those managed destinations.

Where to go next

  • Browse the complete examples for development labs, host mounts, cloud-init, and file workflows.
  • Read the 2.0 upgrade notes before moving an existing 1.x configuration to the new resize default.
  • Prefer TypeScript or Python? The provider also works through Pulumi's Any Terraform Provider bridge. Start with the Pulumi guide and its language examples.

Start with a small VM, add only the networking and bootstrap configuration you need, and review each plan before applying it. The same resource model then scales to repeatable development environments, test labs, and local CI hosts.

Top comments (0)