I love reproducible development environments, but not for the reasons most people do.
I ❤️open source; both as a vehicle for self-learning and a way to contribute to the wider community. But as the parent to a five-year-old son my development habits look something like this:
- Sneak a backend PR in early in the morning on my Windows machine.
- Tweak some frontend code on a Macbook from a coffee shop while kiddo is at an extracurricular activity.
- Write some documentation on an iPad while kiddo trying to finish a Technic set. (Technic is hard.)
Development Containers are a great tool for me to shove my tools into code and move it between operating systems and machines (or even up into the cloud!). This allows me to start work in one place, put it down when life gets busy, and pick it up somewhere – without needing to spend unnecessary time installing the latest Python or Go version or searching for that one VSCode plugin.
My day job is as a Platform Engineer with a focus on infrastructure and developer experience. We don't use development containers. I don't think this is a particularly unusual – and this could could be for a whole bunch of really good reasons! There are more important priorities, production is 🔥, your Kubernetes cluster's racking up extended support costs because you haven’t upgraded, etc, etc, ad nauseam.
Even so, I remember a conversation at an old startup: What if we had a disk image with our development tools pre-installed and configured? Development containers are a perfect way of achieving this goal with only a fraction of the overhead. And here's where I think my personal experience can help folks development container curious.
A Case Study
Let's say you're building a Golang CLI that is vaguely YAML-adjacent. You set out to use a reasonably modern toolchain:
- Golang (as modern a version as possible)
- golangci-lint so you're following best practices
- pre-commit to enforce consistent styling/linting.
We can build ourselves a development environment with just three files:
Dockerfile
The Dockerfile is (obviously) the beating ❤️ of your development container. You have three broad choices when building your Dockerfile:
- Use a language-specific base image like
mcr.microsoft.com/devcontainers/go:1-1.23
to get started as quickly as possible. Some development containers also include handy bundled tooling (i.e. thego
images havenvm
pre-installed to make full-stack development a breeze). - Use a base image like
mcr.microsoft.com/devcontainers/base:debian-12
and configure your programming languages and tools from scratch. This is particularly useful if you have a very specific dependencies, like to use tooling manage multiple versions (e.g.pyenv
oruv
while writing a Python library), or like to live on the bleeding edge ("official" language-specific base images can lag behind by several months).
Additionally you can use development container features (community-owned configuration scripts) to further customize your container. This is particularly useful for complicated dependencies such as Docker. However, features are installed when the development container is created by your IDE so you won't reap some inherent benefits like caching pre-built images in an image registry or using your development container in CI/CD.
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/blob/main/containers/go/.devcontainer/Dockerfile
ARG BASE="debian-12"
FROM mcr.microsoft.com/devcontainers/base:${BASE}
# Install standard packages
RUN apt-get clean && \
apt-get update && \
# General purpose tools
apt-get install -y \
# Python
libsasl2-dev libldap2-dev libssl-dev libsnmp-dev libffi-dev \
libncurses-dev libsqlite3-dev libbz2-dev libreadline-dev liblzma-dev tzdata
# Set general-purpose environment variables.
ENV HOME="/home/vscode"
ENV UV_CACHE_DIR="/home/vscode/.uv_cache"
# Switch to non-root user and home directory.
USER vscode
WORKDIR /home/vscode
# Install and configure Golang
ARG GO_VERSION=go1.24.0.linux-amd64.tar.gz
ARG GO_VERSION_HASH=dea9ca38a0b852a74e81c26134671af7c0fbe65d81b0dc1c5bfe22cf7d4c8858
RUN cd /home/vscode && curl -OL https://golang.org/dl/${GO_VERSION} && \
echo "${GO_VERSION_HASH} *${GO_VERSION}" | sha256sum -c - && \
tar -zxf ${GO_VERSION} && \
rm ${GO_VERSION}
RUN mkdir -p /home/vscode/go-path
RUN mkdir -p /home/vscode/go
ENV GOPATH=/home/vscode/go-path
ENV GOROOT=/home/vscode/go
ENV PATH=${PATH}:/usr/local/bin:$GOROOT/bin:$GOPATH/bin
ENV GOTOOLCHAIN=auto
# Install and configure Python
COPY --from=ghcr.io/astral-sh/uv:debian /usr/local/bin/uv /usr/local/bin
COPY --from=ghcr.io/astral-sh/uv:debian /usr/local/bin/uvx /usr/local/bin
ARG PYTHON_VERSION=3.13
RUN uv python install ${PYTHON_VERSION}
ENV UV_LINK_MODE=copy
# Install CLI tools
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d
# Install Go tools
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
RUN go install github.com/jstemmer/go-junit-report/v2@latest
A do-it-yourself Dockerfile
devcontainer.json
The devcontainer.json
is where you can customize your development container further (surprise! 🤯) but also set up your IDE with sensible defaults. This is particularly useful in languages where connecting IDE settings to tools installed through Docker or a language's own package management tools. Or to put another way: this solves the "How do I get VSCode to ruff
installed in my virtual environment?"
A really great feature is adding a postCreateCommand
to point at a local shell script for any configuration/setup that's needed after start up the container. We'll cover that more in the next section.
{
"name": "fullstack",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
"BASE": "debian-12",
"GO_VERSION": "go1.24.0.linux-amd64.tar.gz",
"GO_VERSION_HASH": "dea9ca38a0b852a74e81c26134671af7c0fbe65d81b0dc1c5bfe22cf7d4c8858",
"PYTHON_VERSION": "3.13"
}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"yaml.schemas": {
"https://taskfile.dev/schema.json": [
"**/Taskfile.yml",
"tasks/**"
]
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"GitHub.copilot",
"github.vscode-github-actions",
"golang.go",
"ms-azuretools.vscode-docker",
"redhat.vscode-yaml",
"tamasfe.even-better-toml",
"task.vscode-task"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash ./.devcontainer/post_install.sh",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}
Configure devcontainer & VS Code
post_install.sh
Sometimes you can't install all your tools during the Docker build step. A good example of this is the (semi) ubiquitous pre-commit tool which is a 💎 for making linting and formatting a regular part of your development workflow. A post-install script lets you automate away the "little things" that make setting up your repository a breeze.
#!/bin/bash
set -ex
# Set up Python
rm -rf /home/vscode/.uv_cache | true
mkdir /home/vscode/.uv_cache | true
uv venv
# Install pre-commit hooks
uv pip install -r requirements.txt
uv run pre-commit install
# Configure git...
# ...to do GPG code signing locally.
if [ "$CODESPACES" != "true" ]; then
echo "Running locally, configure gpg."
git config --global gpg.program gpg2
git config --global commit.gpgsign true
else
echo "Running in codespaces."
fi
# ...to automatically setup remote branches.
git config --global --add --bool push.autoSetupRemote true
# Woohoo!
echo "Hooray, it's done!"
Pulling It All Together
To be level with everyone: I've tried pitching development containers at different jobs and never been particularly successful. There's been some interesting developments recently that make development containers less of a "Github.com/Microsoft only" technology like Gitpod (cool feature: API driven environments) and DevPod (cool feature: work out-of-box with internal infrastructure like Kubernetes). I'm hopeful that will make devcontainers an easier pitch in the future.
But if you're doing open source or personal projects, I'd definitely recommend giving development containers a try!
You can find the code from this article in my template_go repository. Examples of using developer containers in anger can be found in rubrical (Python) and tangle (Fullstack Go/TS with k3d).
Member discussion