DEV Community

Cover image for Terraform for Local VMs: A Modern Alternative to Vagrant
todoroff
todoroff

Posted on Edited on

Terraform for Local VMs: A Modern Alternative to Vagrant

Updated October 2026 for provider 2.0.1.

After 8 years of using Vagrant, I finally made the switch. Not because Vagrant stopped working - it still does what it's always done. But because I found something that fits better into my modern infrastructure-as-code workflow.

Here's the story of how I replaced Vagrant with Multipass + Terraform and never looked back.

The Problem: Vagrant is Showing Its Age

Don't get me wrong - Vagrant revolutionized local development. The idea of "infrastructure as code" for your laptop was groundbreaking in 2010.

But in 2026, I kept hitting the same pain points:

🔧 Provider-specific setup
Vagrant supports several providers, including Hyper-V. Each backend still comes with its own setup and configuration.

📦 Heavy box downloads
The first use of a new box meant a large image download.

🤔 Different workflow
Vagrantfile syntax is Ruby DSL. My production infrastructure is Terraform. Why maintain two mental models?

Sound familiar?

The Discovery: Multipass + Terraform

Then I discovered Canonical Multipass - a lightweight VM manager from Canonical.

Combined with a Terraform provider, I could:

  • ✅ Use the same IaC approach locally and in production
  • ✅ Native cloud-init support (just like AWS/GCP/Azure)
  • ✅ Simpler setup - no VirtualBox Guest Additions hassles
  • ✅ One workflow to rule them all

Let me show you how to make the switch.


Part 1: Understanding the Difference

Vagrant's Approach

# Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/jammy64"
  config.vm.network "private_network", ip: "192.168.56.10"

  config.vm.provider "virtualbox" do |vb|
    vb.memory = "2048"
    vb.cpus = 2
  end

  config.vm.provision "shell", inline: <<-SHELL
    apt-get update
    apt-get install -y nginx
  SHELL
end
Enter fullscreen mode Exit fullscreen mode
  • Ruby DSL
  • Provider-specific configurations
  • Shell provisioners (or Ansible/Chef/Puppet)
  • Box-based images

Multipass + Terraform Approach

# main.tf
terraform {
  required_providers {
    multipass = {
      source  = "todoroff/multipass"
      version = "~> 2.0.1"
    }
  }
}

resource "multipass_instance" "web" {
  name   = "web-server"
  cpus   = 2
  memory = "2G"
  disk   = "10G"
  image  = "jammy"

  wait_for_cloud_init = true

  cloud_init = <<-EOT
    #cloud-config
    package_update: true
    packages:
      - nginx
  EOT
}
Enter fullscreen mode Exit fullscreen mode
  • Standard HCL (same as production Terraform)
  • Cloud-init provisioning (same as cloud VMs)
  • Image aliases (Multipass downloads and caches the selected image)

Both install Nginx. The Multipass example uses its default network; matching Vagrant's static private IP needs additional network configuration.


Part 2: Setting Up Multipass + Terraform

Step 1: Install Multipass

You'll need Terraform 1.6+ and Multipass 1.13+ on the same host. The provider calls the local Multipass CLI.

# macOS
brew install multipass

# Ubuntu/Debian (with snapd installed)
sudo snap install multipass

# Windows
# Download from https://multipass.run/install
winget install Canonical.Multipass
Enter fullscreen mode Exit fullscreen mode

Verify it works:

multipass version
# Check that Multipass is 1.13 or newer
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Your First Terraform Config

Create a new directory and main.tf:

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

provider "multipass" {
  # Optional: explicit path to multipass binary
  # multipass_path = "/usr/local/bin/multipass"

  # Optional: timeout for commands (default 600s)
  command_timeout = 600
}

resource "multipass_instance" "dev" {
  name   = "my-dev-vm"
  cpus   = 2
  memory = "4G"
  disk   = "20G"
  image  = "jammy"  # Ubuntu 22.04 LTS
}

