2 AM and the Dev Environment Is on Fire
2:17 AM. Staging deploy done, everything running smoothly. Next morning, a developer sends a bug report: “Can’t reproduce it on my machine.” I stared at the screen for 5 minutes before it clicked — the dev was on Ubuntu 20.04, the server was running Ubuntu 22.04, and I was debugging on macOS. Three different environments, one codebase.
Vagrant fixes exactly that pain. Not because it’s the “best” or “most modern” tool — but for one simple reason: one Vagrantfile committed to git means any developer who clones the repo can run vagrant up and get an identical environment, whether they’re on Mac, Windows, or Ubuntu.
Comparing the Approaches: Manual VMs, Docker, and Vagrant
Three tools, three different design philosophies. Understanding each one before choosing matters — I’ve picked the wrong tool for the job more than once:
Approach 1: Manual VMs (VirtualBox/VMware)
Create a VM by hand, install the OS, set up dependencies, take a snapshot. This is how I ran my homelab — currently managing 12 VMs and containers with Proxmox VE, using it as a playground to test everything before pushing to production. Fine for a homelab, but for a dev team, it’s a nightmare.
- Config can’t be shared as code
- Every dev sets it up manually → inconsistent environments
- Onboarding a new team member takes an entire day
- No version control for the environment
Approach 2: Docker + Docker Compose
Container-based, lightweight, portable. This is the approach I use most for microservices. But Docker has weaknesses when you need an environment that more closely mirrors a real server:
- Containers share the host kernel — not full OS isolation
- Some tools require systemd, kernel modules, or need to test the actual network stack
- Docker Desktop on Mac/Windows has overhead and behaves differently from native Linux — I once spent 2 hours debugging a permissions bug that only reproduced on Linux due to different bind mount behavior; the same file at 777 on Mac had no effect at all
- Not suitable when you need to test firewall configs, disk partitioning, or kernel tuning
Approach 3: Vagrant
Vagrant wraps VM creation and management into a single config file — the Vagrantfile. It’s not a Docker replacement, but a complement for cases where a full VM is needed.
- Vagrantfile committed to git → the environment is code, with version history
- Full VM isolation, capable of testing kernel-level things
- Supports multiple providers: VirtualBox, VMware, Hyper-V, libvirt
- Can run Docker inside a Vagrant VM
Vagrant Pros and Cons
Advantages
- Infrastructure as Code: Vagrantfile is Ruby DSL — easy to read, easy to edit, committable to git
- Reproducible:
vagrant destroy && vagrant upgives you a fresh environment every time - Multi-machine: Define multiple VMs in one Vagrantfile (web + db + cache)
- Built-in provisioning: Shell scripts, Ansible, Chef, and Puppet are all supported out of the box
- Shared folders: Code lives on the host, runs inside the VM — edit with your favorite IDE without copying files
Disadvantages
- Heavier than Docker: Full VMs consume significantly more RAM and disk than containers
- Slow boot time: Booting a VM takes 30–60 seconds; containers start in seconds
- Hypervisor overhead: Requires VirtualBox (default) — can conflict with Docker Desktop on Windows due to Hyper-V contention
- Not ideal for microservices: If your project has 20 services, Docker Compose is a much better fit
Vagrant or Docker: When to Use Which?
My simple rule after making the wrong choice multiple times:
- Use Docker when: your app is a web service or microservices, you need fast spin-up/down, or you’re building a CI/CD pipeline
- Use Vagrant when: you need to test system-level things, simulate a multi-server setup, your team uses Mac/Windows but the server is Linux, or you need a production-identical environment down to the kernel and network
The real-world use case where I reach for Vagrant most: testing Ansible playbooks before applying them to production, simulating network topologies with multiple VMs, and reproducing bugs that only occur on a specific kernel version.
Setting Up Vagrant from Scratch
Step 1: Installation
Vagrant needs a VM provider. VirtualBox is the default:
# Ubuntu/Debian — install VirtualBox
sudo apt update && sudo apt install -y virtualbox
# Install Vagrant from the official HashiCorp repo
wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install vagrant
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/vagrant
# Verify installation
vagrant --version
# Vagrant 2.4.x
Step 2: Create Your First Vagrantfile
Create a project directory and write the Vagrantfile. I usually write it by hand instead of using vagrant init — the generated file is stuffed with comments that take forever to read through. Writing it from scratch is shorter and much clearer. Here’s a config for a simple LAMP stack:
Vagrant.configure("2") do |config|
# Base box — Ubuntu 22.04 LTS
config.vm.box = "ubuntu/jammy64"
config.vm.box_check_update = false
# Forward ports 80 and 3306 to the host
config.vm.network "forwarded_port", guest: 80, host: 8080
config.vm.network "forwarded_port", guest: 3306, host: 3306
# Private network with a static IP (easier to remember)
config.vm.network "private_network", ip: "192.168.56.10"
# Shared folder: code on host mounted to /var/www in the VM
config.vm.synced_folder "./src", "/var/www/html"
# VM resources
config.vm.provider "virtualbox" do |vb|
vb.memory = "2048"
vb.cpus = 2
vb.name = "dev-lamp"
end
# Provisioning: run shell script when the VM is first created
config.vm.provision "shell", inline: <<-SHELL
apt-get update -q
apt-get install -y -q apache2 mysql-server php php-mysql
systemctl enable apache2 mysql
systemctl start apache2 mysql
echo "Done! Access: http://192.168.56.10"
SHELL
end
Step 3: Start and SSH into the VM
# Create and start the VM (first run downloads the box ~500MB)
vagrant up
# SSH into the VM
vagrant ssh
# Check inside the VM
uname -a
# Linux ubuntu-jammy 5.15.0-xx-generic ...
# Exit
exit
Step 4: Daily Workflow
# Start the VM (already created, faster than the first time)
vagrant up
# Suspend — preserves RAM state (fastest)
vagrant suspend
vagrant resume
# Shut down the VM completely (frees RAM)
vagrant halt
# Completely destroy and recreate the VM from scratch
vagrant destroy -f && vagrant up
Step 5: Multi-machine — Simulate a Production Architecture
This is where Vagrant truly shines over manual setup. Simulate a separate web server and database server, exactly like production:
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/jammy64"
# Web server
config.vm.define "web" do |web|
web.vm.hostname = "web-server"
web.vm.network "private_network", ip: "192.168.56.10"
web.vm.provider "virtualbox" do |vb|
vb.memory = "1024"
end
web.vm.provision "shell", inline: "apt-get install -y -q nginx"
end
# Database server
config.vm.define "db" do |db|
db.vm.hostname = "db-server"
db.vm.network "private_network", ip: "192.168.56.11"
db.vm.provider "virtualbox" do |vb|
vb.memory = "2048"
end
db.vm.provision "shell", inline: "apt-get install -y -q postgresql"
end
end
# Start all machines
vagrant up
# Start only the web server
vagrant up web
# SSH into each machine
vagrant ssh web
vagrant ssh db
Tip: Use Ansible Provisioner Instead of Inline Shell
Inline shell scripts are fine for quick testing. For more serious projects, I switch to the Ansible provisioner:
config.vm.provision "ansible" do |ansible|
ansible.playbook = "provision/setup.yml"
ansible.verbose = "v"
end
The double benefit: the same playbook can be applied directly to your real production server — truly meaning “dev environment identical to production, down to every line of config.”
Practical Takeaways
My take after too many late-night debugging sessions caused by environment inconsistencies: you don’t have to pick one. Vagrant runs the VM, Docker runs containers inside that VM — this combination covers the vast majority of use cases.
Need to reproduce a bug that “only happens on the server”? Want to onboard a new dev without burning half a day on setup? Need to test an Ansible playbook before applying it to production? A Vagrantfile in your git repo is the answer. vagrant up and you’re done.
