Modeling a Two-Tier App on One Laptop with Vagrant Multi-Machine
Vagrant's multi-machine feature lets you define a web VM and a database VM in one Vagrantfile, wired over a private network — a cheap way to rehearse real topology on a laptop.
01 Oct 2026, 00:57 UTC

You've written an app that talks to PostgreSQL, and your tests all pass against a database running in a container on your machine. Then staging reveals the truth: your app assumes localhost latency, your migration script leaks connections, and nobody ever tested what happens when the database is a network hop away. You needed two machines, not one process. Vagrant's multi-machine feature exists for exactly this gap: several VMs defined in a single Vagrantfile, so you can rehearse a realistic topology before it costs you an incident.
What multi-machine actually gives you
A regular Vagrantfile defines one VM. Multi-machine replaces that with named config.vm.define blocks, each with its own box, network, and provisioning, while inheriting whatever you set at the top level. The payoff is that topology becomes code: the fact that your web tier reaches the database over a private network at a fixed address is written down, version-controlled, and reproducible on a teammate's machine.
Every vagrant command then takes a machine name as an argument: vagrant up web, vagrant ssh db, vagrant halt web. Run vagrant up with no name and you get all of them, which is convenient right up until your laptop starts swapping.
A worked example: web plus database
This Vagrantfile targets the VirtualBox provider and defines two machines on a private network with static IPs, so the web VM can always find the database at the same address:
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/jammy64"
config.vm.define "db", autostart: false do |db|
db.vm.network "private_network", ip: "192.168.56.11"
db.vm.provider "virtualbox" do |vb|
vb.memory = 1024
end
db.vm.provision "shell", path: "provision-db.sh"
end
config.vm.define "web", primary: true do |web|
web.vm.network "private_network", ip: "192.168.56.10"
web.vm.network "forwarded_port", guest: 8080, host: 8080
web.vm.provider "virtualbox" do |vb|
vb.memory = 1024
end
web.vm.provision "shell", path: "provision-web.sh"
end
endThree details do the heavy lifting here:
- Static private IPs.
private_networkcreates a host-only network, so the VMs can reach each other and the host, but nothing outside can reach them. Your app config can hardcode192.168.56.11:5432for development and it will be true on every machine that runs this Vagrantfile. - Per-machine provisioning.
provision-db.shinstalls and configures PostgreSQL (including listening on the private interface, not just localhost — a classic gotcha this setup will actually catch), whileprovision-web.shinstalls your app runtime. Concerns stay separated in one file. primary: trueandautostart: false. The primary flag makesvagrant sshwith no argument land onweb. Settingautostart: falseon the db means a barevagrant upskips it — useful when you're iterating on app code against a staging database and don't want to spend a gigabyte of RAM.
Verifying it actually works
Run these from the directory containing the Vagrantfile, as your normal user (no sudo needed for VirtualBox on most setups):
vagrant validate
vagrant up web db
vagrant statusvagrant validate catches syntax errors before you wait through a box boot. vagrant status should show both machines as running. Then prove the network path — this is the whole point of the exercise:
vagrant ssh web
# inside the web VM:
nc -zv 192.168.56.11 5432A successful TCP connection to port 5432 confirms the private network, the database's listen address, and any firewall rules all line up. If it fails, check whether PostgreSQL is bound to 0.0.0.0 or only 127.0.0.1 in postgresql.conf — that mismatch is the most common cause, and finding it here instead of in staging is the entire value proposition.
Also test selective control: vagrant halt db, then confirm vagrant ssh web still works and a bare vagrant up respects the autostart setting.
The honest trade-offs
RAM is the hard limit. Consumption scales linearly. Two 1 GB VMs plus VirtualBox overhead is fine on a 16 GB laptop; three or four will hurt. Size each machine with vb.memory, and lean on autostart: false for anything you don't need every session.
Static IPs can collide. The 192.168.56.0/24 range is VirtualBox's default host-only range, but corporate VPNs and home routers sometimes claim overlapping subnets. If you see bizarre connectivity failures when the VPN is up, move your Vagrant network to something uncommon like 10.99.0.0/24.
Provider behavior differs. This example names VirtualBox deliberately. VMware and Hyper-V handle private networking and synced folders differently enough that a Vagrantfile tuned for one provider often needs adjustment on another. Treat cross-provider portability as a goal to test, not a given.
It's a rehearsal, not production. Multi-machine Vagrant models topology and provisioning flow. It has no service discovery, no health checks, no restart policies. If the db VM dies, nothing brings it back. That's fine — the job here is catching "works on my machine" bugs in your provisioning scripts and network assumptions, not running workloads.
Where to go from here
Start with the two-tier example above, run vagrant validate, and get the nc connectivity check passing. Once it does, point your app's real config at the db VM's address and run your integration test suite against it. Whatever breaks — hardcoded localhost, missing retry logic, a migration that assumes a local socket — is a bug you just found for the price of a gigabyte of RAM. And since Vagrant's defaults and provider support shift between releases, check the docs for your installed version with vagrant version before assuming any behavior described here still holds.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.