One thing I forgot: in the book Ansible Up & Running, in chapter 3, there's a Vagrantfile describing three containers with consecutive ports exposed and mapped to the host OS.  Each VM is defined individually, by hand.  I had read that a Vagrantfile is just Ruby, so I thought, "Screw that.  I'm gonna use a loop."

It turns out you can't just use a loop.  Because the VMs are booted asynchronously, they need closures.  You can replace the entirety of the Vagrantfile in chapter three with this:

config.ssh.insert_key = false

(1..3).each do |host|
  config.vm.define "vagrant#{host}" do |vagrant|
    vagrant.vm.box = "maier/alpine-3.4-x86_64"
    vagrant.vm.network "forwarded_port", guest: 79 + host, host: 8079 + host
    vagrant.vm.network "forwarded_port", guest: 442 + host, host: 8442 + host
    vagrant.vm.synced_folder ".", "/vagrant", disabled: true
  end
end

The do |host| syntax creates a closure that ensures that the host variable will be different for each VM, run in a separate scope. Using a for loop, the host variable would be "3" at the end and when vagrant picks up the entry, all three VMs will try to allocate the same port, resulting in conflict and crash.  Again note that I'm using Alpine here.  If you're using Ubuntu as the book recommends, make sure you change the container name.