output "ip_address" {
  value = multipass_instance.dev.ipv4[0]
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Deploy

terraform init
terraform apply
Enter fullscreen mode Exit fullscreen mode

That's it. Your VM is running. ⚡

# Check it
multipass list
# Name       State    IPv4            Image
# my-dev-vm  Running  192.168.64.5    Ubuntu 22.04 LTS

# SSH into it
multipass shell my-dev-vm
Enter fullscreen mode Exit fullscreen mode

Part 3: Migrating Your Vagrantfile

Let's convert a real-world Vagrant setup to Multipass + Terraform. This example provisions PostgreSQL and Nginx, and passes the database address to the web VM. A real application still needs its database connection and authentication configured.

Before: Vagrant Multi-Machine Setup

# Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/jammy64"

  config.vm.define "db" do |db|
    db.vm.hostname = "db-server"
    db.vm.network "private_network", ip: "192.168.56.10"
    db.vm.provider "virtualbox" do |vb|
      vb.memory = "2048"
    end
    db.vm.provision "shell", inline: <<-SHELL
      apt-get update
      apt-get install -y postgresql postgresql-contrib
      sudo -u postgres createuser --superuser vagrant
      sudo -u postgres createdb myapp
    SHELL
  end

  config.vm.define "web" do |web|
    web.vm.hostname = "web-server"
    web.vm.network "private_network", ip: "192.168.56.11"
    web.vm.provider "virtualbox" do |vb|
      vb.memory = "1024"
    end
    web.vm.provision "shell", inline: <<-SHELL
      apt-get update
      apt-get install -y nginx
      echo "<h1>Web server</h1><p>Database: 192.168.56.10</p>" > /var/www/html/index.html
    SHELL
  end
end
Enter fullscreen mode Exit fullscreen mode

After: Multipass + Terraform

Use a new directory for this example, with main.tf and web-init.yaml together.

# main.tf
terraform {
  required_providers {
    multipass = {
      source  = "todoroff/multipass"
      version = "~> 2.0.1"
    }
  }
}

provider "multipass" {}

# Database Server
resource "multipass_instance" "db" {
  name   = "db-server"
  cpus   = 2
  memory = "2G"
  disk   = "10G"
  image  = "jammy"

  wait_for_cloud_init = true

  cloud_init = <<-EOT
    #cloud-config
    package_update: true
    packages:
      - postgresql
      - postgresql-contrib
    runcmd:
      - sudo -u postgres createuser --superuser ubuntu
      - sudo -u postgres createdb myapp
  EOT

  timeouts {
    create = "20m"
  }
}

# Web Server (depends on DB for its IP)
resource "multipass_instance" "web" {
  name   = "web-server"
  cpus   = 1
  memory = "1G"
  disk   = "5G"
  image  = "jammy"

  cloud_init = templatefile("${path.module}/web-init.yaml", {
    db_host = multipass_instance.db.ipv4[0]
  })

  wait_for_cloud_init = true

  timeouts {
    create = "20m"
  }
}

# Outputs
output "db_ip" {
  value = multipass_instance.db.ipv4[0]
}

output "web_ip" {
  value = multipass_instance.web.ipv4[0]
}
Enter fullscreen mode Exit fullscreen mode
#cloud-config
package_update: true
packages:
  - nginx
write_files:
  - path: /var/www/html/index.html
    permissions: '0644'
    defer: true
    content: |
      <h1>Web server</h1>
      <p>Database: ${db_host}</p>
runcmd:
  - systemctl restart nginx
Enter fullscreen mode Exit fullscreen mode

Save the YAML as web-init.yaml. The DB reference creates Terraform's dependency automatically. wait_for_cloud_init = true also waits for guest provisioning to finish; depends_on alone would not do that. The page displays the DB address to demonstrate templating.


Part 4: Advanced Features You'll Love

Resize an Existing VM

Provider 2.x defaults to resize_policy = "in_place": CPU and memory changes, and disk growth, resize the existing VM. Running VMs stop and restart; already stopped VMs stay stopped. Disk shrink is rejected before mutation, and guest filesystem expansion may require a separate step.

Use resize_policy = "replace" to keep the previous behavior of rebuilding on size changes. Name, image, cloud-init, and network changes still force recreation. Changing the policy alone leaves the VM as it is.

When upgrading from 1.x, change the version constraint and run terraform init -upgrade, then review the plan. ~> 1.5 excludes 2.x. See the 2.0 upgrade notes.

Snapshots (Save Your Progress!)

Multipass has native snapshots, which the provider can manage. Vagrant also supports snapshots with compatible backends.

resource "multipass_instance" "test_env" {
  name  = "test-env"
  image = "jammy"
  # ... config
}
Enter fullscreen mode Exit fullscreen mode

Apply the instance configuration first. Then stop the VM before adding the snapshot resource:

multipass stop test-env
Enter fullscreen mode Exit fullscreen mode
# Take a snapshot before risky operations
resource "multipass_snapshot" "before_upgrade" {
  instance = multipass_instance.test_env.name
  name     = "pre-upgrade-backup"
}
Enter fullscreen mode Exit fullscreen mode

Apply again to create the snapshot, then run multipass start test-env. A snapshot requires a stopped instance; the resource does not stop it automatically.

Now you can:

  1. Run your tests
  2. Break things
  3. Restore to snapshot
  4. Repeat

Terraform tracks the snapshot. To restore it, use the CLI:

multipass stop test-env
multipass restore test-env.pre-upgrade-backup
multipass start test-env
Enter fullscreen mode Exit fullscreen mode

Follow the restore prompt, then review terraform plan because restoration can change the VM relative to your configuration.

File Transfers (No More Provisioners!)

For config files and log downloads, the provider has dedicated transfer resources. Create configs/app.yaml locally before applying this example:

# Upload config files
resource "multipass_file_upload" "app_config" {
  instance    = multipass_instance.web.name
  source      = "${path.module}/configs/app.yaml"
  destination = "/home/ubuntu/app.yaml"
}

# Download logs for analysis
resource "multipass_file_download" "app_logs" {
  instance    = multipass_instance.web.name
  source      = "/var/log/nginx/access.log"
  destination = "${path.module}/logs/app.log"

  triggers = {
    refresh = "1" # Change this value to download a fresh copy.
  }
}
Enter fullscreen mode Exit fullscreen mode

Uploads need a writable destination; the transfer does not elevate permissions for paths under /etc. Downloads need readable source files. Transfers are discrete copies, and remote edits do not automatically trigger another transfer. Destroying these resources removes their respective remote or local destination.

For a shared source directory, use a mounts block inside your instance resource:

  mounts {
    host_path     = abspath("${path.module}/src")
    instance_path = "/workspace"
  }
Enter fullscreen mode Exit fullscreen mode

Create src on the host before applying. This is the closer equivalent to config.vm.synced_folder; mounts can be added or removed without recreating the VM.

Aliases (Quick Access)

Create host-level shortcuts to jump into your VMs:

resource "multipass_alias" "db_shell" {
  name     = "db-psql"
  instance = multipass_instance.db.name
  command  = "sudo -u postgres psql myapp"
}

resource "multipass_alias" "web_logs" {
  name     = "web-logs"
  instance = multipass_instance.web.name
  command  = "tail -f /var/log/nginx/access.log"
}
Enter fullscreen mode Exit fullscreen mode

Now from your host terminal:

multipass db-psql      # Opens psql on db server
multipass web-logs     # Tails nginx logs
Enter fullscreen mode Exit fullscreen mode

For the shorter db-psql and web-logs commands, add the Multipass alias directory to your host's PATH as described in the alias guide.

No more vagrant ssh web -c "..." gymnastics.


Part 5: Real-World Use Cases

Local Kubernetes Clusters

Want to spin up a full multi-node K3s cluster for testing? I wrote a complete step-by-step tutorial:

📚 Build a Local Kubernetes Cluster in Minutes with Terraform and Multipass

The tutorial walks through:

  • 1 master + 2 worker nodes
  • Automatic cluster joining via cloud-init
  • kubectl access from your host machine
  • Complete working code you can deploy today

Other Common Use Cases

This stack is also perfect for:

🔧 Infrastructure Testing

  • Test Ansible playbooks across multiple nodes
  • Validate HAProxy or Nginx load balancer configs
  • Simulate distributed systems locally

🎓 Learning Labs

  • Practice Terraform without cloud costs
  • Learn Linux system administration
  • Experiment with databases and replication

💻 Development Environments

  • Reproducible team dev environments
  • Test upgrades in isolation
  • Quick throwaway VMs for experimentation

Part 6: Migration Cheat Sheet

Vagrant Multipass + Terraform
vagrant up terraform apply to create; multipass start <name> to start an existing stopped VM
vagrant destroy terraform destroy
vagrant ssh multipass shell <name>
vagrant halt (use multipass stop <name>)
vagrant reload multipass restart <name> to reboot; terraform apply for configuration changes
config.vm.box image = "jammy"
vb.memory memory = "2G"
vb.cpus cpus = 2
config.vm.provision "shell" cloud_init = <<-EOT ... EOT at creation; changing it recreates the VM
config.vm.synced_folder mounts { } block
config.vm.network networks { } for host interfaces; configure static IPs inside the guest

Part 7: When to Stick with Vagrant

To be fair, Vagrant still has its place:

✅ Use Vagrant if:

  • You need non-Ubuntu operating systems (Windows, CentOS, etc.)
  • You rely on a particular Vagrant provider or plugin
  • Your team has heavy investment in existing Vagrantfiles
  • You need VirtualBox-specific features

✅ Use Multipass + Terraform if:

  • You're working primarily with Ubuntu
  • You want a simpler, lighter setup
  • You want one workflow for local and cloud
  • You're already using Terraform
  • You want modern cloud-init provisioning
  • Resource efficiency is important

Getting Started Today

Ready to make the switch? Here's a quickstart. The first image download and package installation can take a few minutes.

1. Install Dependencies

# Install Multipass
brew install multipass  # or snap install multipass

# Verify
multipass version
Enter fullscreen mode Exit fullscreen mode

2. Create Your First Config

The following file-creation commands use Bash. In PowerShell, save the HCL between EOF markers as main.tf using your editor.

mkdir my-dev-env && cd my-dev-env

cat > main.tf << 'EOF'
terraform {
  required_providers {
    multipass = {
      source  = "todoroff/multipass"
      version = "~> 2.0.1"
    }
  }
}

resource "multipass_instance" "dev" {
  name   = "dev-box"
  cpus   = 2
  memory = "4G"
  disk   = "20G"
  image  = "jammy"

  wait_for_cloud_init = true

  cloud_init = <<-EOT
    #cloud-config
    package_update: true
    packages:
      - git
      - curl
      - build-essential
    runcmd:
      - echo "Dev environment ready!" > /home/ubuntu/welcome.txt
  EOT

  timeouts {
    create = "20m"
  }
}

output "ip" {
  value = multipass_instance.dev.ipv4[0]
}
EOF
Enter fullscreen mode Exit fullscreen mode

3. Deploy

terraform init
terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

4. Access Your VM

multipass shell dev-box
cat ~/welcome.txt
# Dev environment ready!
Enter fullscreen mode Exit fullscreen mode

That's it. You're running a modern, fast, Terraform-managed local development environment.


Resources


Conclusion

Switching from Vagrant to Multipass + Terraform was one of the best decisions I made for my local development workflow.

What I gained:

  • 🔧 Simpler setup (no Guest Additions, NFS issues, etc.)
  • 🔄 One IaC workflow for everything
  • 📸 Native snapshot support
  • 🎯 Cloud-native provisioning with cloud-init

What I lost:

  • Multi-OS support (Ubuntu-only)
  • ...that's about it

If you want a modern, Terraform-native approach to local VMs, give this a try.


Have questions about migrating your Vagrant setup? Drop them in the comments! I'm happy to help with specific migration scenarios.

Found this useful? Consider ⭐ starring the provider on GitHub - it helps others discover it!


What's your local development setup? Still on Vagrant, Docker Compose, or something else entirely? Let me know in the comments! 👇

Top comments (0)