It all started with the virtual chroma screen function of Zoom, which does not work on older laptops that do not have a beefy CPU. So, one day on Hacker News, I found an article by Ben Elder that describes how to set up your own virtual webcam on Linux.

It is rather easy to set up a virtual Linux machine on my laptop. I like to use Vagrant to script my virtual machines. Vagrant can work with several backends to provision VMs; I prefer Oracle VirtualBox.

With Vagrant, all definitions for bringing up a VM are contained within the Vagrantfile, which is basically a Ruby script. I was wondering if there is a way to connect the host computer’s webcam to the guest VM on startup. Turns out there is, but it is not quite trivial.

From the host command line, you would call

  VBoxManage controlvm {machine-uuid} webcam attach {webcam-id}

That’s what I am going to automate! My first idea was to set up the webcam during the provisioning phase, where Vagrant has an easy way to call the VBoxManage interface. Something like this:

  config.vm.provider "virtualbox" do |vb|
    vb.customize [ "controlvm", "zoombox", "webcam", "attach", ".0" ]

Turns out this does not work. At the point in time this is called, the VM is not up yet and you cannot use controlvm.

There is a GitHub repository by Kenta Yonekura that pointed me to the right solution. Vagrant has the possibility to use triggers that can be called before or after certain commands. However Kenta’s solution did not work for me, for these reasons:

  • The ID of the virtual machine should be read from an ID file in the .vagrant directory. This file does not exist on my system when the trigger is called.
  • The VBoxManage.exe executable is not in my PATH, so it cannot be found.

I managed to work around the first issue by assigning an explicit name to my VM. This name is currently hardwired in my Vagrantfile. Also, I noticed that VirtualBox defines an environment variable VBOX_MSI_INSTALL_PATH, which points to the VirtualBox binaries.

Finally, I had to handle some issues around shell quoting with Windows - this one was easily fixed by using the run and args options instead of inline.

So here is my code:

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure(2) do |config|

  # Use Debian Stretch - I had issues with package hashes when using Python 3 on Debian Buster = "debian/contrib-stretch64"

  config.vm.hostname = "zoombox.localdomain" "private_network", ip: ""
  config.ssh.forward_x11 = true

  config.vm.provider "virtualbox" do |vb|
    vb.memory = "1024"
    # "--natdnshostresolver1": Fix DNS for use with VPN tunnel,
    # see
    # Most of the other options are courtesy of 
    vb.customize [ "modifyvm", :id,
      "--name", "zoombox",
      "--vram", "256",
      "--accelerate3d", "on",
      "--clipboard", "bidirectional",
      "--hwvirtex", "on",
      "--nestedpaging", "on",
      "--largepages", "on",
      "--ioapic", "on",
      "--pae", "on",
      "--paravirtprovider", "kvm",
      "--natdnshostresolver1", "on",
      "--usb", "on",
      "--usbehci", "on",      

  config.vm.provision :shell do |s|
    s.path = File.join( Dir.pwd, "" )
  config.trigger.after [ :up, :reload ] do |t|
    # Attach webcam
    # Possible upgrade: Select webcam instead of just using the default ".0",
    # like using [VBoxManage list webcams]
    # Note: VirtualBox does not put itself into PATH and I don't wnat to change that,
    # so read out the install path from the corresponding environment variable = "Mount webcam to \"zoombox\". VirtualBox path is #{ENV['VBOX_MSI_INSTALL_PATH']}" = {
      path: File.join( ENV['VBOX_MSI_INSTALL_PATH'], "VBoxManage.exe" ),
      args: [ "controlvm", "zoombox", "webcam", "attach", ".0" ],


This can also be found in my GitHub repo.