blog.stogas.dev

Devcontainers are pretty useful

 •  • Updated:  • development, devops

Issues with onboarding in dev projects #

We've all been there. You have a project (personal or work-related), share the git repo with someone else who wants to help. They ask:

  • "How do I set it up on my machine?"

The answer varies wildly depending on your tech stack. Let's walk through a hypothetical example to illustrate the challenges:

Onboarding example: A Javascript project #

Imagine your team has a Javascript project, and you're onboarding a new contributor. They have heard about alternative runtimes like Bun or Deno, but which one does your project actually use? Is this information in the README.md?

The package.json file lists all dependencies, but doesn't necessarily specify the runtime. It has an "engines" field for NodeJS versions, but Bun doesn't support this field yet.

{
  "engines": {
    "node": ">=18.0.0" # only with NodeJS
  },
  "packageManager": "pnpm@9.1.0" # only with pnpm & yarn
}

And which package manager should they use? Pnpm and Yarn use the packageManager field to specify their versions, but Bun and NPM largely ignore it. Should your contributor manually search the repo for package-lock.json, yarn.lock, or pnpm-lock.yaml to figure it out? Or should they check the README and hope you documented it?

And if your project uses a different NodeJS version than their other projects? Now they need a version manager like nvm to juggle multiple versions...

Onboarding example: Other dev tools #

Setting up the base toolchain is fine so far, but what happens when you need some other development tools alongside?

For example, you might use lefthook for mutual git hooks; buf for working with protobufs; ansible for deploying stuff, or anything else.

Common additional tools you might recognize, off the top of my head:

  • Git hooks: lefthook, husky
  • Protocol buffers: buf, protoc
  • Build tools: make, cmake, gradle
  • Infrastructure: terraform, ansible, kubectl
  • Linting/formatting: eslint, golangci-lint
  • Database tools: sqlc
  • Code generators: openapi/swagger generators, grpc tools

How do you ensure which versions of these tools your fellows use, and how do you coordinate upgrades for these tools in tandem?

Temporary dev environments #

There's also cases where you might want to set up the tooling for a project temporarily - you'll dive in, fix an issue or create a PR, and then you don't care about the tooling anymore and wish to get it out of your OS.

For example, I want to quickly help out in a Java project, but afterwards I want to get rid of Java from my system completely. There's ways to do this (VMs, manual containers, etc), but you'll have to do it manually.

Traditional methods of onboarding #

There's various methods of how teams onboard new contributors. Here's a few methods:

Method Pros Cons
README.md instructions Simple, universal Manual, error-prone
setup.sh script Automated OS-specific, hard to maintain
Prerequisites check script Validates environment Doesn't fix issues
Individual onboarding Personalized help Doesn't scale

You can probably come up with at least one or two drawbacks of your own to each and every one of these methods yourself.

You mentioned containers for dev environments? #

There's one item from the list in the previous section I've purposefully left out, because I haven't really seen it done in the wild: write a Dockerfile which sets up an image with all your specific prerequisites and ask your teammates to work from inside a container built from that image.

We can also probably immediately come up with various reasons why this would be difficult:

  • How to make it work with any IDE your teammates prefer?
  • How will Git work, especially when you want to re-use your SSH keys, re-use your SSH agent, and sign commits?
  • How to allow running things like npm run dev, go run . and make localhost access work seamlessly just like without containers?
  • Would I have to constantly run Docker commands to start it up?
  • Will we be forced to use this container all the time? Could I change project files, exit the container, and continue work with my local tools on the same modified files? (Let's call this "workspace sync" for brevity)
  • Will the containers have to be rebuild every time I open a project? That can take some time and interrupt my workflow

Devcontainers #

Devcontainers1 2 come into the picture by aiming to answer and solve all of the questions above.

You can read the official website at https://containers.dev/, but in short, devcontainers provide a specification on how such a "container image for development" can integrate with IDEs and your development flow as seamlessly as possible.

To highlight some generic answers to the questions above:

  • VSCode3, IntelliJ IDEA, Cursor (more or less) all support devcontainers, and on other workflows, you can use the devcontainers-cli4. There's also further integrations with any IDE through devpods5
  • Devcontainer integrations come with seamless SSH credential usage from within the containers (at least in VSCode and IntelliJ)
  • You can specify default port forwarding in the configuration file, but some IDEs offer forwarding automatically
  • No need to run Docker commands manually - just let your IDE do that for you behind the scenes
  • Your project files are synchronized between the container and your host automatically
  • Through caching and re-using existing containers, you can minimize the startup time for devcontainers. Add in pre-built images, and you never rebuild - updates are just an automatic pull away

Just show me how to use it #

This section works with a very simple React project. We want to provide a smooth onboarding sequence:

  • Use NodeJS 24.9.0;
  • Install dependencies when starting up the devcontainer - they might change every week;

Devcontainers can set up much more complicated projects - this is intended to be just a very simple example. Think - setting up legacy projects, running unfamiliar languages, installing a bunch of other tools, etc.

Adding a devcontainer to a project #

Prerequisites:

  • Docker
  • IDE that works with devcontainers (in this example - VSCode with the Dev Containers extension6)
  • Existing git repository for a project

Add this content to a new .devcontainer/devcontainer.json file:

{
	"name": "Example React Devcontainer",
	"image": "node:24.9.0-bookworm-slim", // NodeJS 24.9.0
	"remoteUser": "node",
	"postStartCommand": "npm install" // install dependencies
}

Modify vite.config.ts to listen on any interface, not just localhost (localhost didn't work for me, at least):

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: { host: '0.0.0.0' } // this is meant to be used in a devcontainer. Change to 'localhost' if you don't use a devcontainer.
});

