Introduction

There are times when I just don't want to mess with virtual environments or Conda etc. My main Linux machine, right now, is far from being a beast, so I need to keep it lean. Sure docker's fine, and I've used that plenty, but sometimes it's useful to replicate something like a server you're going to start with when you spin one up in the cloud or with a VM provider, which is where Vagrant comes in. Highly repeatable, enjoyably destructible, and you can use it to test out anything you would like to do with a server, without having to worry about messing up your main machine.

Prerequisites

VirtualBox & Vagrant installation

Pretty horrible looking website but it's a decent product: VirtualBox, follow the link to your OS; For Windows & MacOS it'll download an installer. As I write this (April '24), the Linux instructions still point to version 6.1, but 7.0 is available either via your package manager or if you go to download.virtualbox.org/virtualbox/.

If you're on Linux Mint, remember to point at the jammy repo, not the lsb_release one, which is virginia in my case. these instructions should work on Mint or Ubuntu:

VirtualBox installation

# Get the correct codename
UBUNTU_CODENAME=$(grep 'UBUNTU_CODENAME' /etc/os-release | cut -d= -f2)

# Add the repo
echo "deb [signed-by=/usr/share/keyrings/virtualbox-archive-keyring.gpg] https://download.virtualbox.org/virtualbox/debian $UBUNTU_CODENAME contrib" | sudo tee /etc/apt/sources.list.d/virtualbox.list

# Get the key
wget -q https://www.virtualbox.org/download/oracle_vbox_2016.asc -O- | sudo gpg --dearmor -o /usr/share/keyrings/virtualbox-archive-keyring.gpg

# Update the package list
sudo apt update

# See available versions
apt-cache search virtualbox | grep "Oracle VM VirtualBox"

# Install the latest version
sudo apt install virtualbox

Vagrant installation

# Get the correct codename
UBUNTU_CODENAME=$(grep 'UBUNTU_CODENAME' /etc/os-release | cut -d= -f2)

# Add the 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 $UBUNTU_CODENAME main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

# Update the package list and install
sudo apt update && sudo apt install vagrant

Guest Additions & Vagrant plugins

I actually found that the plugin that is supposed to install the guest additions made the machine build hang, I think I know why but it seemed easier to install it by other means, so I removed the plugin and will install them as part of the scripted build below. To enable this I downloaded guest additions into the same directory as I'm running Vagrant from. You can download it Guest Additions as follows:

# Fetch the latest VirtualBox version & build the URL
LATEST_VB_VERSION=$(curl -s https://download.virtualbox.org/virtualbox/LATEST.TXT)
GUEST_ADDITIONS_URL="https://download.virtualbox.org/virtualbox/${LATEST_VB_VERSION}/VBoxGuestAdditions_${LATEST_VB_VERSION}.iso"

# Download the Guest Additions ISO
curl -O $GUEST_ADDITIONS_URL -o VBoxGuestAdditions.iso

I'm only using the disksize plugin in this example, which is useful for when you need to increase the size of the disk that comes with the image. You can install it like this:

vagrant plugin install vagrant-disksize

Setting up your environment

To run this as I am, you'll need to have a directory with the following files in it:

  • Vagrantfile
  • config.yml
  • bootstrap-ubuntu.sh
  • VBoxGuestAdditions.iso

Here's a quick command to make the files:

touch Vagrantfile config.yml bootstrap-ubuntu.sh

Now that you're all set up, let's take a look at the files you've just created.

Vagrantfile

There's a few things to point out about this file:

  • Don't worry too much about getting your networking right at first, if it can't find the network you specify, it'll prompt you to choose one during build, it usually makes sense which your active connection is, then you can replace "en7: USB 10/100/1000 LAN" with whatever yours is.
  • There is a storageattach line in the vb.customize block that presents the Guest Additions ISO to the VM. We then use an inline script to run the guest additions. I think the reason this wasn't working when it tried to automate it was that the Ubuntu image needed these packages installing: bzip2 gcc make perl
  • Also in the storageattach line, you may find that your --storagectl is something other than IDE, you can find out by going into the VirtualBox GUI and looking at the storage settings for the VM.
  • The ASK_CREDENTIALS variable is used to decide whether to prompt you for a username and password, when we build the machine it will look like this: ASK_CREDENTIALS=true vagrant up. if you don't want a user creating, just leave the ASK_CREDENTIALS=true part out and run vagrant up.
  • If you do create a user it will be added to the sudo group with no password required, you should adjust this is that's not how you like things.
require 'yaml'

CONFIG = YAML.load_file('config.yml')

Vagrant.configure("2") do |config|
  config.disksize.size = "50GB"
  config.ssh.insert_key = false

  config.vm.define "ubuntu-dev" do |dev|
    dev.vm.box = "ubuntu/jammy64"
    dev.vm.hostname = "ubuntu-dev"
    dev.vm.network "public_network", ip: "192.168.76.220", bridge: "en7: USB 10/100/1000 LAN"

    dev.vm.provider "virtualbox" do |vb|
      vb.memory = 8192
      vb.cpus = 4
      vb.name = "ubuntu-dev"
      vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
      vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"]
      vb.customize ["modifyvm", :id, "--graphicscontroller", "vmsvga"]
      vb.customize ["modifyvm", :id, "--vram", "180"]
      vb.customize ['storageattach', :id, '--storagectl', 'IDE', '--port', 1, '--device', 0, '--type', 'dvddrive', '--medium', File.join(File.dirname(__FILE__), "VBoxGuestAdditions.iso")]
    end

    # Provisioning to install Guest Additions
    dev.vm.provision "shell", inline: <<-SHELL
      if [ -z "$(find /opt -name VBoxGuestAdditions.run -print -quit)" ]; then
        sudo apt-get update
        sudo DEBIAN_FRONTEND=noninteractive apt install -y bzip2 gcc make perl
        sudo mkdir /media/VBoxGuestAdditions
        sudo mount -o loop /dev/cdrom /media/VBoxGuestAdditions
        sudo /media/VBoxGuestAdditions/VBoxLinuxAdditions.run ` true
        sudo umount /media/VBoxGuestAdditions
        sudo rmdir /media/VBoxGuestAdditions
      else
        echo "Guest Additions already installed."
      fi
    SHELL

    if ENV['ASK_CREDENTIALS'] == 'true'
      # Prompt the user for a username
      puts "Please enter a username for the Ubuntu user:"
      user_name = STDIN.gets.chomp

      # Prompt the user for a password
      puts "Please enter a password for the Ubuntu user:"
      user_password = STDIN.noecho(&:gets).chomp
      puts "\n"

      # Provision to create the user and configure SSH
      dev.vm.provision "shell", inline: <<-SHELL
        echo "Creating a user with the provided username and password..."
        sudo useradd -m -s /bin/bash #{user_name}
        echo "#{user_name}:#{user_password}" | sudo chpasswd

        # Configure SSH to allow password authentication for the new user
        sudo mkdir -p /home/#{user_name}/.ssh
        sudo bash -c 'echo "Match User #{user_name}" >> /etc/ssh/sshd_config'
        sudo bash -c 'echo "    PasswordAuthentication yes" >> /etc/ssh/sshd_config'
        # Add user to sudo group with nopasswd
        sudo usermod -aG sudo #{user_name}
        sudo bash -c 'echo "#{user_name} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/#{user_name}'
        sudo systemctl restart sshd
      SHELL
    end

    # Provision to copy the configuration file
    dev.vm.provision "file", source: "./config.yml", destination: "/home/vagrant/provision.conf"
    # Provision to run the bootstrap script
    dev.vm.provision "shell", path: "bootstrap-ubuntu.sh", run: "always", env: {"CONFIG_FILE" => "/home/vagrant/provision.conf"}

    # Adding direct reboot at the end of provisioning
    dev.vm.provision "shell", inline: "echo 'Rebooting now...'; sudo reboot", run: "always"
  end
end

bootstrap-ubuntu.sh

This script will take values from the config.yml file you have locally, on the server it will be written to /home/vagrant/provision.conf and then read in by the script.

#!/bin/bash

# Default configuration file
CONFIG_FILE="provision.conf"

# Function to log messages
log() {
  echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')] $@"
}

# Function for installing software on Ubuntu
install_ubuntu() {
  package_name=$1
  case $package_name in
    set_vim_editor)
      log "Setting default editor to Vim on Ubuntu..."
      sudo update-alternatives --set editor /usr/bin/vim.basic
      ;;
    pip)
      log "Installing pip on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive add-apt-repository -y universe
      sudo apt update
      sudo DEBIAN_FRONTEND=noninteractive apt install -y python3-pip
      ;;
    git)
      log "Installing Git on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive apt install -y git
      ;;
    nodejs)
      log "Installing Node.js $nodejs_version on Ubuntu..."
      curl -sL https://deb.nodesource.com/setup_$nodejs_version.x | sudo DEBIAN_FRONTEND=noninteractive -E bash -
      sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs
      ;;
    mysql)
      log "Installing MySQL on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive apt install -y mysql-server
      ;;
    docker)
      log "Installing Docker on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive apt install apt-transport-https ca-certificates curl software-properties-common
      curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
      echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
      sudo apt update
      apt-cache policy docker-ce
      sudo DEBIAN_FRONTEND=noninteractive apt install -y docker-ce
      ;;
    docker-compose)
      log "Installing Docker Compose on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive apt install -y docker-compose
      ;;
    ubuntu-desktop)
      log "Installing graphical environment on Ubuntu..."
      sudo snap install firefox
      sudo DEBIAN_FRONTEND=noninteractive apt install -y ubuntu-desktop
      ;;
    budgie-desktop)
      log "Installing Budgie Desktop on Ubuntu..."
      sudo add-apt-repository -y ppa:ubuntubudgie/backports-budgie
      sudo apt update
      sudo DEBIAN_FRONTEND=noninteractive apt install -y budgie-desktop-environment
      ;;      
    jetbrains-toolbox)
      log "Installing JetBrains Toolbox on Ubuntu..."
      sudo DEBIAN_FRONTEND=noninteractive apt install -y wget
      wget -O jetbrains-toolbox.tar.gz "https://download.jetbrains.com/toolbox/jetbrains-toolbox-1.21.9712.tar.gz"
      tar -xvf jetbrains-toolbox.tar.gz
      sudo mv jetbrains-toolbox-1.21.9712/jetbrains-toolbox /usr/local/bin
      ;;
    *)
      log "Unsupported package: $package_name"
      ;;
  esac
}

# Parse arguments
for i in "$@"
do
case $i in
  --config=*)
  CONFIG_FILE="${i#*=}"
  shift
  ;;
  *)
        # unknown option
  ;;
esac
done

# Source the configuration file
if [ -f "$CONFIG_FILE" ]; then
  source "$CONFIG_FILE"
  log "Using configuration file: $CONFIG_FILE"
else
  log "Configuration file not found: $CONFIG_FILE"
  exit 1
fi

# Detect the distribution
if [ -f /etc/os-release ]; then
  source /etc/os-release

  if [[ "$ID" == "ubuntu" ]]; then
    # Run a full update and upgrade
    sudo apt update
    sudo DEBIAN_FRONTEND=noninteractive apt full-upgrade -y
    # Unattended user creation
    sudo DEBIAN_FRONTEND=noninteractive apt install -y whois

    # Alias python to python3
    sudo DEBIAN_FRONTEND=noninteractive apt install -y python-is-python3
    # Conditional installations based on configuration
    [[ "$set_vim_editor" == "true" ]] && install_ubuntu "set_vim_editor"
    [[ "$install_pip" == "true" ]] && install_ubuntu "pip"
    [[ "$install_git" == "true" ]] && install_ubuntu "git"
    [[ "$install_nodejs" == "true" ]] && install_ubuntu "nodejs"
    [[ "$install_mysql" == "true" ]] && install_ubuntu "mysql"
    [[ "$install_docker" == "true" ]] && install_ubuntu "docker"
    [[ "$install_docker_compose" == "true" ]] && install_ubuntu "docker-compose"
    [[ "$install_ubuntu_desktop" == "true" ]] && install_ubuntu "ubuntu-desktop"
    [[ "$install_budgie_desktop" == "true" ]] && install_ubuntu "budgie-desktop"
    # Only install JetBrains Toolbox if if either Ubuntu Desktop or Budgie Desktop is installed
    [[ "$install_jetbrains_toolbox" == "true" && ("$install_ununtu_desktop" == "true" ` "$install_budgie_desktop" == "true") ]] && install_ubuntu "jetbrains-toolbox"
  else
    log "Unsupported distribution: $ID. Exiting."
    exit 1
  fi
