Long before Docker Desktop existed for Mac and Windows, running Docker meant running Linux, and the easiest way to get a consistent Linux VM on any host operating system was Vagrant. Even today, I still reach for Vagrant when I want a fully isolated, reproducible Docker host that’s separate from my actual laptop — useful for testing daemon configuration changes, experimenting with different Docker versions side by side, or giving a team a byte-for-byte identical environment regardless of whether they’re on macOS, Windows, or Linux.
What Vagrant Actually Provides
Vagrant is a tool for describing and provisioning virtual machines declaratively, using a Vagrantfile. It sits on top of a virtualization provider — VirtualBox, VMware, Hyper-V, or libvirt — and automates VM creation, networking, shared folders, and provisioning scripts. For this guide I’ll use VirtualBox, since it’s free and cross-platform.
Prerequisites
- VirtualBox installed
- Vagrant installed (
vagrant --version) - About 2GB of free disk space for the base box
vagrant --version
Vagrant 2.4.1
Step 1: Create a Project Directory
mkdir docker-vagrant-host && cd docker-vagrant-host
Step 2: Initialize a Vagrantfile
vagrant init ubuntu/jammy64
A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.
Step 3: Edit the Vagrantfile to Provision Docker Automatically
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/jammy64"
config.vm.hostname = "docker-host"
config.vm.network "private_network", ip: "192.168.56.10"
config.vm.network "forwarded_port", guest: 80, host: 8080
config.vm.provider "virtualbox" do |vb|
vb.memory = "4096"
vb.cpus = 2
end
config.vm.provision "shell", inline: <<-SHELL
apt-get update
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
usermod -aG docker vagrant
SHELL
end
A few decisions worth explaining:
private_networkwith a static IP gives me a predictable address to reach the VM from my host machine, separate from NAT port forwarding.forwarded_portmaps a specific container-facing port (80) to a host-facing one (8080), so I can hitlocalhost:8080from my actual laptop browser.- The inline shell provisioner runs Docker’s official APT-based installation, identical to installing Docker on a bare Ubuntu server.
usermod -aG docker vagrantlets the defaultvagrantuser run Docker commands withoutsudo.
Step 4: Bring Up the VM
vagrant up
Expected output (abbreviated):
Bringing machine 'default' up with 'virtualbox' provider...
==> default: Importing base box 'ubuntu/jammy64'...
==> default: Setting the name of the VM: docker-vagrant-host_default
==> default: Configuring and enabling network interfaces...
==> default: Running provisioner: shell...
default: Running: inline script
default: Reading package lists...
default: Setting up docker-ce (5:27.3.1-1~ubuntu.22.04~jammy) ...
default: Setting up docker-ce-cli (5:27.3.1-1~ubuntu.22.04~jammy) ...
The full provisioning run typically takes three to six minutes depending on connection speed.
Step 5: SSH Into the VM
vagrant ssh
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-101-generic x86_64)
vagrant@docker-host:~$
Verify Docker:
docker --version
docker run hello-world
Docker version 27.3.1, build ce12230
...
Hello from Docker!
This message shows that your installation appears to be working correctly.
Step 6: Run a Real Container and Reach It From the Host
Inside the VM:
docker run -d -p 80:80 --name vm-nginx nginx:latest
From my actual laptop (outside the VM):
curl http://localhost:8080
<!DOCTYPE html>
<html>
<head><title>Welcome to nginx!</title></head>
...
The forwarded port I defined in the Vagrantfile makes port 80 inside the VM reachable at port 8080 on my host machine.
Step 7: Shared Folders for Development
By default, Vagrant syncs the project directory (where the Vagrantfile lives) into /vagrant inside the VM — handy for editing Dockerfiles and Compose files on my host with my normal editor, while building and running them inside the VM:
vagrant ssh -c "ls /vagrant"
Vagrantfile
Step 8: Managing the VM Lifecycle
vagrant halt # gracefully shuts down the VM
vagrant suspend # saves state and pauses, faster to resume
vagrant resume # resumes a suspended VM
vagrant destroy # deletes the VM entirely
vagrant destroy -f
==> default: Forcing shutdown of VM...
==> default: Destroying VM and associated drives...
Internal Working: What Vagrant Is Doing Underneath
Vagrant itself doesn’t run VMs — it’s an orchestration layer over a provider (VirtualBox here). When I run vagrant up, Vagrant reads the Vagrantfile, calls the VirtualBox provider driver to import the specified base box (a pre-built VM disk image plus metadata), configures the VM’s virtual NICs and forwarded ports via VirtualBox’s own CLI (VBoxManage), boots it, waits for SSH to become reachable, then executes the shell provisioner script over that SSH connection exactly as if I’d typed the commands myself. The Docker installation itself is entirely standard — Vagrant contributes nothing Docker-specific; it’s simply automating “boot a fresh Ubuntu VM and run these exact commands on it.”
Networking Considerations
Two networking modes are active simultaneously here: the default NAT interface (which is how vagrant ssh reaches the VM, and how the VM reaches the internet to install packages), and the private_network interface I added, which puts the VM on an isolated host-only network at a fixed IP. Port forwarding is a third, independent mechanism layered on top of the NAT interface — it’s why localhost:8080 on my host reaches port 80 inside the VM without needing the private network IP at all.
Storage Considerations
The VM’s virtual disk is separate from my host filesystem entirely, except for the synced /vagrant folder. Docker images and containers pulled/created inside the VM live on that virtual disk, which means destroying the VM (vagrant destroy) wipes all pulled images and container data — intentional for a disposable dev environment, but worth remembering if I’ve built something I want to keep.
Security Considerations
- Vagrant’s default SSH setup uses an insecure, publicly known private key for the initial box unless explicitly replaced — fine for a local dev VM isolated from the internet, but never expose this VM’s SSH port externally without hardening it first.
- The
private_networkIP I configured is only reachable from my host machine’s network stack by default, not the wider internet, but I still avoid running anything sensitive on it long-term. - Since the
vagrantuser is added to thedockergroup, any process running as that user has effectively root-equivalent control over the VM (a general property of Docker group membership, not specific to Vagrant).
Troubleshooting
“VT-x is not available” or virtualization errors Usually means hardware virtualization isn’t enabled in the host machine’s BIOS, or it’s conflicting with another hypervisor (like Hyper-V on Windows) that’s already claimed it.
Provisioning script fails partway through Re-run just the provisioner without recreating the whole VM:
vagrant provision
Forwarded port already in use on the host
Vagrant cannot forward the specified ports on this VM, since they
would collide with some other application that is already listening
on these ports.
Change the host-side port number in the Vagrantfile’s forwarded_port line and run vagrant reload.
Monitoring
vagrant ssh -c "docker stats --no-stream"
vagrant global-status
id name provider state directory
a1b2c3d default virtualbox running /home/user/docker-vagrant-host
Best Practices
- Pin the base box version explicitly (
config.vm.box_version) for reproducibility across teammates. - Keep provisioning scripts idempotent so
vagrant provisioncan be re-run safely. - Use
private_networkwith static IPs for predictable access rather than relying solely on NAT/forwarded ports. - Destroy and recreate VMs regularly rather than letting configuration drift accumulate.
Summary
Vagrant gives me a fully reproducible, disposable Linux VM with Docker installed via a single declarative file and one command, independent of whatever operating system my actual laptop runs. It’s an excellent way to test Docker daemon configurations, try different Docker versions, or hand a team an identical environment — all without touching my host machine’s own software directly.
References
- Vagrant documentation: https://developer.hashicorp.com/vagrant/docs
- VirtualBox documentation: https://www.virtualbox.org/wiki/Documentation
- Docker Engine installation on Ubuntu: https://docs.docker.com/engine/install/ubuntu/
- Vagrant Cloud (base box registry): https://app.vagrantup.com/boxes/search
