My Current Go-Dev Dockerfile for Microsandbox

In the previous Spotlight article, I examined Microsandbox1, a microVM with a straightforward CLI and a Go SDK, for running Go development tasks in a microVM. Why using a microVM? In short, a microVM is an excellent security enhancement, as it isolates guest processes from the host system at a fairly high level and with a very small attack surface compared to other approaches like containers or hypervisors (only topped by WASM and isolates, but these cater to a different category of use cases).2

The beginnings

My initial approach was about getting quick results, so I created the microVM with a standard image (ubuntu) and installed everything I needed through a setup script, after creating a VM instance. Then I could stop and start that instance without having to re-run this script. However, whenever I had to create a new instance because I needed an instance mounted to another local folder, I had to run the script again.

From script to Dockerfile

At one point, this became annoying enough to push the setup step to an earlier point in the setup chain: I turned the script into a Dockerfile and generated a custom image. Now I only have to rebuild the image when my setup changes. I can create a new Microsandbox VM in a second and start working with it right away.

Here is a quick rundown of the current Dockerfile:

I start with the usual FROM <image> line. I use Ubuntu because that’s what I used for my initial experiments; I could try OS images with a smaller footprint like Alpine but that’s not a top priority for me at the moment. The DEBIAN_FRONTEND and TZ settings allow install steps to run through without user interactions and

FROM ubuntu:latest

ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Berlin

The default user in a Dockerfile is root. That is, when you run commands like RUN, they are executed on the base image as root. I use this to do the installations that require root rights:

# Install things as user root

RUN apt-get update && \
    apt-get install -y curl && \
    GO_VERSION=$(curl -Ls https://go.dev/VERSION?m=text | head -n 1) && \
    arch="$(uname -m)" && \
    case $arch in arm64|aarch64) arch=arm64 ;; esac && \
    case $arch in x86-64|x86_64) arch=amd64 ;; esac && \
    curl -so /tmp/go.tar.gz -L https://go.dev/dl/${GO_VERSION}.linux-$arch.tar.gz && \
    tar -C /usr/local -xzf /tmp/go.tar.gz && \
    rm /tmp/go.tar.gz && \
    apt-get install -y neovim --no-install-recommends && \
    apt-get install -y git && \
    apt-get install -y gcc && \
    rm -rf /var/lib/apt/lists/*

With these tools in place, I can swith to a non-root user and continue the setup. I name my user ubuntu. Then I set up a few environment settings and add some paths to $PATH that some installation scripts don’t add by their own.

# Do the rest as user ubuntu

ENV HOME=/home/ubuntu
USER ubuntu
WORKDIR /home/ubuntu

ENV PATH=/usr/local/go/bin:$PATH

RUN echo 'export PATH=$PATH:/usr/local/go/bin:/home/ubuntu/go/bin' >> /home/ubuntu/.bashrc && \
    echo 'export PATH=$PATH:$HOME/.local/bin' >> /home/ubuntu/.bashrc \
    echo 'export PATH=$PATH:$HOME/.local/share/opencode/bin' >> /home/ubuntu/.bashrc \
    echo 'export COLORTERM=truecolor' >> /home/ubuntu/.bashrc

# expected by OpenCode (but non-existant in the base image):
RUN mkdir -p /home/ubuntu/.local/share 

Now, the Dockerfile can run all install scripts that can (or need to) run in userspace. I always install a bunch of useful Go tools and also one or two AI agent harnesses. The uvx tool often comes in handy for running tools from the Python ecosystem without installing them; it’s included in the uv package.

My Go tool selection usually includes tools for checking code: gopls, govulncheck, gosec, staticcheck, and more.

# Install OpenCode, Claude, uv

RUN curl -fsSL https://opencode.ai/install | bash
RUN curl -fsSL https://claude.ai/install.sh | bash
RUN curl -fsSL https://astral.sh/uv/install.sh | bash

# Install Go tools
RUN curl -SfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.12.2 && \
    go install golang.org/x/tools/gopls@latest && \
    go install golang.org/x/vuln/cmd/govulncheck@latest && \
    go install github.com/securego/gosec/v2/cmd/gosec@latest && \
    go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \
    go install github.com/go-critic/go-critic/cmd/go-critic@latest && \
    go install honnef.co/go/tools/cmd/staticcheck@latest && \
    go install github.com/fzipp/gocyclo/cmd/gocyclo@latest && \
    go install github.com/samber/godig/cmd/godig@latest && \
    go clean -cache && go clean -modcache

Finally, I set the workspace to the directory that the Dockerfile bind-mounted to the current local directory earlier, and have the microVM invoke bash on startup.

WORKDIR /home/ubuntu/workspace

CMD ["bash"]

From Dockerfile to Microsandbox image

Now, there are only a few steps between this point and a working Microsandbox image. Typically, you would build the image from the Dockerfile, upload the image to a registry, and create a Microsandbox VM with that image.

But not everyone has access to a remote container registry service (or doesn’t want to). Luckily, both Docker and Microsandbox allow creating and consuming local container images.

Here are the steps to build a Microsandbox image locally:

1. Build the container image

Run

docker build --tag go-dev .

(If the latest changes don’t seem to show up in the image, docker might have skipped a cached step; you can see this in the Docker build output. In this case, add the --no-cache flag.)

2. Transfer the image from Docker to Microsandbox

Microsandbox (at version 0.6.17) cannot see images of a local Docker installation. To enable access, I save the image from Docker into a tar file and load this file into Microsandbox.

docker image save go-dev:latest -o go.dev.tar
msb load -i go.dev.tar

These steps take a few seconds, depending on the image size.

3 Instantiate a microVM

Now you can instantiate a new microVM with a bind-mount to the current local directory and some other settings as desired.

The following example creates a VM with name myrepo and a bind mount (-v) to the current file, default user ubuntu, six CPUs at max and 4G memory at max, and a secret GITHUB_TOKEN environment variable read from gopass (that has to be unlocked beforehand).

A word on secrets: Secrets are a security measure to avoid exposing sensible environment variables to the guest system. Instead, processes inside the mivroVM only see a dummy value. When they send a request containing that dummy value, Microsandbox replaces it on the fly with the actual value taken from an environment variable set on the host system. A smart and quite efficient security measure! In the following example, I set a GITHUB_TOKEN secret that commands can use to interact with GitHub.

Tip: for the export command, I usually don’t paste the actual token to the command, to avoid that it is saved to the Bash history.

Putting a space before the export command usually prevents it from being added to the history; however, this is easy to forget, so I use a secrets manager named gopass to inject the secret like so: export GITHUB_TOKEN=$(gopass github_token). This works with any secrets manager that has a suitable CLI command for printing out secrets (and that is unlocked when the export command runs).

cd myrepo
 export GITHUB_TOKEN=...

msb create --name myrepo \
	--secret [email protected] \
  -u ubuntu \
  -w /home/ubuntu/workspace \
  -v "$(pwd)":/home/ubuntu/workspace \
  -c 6 \
  -m 4G \
  go-dev

Done! Now the Microsandbox instance is ready to use.

> msb exec qm
ubuntu@qm:~/workspace$ whoami
ubuntu
ubuntu@qm:~/workspace$ go version
go version go1.27.0 linux/arm64
ubuntu@qm:~/workspace$