That's it! Commit this to Git!

Using a devcontainer in a project (onboarding new people) #

The container might take a bit longer to start the first time, but the overall flow is exactly the same every time:

  1. Have Docker & Dev Containers extension preinstalled in VSCode;
  2. Open project;
  3. Click on the "Reopen in container" notification (or use the "Dev Containers: Reopen in Container" command manually);
  4. Enjoy!

This video shows the full developer onboarding experience for setting up and running the project.

In this basic example, setting up NodeJS 24 locally would not be much of a hassle either.

But would it be this easy if, for a single project, you would have to set up Go, NodeJS, and a bunch of other tools at the same time?

With devcontainers - it would!

Additional devcontainer.json example: Go with React via pnpm #

Here's a more complex devcontainer I've used for a project - it sets up Go + NodeJS with pnpm:

devcontainer.json
{
	"name": "Project devcontainer",
	"build": {
		"dockerfile": "Dockerfile",
		"args": {
			"GO_VERSION": "1.25.3",
			"GOLANGCI_LINT_VERSION": "2.5.0",
			"NODE_VERSION": "24.10.0"
		}
	},
	"remoteUser": "developer",
	"containerUser": "developer",
	"customizations": {
		"vscode": {
			"settings": {
				"terminal.integrated.defaultProfile.linux": "bash"
			},
			"extensions": [
				"pkief.material-icon-theme", // Material Icon Theme
				"golang.go", // Go
				"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense
				"github.vscode-github-actions", // GitHub Actions
				"dbaeumer.vscode-eslint", // ESLint
				"redhat.vscode-yaml", // YAML
				"ms-azuretools.vscode-docker" // Docker
			]
		}
	},
	"forwardPorts": [
		8080,
		5173
	],
	"portsAttributes": {
		"8080": {
			"label": "API (backend)",
			"onAutoForward": "notify"
		},
		"5173": {
			"label": "App (frontend)",
			"onAutoForward": "notify"
		}
	},
	"containerEnv": {
		"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"
	},
	"onCreateCommand": "sudo chown -R developer:developer /workspaces && sudo npm install -g --no-update-notifier corepack && corepack enable",
	"updateContentCommand": "go install golang.org/x/vuln/cmd/govulncheck@latest",
	"postCreateCommand": {
		"go dependencies": "cd backend/ && go mod download",
		"js dependencies": "cd frontend/ && corepack install && pnpm install"
	},
	"postStartCommand": {},
	"postAttachCommand": "sudo chown -R developer:developer /workspaces"
}

And the Dockerfile for it:

Dockerfile
# any ARG values are default, override via build args in devcontainer.json instead
ARG NODE_VERSION=24.10.0

FROM alpine:3 AS downloader

ARG TARGETARCH TARGETOS

RUN apk add --no-cache curl xz tar