else
  log "Unable to detect distribution. Exiting."
  exit 1
fi

log "Installation complete."

As you can see, there are a few options and it would be easy to add more by adding a block for the installation commands, a line in the conditional block, and a line in the config.yml file.

With the ubuntu-desktop installation, I found that it was hanging on a snap installation of Firefox, so I added that as a separate command prior to the desktop installation.

config.yml

This file is where you configure the software you want to install. Here's an example:

set_vim_editor=true
install_pip=true
install_git=true
install_nodejs=false
nodejs_version=20
install_mysql=false
install_docker=false
install_docker_compose=false
install_ubuntu_desktop=false
install_budgie_desktop=true
install_jetbrains_toolbox=false

Running the build

So now you should have a directory that looks something like this:

$ tree
.
├── bootstrap-ubuntu.sh
├── config.yml
├── Vagrantfile
└── VBoxGuestAdditions_7.0.14.iso

When you've made any tweaks you need to make, you can run the build like this:

# To install a user you can ssh into, run it like this and you will be prompted for a username and password
ASK_CREDENTIALS=true vagrant up

# To just build the machine (you can still use the vagrant ssh command)
vagrant up

Conclusion

This setup should give you a good starting point for a development server with a working method for an additional user to be able to connect remotely. It should pick up an IP from your home router with a bridge configuration, so if you want to do something that's going to be exposed to your home network or the internet, then you can do that, and it has an option to install extra software and a desktop environment if you want to use it like that.