# installs to /usr/local/other-utils
# changest most rarely, so is installed first
RUN mkdir /usr/local/other-utils
# sqlc
RUN LATEST_SQLC=$(curl -s https://api.github.com/repos/sqlc-dev/sqlc/releases/latest | grep '"tag_name":' | cut -d'"' -f4 | sed 's/^v//') \
	&& SQLC_DOWNLOAD="sqlc_${LATEST_SQLC}_${TARGETOS}_${TARGETARCH}.tar.gz" \
	&& curl -LO "https://github.com/sqlc-dev/sqlc/releases/download/v${LATEST_SQLC}/${SQLC_DOWNLOAD}" \
	&& tar -xf "${SQLC_DOWNLOAD}" -C /usr/local/other-utils "sqlc"
# golangci-lint
ARG GOLANGCI_LINT_VERSION=2.5.0
RUN GOLANGCI_LINT_DOWNLOAD="golangci-lint-${GOLANGCI_LINT_VERSION}-${TARGETOS}-${TARGETARCH}" \
	&& curl -LO "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${GOLANGCI_LINT_DOWNLOAD}.tar.gz" \
	&& tar -xf "${GOLANGCI_LINT_DOWNLOAD}.tar.gz" -C /usr/local/other-utils --strip-components=1 "${GOLANGCI_LINT_DOWNLOAD}/golangci-lint"
# lefthook
RUN LATEST_LEFTHOOK=$(curl -s https://api.github.com/repos/evilmartians/lefthook/releases/latest | grep '"tag_name":' | cut -d'"' -f4 | sed 's/^v//') \
	&& case "$TARGETARCH" in \
	amd64) LEFTHOOK_ARCH="x86_64" ;; \
	arm64) LEFTHOOK_ARCH="aarch64" ;; \
	*) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \
	esac \
	&& LEFTHOOK_DOWNLOAD="lefthook_${LATEST_LEFTHOOK}_${TARGETOS}_${LEFTHOOK_ARCH}.gz" \
	&& curl -LO "https://github.com/evilmartians/lefthook/releases/download/v${LATEST_LEFTHOOK}/${LEFTHOOK_DOWNLOAD}" \
	&& gunzip -c "${LEFTHOOK_DOWNLOAD}" > /usr/local/other-utils/lefthook \
	&& chmod +x /usr/local/other-utils/lefthook
# buf
RUN LATEST_BUF=$(curl -s https://api.github.com/repos/bufbuild/buf/releases/latest | grep '"tag_name":' | cut -d'"' -f4 | sed 's/^v//') \
	&& case "$TARGETARCH" in \
	amd64) BUF_ARCH="x86_64" ;; \
	arm64) BUF_ARCH="aarch64" ;; \
	*) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \
	esac \
	&& BUF_OS="$(echo "$TARGETOS" | awk '{print toupper(substr($0,1,1)) tolower(substr($0,2))}')" \
	&& BUF_DOWNLOAD="buf-${BUF_OS}-${BUF_ARCH}.tar.gz" \
	&& curl -LO "https://github.com/bufbuild/buf/releases/download/v${LATEST_BUF}/${BUF_DOWNLOAD}" \
	&& tar -xf "${BUF_DOWNLOAD}" -C /usr/local/other-utils --strip-components=2 buf/bin/buf buf/bin/protoc-gen-buf-breaking buf/bin/protoc-gen-buf-lint \
	&& chmod +x /usr/local/other-utils/buf /usr/local/other-utils/protoc-gen-buf-breaking /usr/local/other-utils/protoc-gen-buf-lint

# installs to /usr/local/go
# changes most often, so is installed last
ARG GO_VERSION=1.25.3
RUN GO_DOWNLOAD="go${GO_VERSION}.${TARGETOS}-${TARGETARCH}" \
	&& curl -LO "https://go.dev/dl/${GO_DOWNLOAD}.tar.gz" \
	&& tar -xf "${GO_DOWNLOAD}.tar.gz" -C /usr/local

# we use Debian because NodeJS performs awful on Alpine Musl libc
FROM node:${NODE_VERSION}-bookworm-slim AS runner

RUN apt-get update && apt-get install -y sudo bash git openssh-client curl nano jq gnupg2 && rm -rf /var/lib/apt/lists/*

RUN useradd -m -s /bin/bash developer\
	&& echo "developer ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# Go & others
COPY --from=downloader /usr/local/other-utils /usr/local/other-utils
COPY --from=downloader /usr/local/go /usr/local/go

USER developer
ENV PATH=$PATH:/home/developer/go/bin:/usr/local/go/bin:/usr/local/other-utils

Drawbacks #

No thing is without drawbacks, however. There's a few with devcontainers too:

  • Not all IDEs have native integrations (haven't checked them all, but Zed is a notable example).
  • Container performance might be subpar on Windows/MacOS. This system simply runs Docker containers, so performance is exactly as with those (including any future improvements). Naturally, Linux experiences basically native performance.
  • You have to support your devcontainer for different platforms. Notably, I found file permission edge cases on Linux via Docker, which are not reproducable on Windows via Docker due to differences in runtime.

In my opinion, none of these are major enough to stop you from at least trying devcontainers.

Finishing words #

Devcontainers represent a significant step forward in standardizing development environments. They solve real problems that many development teams face daily: inconsistent tooling, complex onboarding, and the "works on my machine" syndrome.

While they may not be the perfect solution for every team or project, devcontainers offer a "new" approach to environment management using the same old containerization principles we already use in production.

If you're working on a project with multiple contributors, or if you frequently switch between projects or even laptops, I'd encourage you to give devcontainers a try.