Contents

Dev Containers, or How to Never Configure a Colleague's Machine Again

devcontainersdockerdeveloper-toolssshdevopsclaude-code

There’s a specific kind of workday that anyone who has been the “person who knows how the build works” will recognize. You sit down, open your editor, and then someone taps you on the shoulder: “Hey, something’s broken.” You walk over. They didn’t forward their SSH key. Or they’re on the wrong Python patch version. Or that one cursed C library never got installed. You fix it, walk back, and forty minutes later it happens again with a different person.

This post is about containerizing your development environment — not your deployment, your dev environment. What that buys you, how the pieces actually fit together, and what the sharp edges look like once you’re using it every day. There’s a companion post on Git worktrees; the two are completely independent technologies that happen to combine into something genuinely good.

What a dev container actually is

A dev container is a Docker container, but its configuration isn’t expressed purely in Docker terms. There’s an abstraction layer on top: a devcontainer.json file that describes the container plus everything around the container — which folders get mounted, which lifecycle scripts run, which extra “features” get layered in, what happens on first create versus every start.

You’ll recognize a lot of Docker logic in there. But you’ll also notice there’s no Dockerfile at the top level of the config — the devcontainer.json points at one (or at an image, or at a Compose file), and then handles the rest.

The spec started at Microsoft, built for VS Code, and was later published as the open Development Containers Specification. VS Code has genuinely first-class support: drop a .devcontainer/ folder in your repo, open the project, and it says “hey, this is a dev container, want me to reopen inside it?” Under the hood that’s basically an SSH-style remote connection, except the remote is a container on your own machine. But the spec is no longer VS Code-only — there’s a standalone @devcontainers/cli, which is what I actually use, because I live in the terminal.

The setup, end to end

Here’s the whole flow for a brand-new project. I keep my dev container base in my dotfiles repo, which is public, so feel free to steal any of it.

1. Copy the base in with degit

degit copies a subdirectory out of a GitHub repo. Not clone — copy. No history, no .git, just the current state of those files:

git init
npx degit petr-nazarov/dotfiles/.devcontainer/base .devcontainer/base

Now you have a .devcontainer/base/ folder in your project. Note the structure — everything lives under base/, and anything outside base/ is yours. That split matters: when I ship an update to the base, you can re-run degit to pull it in without clobbering your own project-specific tweaks.

I commit .devcontainer/ to the repo. It’s more useful if your teammates can use it too.

2. Run init

.devcontainer/base/host-scripts/init.sh

Note the folder: host-scripts/. These are the scripts you run on your machine, not inside the container. init.sh does two small things — copies base/devcontainer.json up to .devcontainer/devcontainer.json (your project-local, editable copy), and makes sure the dev container CLI is installed:

#!/bin/zsh
cp -p .devcontainer/base/devcontainer.json ./.devcontainer/devcontainer.json

if [ -f mise.toml ]; then
  mise use devcontainer-cli
else
  npm i -g @devcontainers/cli
fi

I install it through mise because mise is my package manager of choice — it manages language runtimes (Node, Python, and so on) as well as CLI tools. Swap that line for apt, brew, whatever you prefer. Nothing here is load-bearing.

3. The Dockerfile

Boring, and that’s the point:

FROM mcr.microsoft.com/devcontainers/base:ubuntu
COPY --from=jdxcode/mise /usr/local/bin/mise /usr/local/bin/

RUN apt-get update && apt-get install -y --no-install-recommends \
  stow \
  neovim \
  jq \
  rclone \
  fzf \
  btop \
  tmuxinator

Microsoft’s devcontainers/base:ubuntu image, mise copied in from its official image, and then whatever tools I can’t live without. This list is entirely personal — put your own in. And if you’re customizing, add a second Dockerfile outside base/ rather than editing this one, for the same update-safety reason as above.

The interesting part: devcontainer.json

This is where the actual decisions live. It looks intimidating and it really isn’t.

{
  "build": {
    "context": "..",
    "dockerfile": "base/Dockerfile"
  },
  "mounts": [
    "source=${localWorkspaceFolder}/.devcontainer/configs/ssh.config,target=/home/vscode/.ssh/config,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.ssh/example.pub,target=/home/vscode/.ssh/example.pub,type=bind,consistency=cached",
    "source=${localEnv:HOME}/dotfiles,target=/home/vscode/dotfiles,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.claude/,target=/home/vscode/.claude/,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.config/ccstatusline/,target=/home/vscode/.config/ccstatusline/,type=bind,consistency=cached",
    "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind"
  ],
  "runArgs": ["--network=host"],
  "containerEnv": { "SSH_AUTH_SOCK": "/ssh-agent" },
  "features": {
    "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false },
    "ghcr.io/devcontainers/features/node:1": { "version": "lts" }
  },
  "initializeCommand": "mkdir -p $HOME/dotfiles $HOME/.claude $HOME/.config/ccstatusline && touch $HOME/.claude.json",
  "postStartCommand": ".devcontainer/base/scripts/post-start.sh",
  "postCreateCommand": ".devcontainer/base/scripts/post-create.sh"
}

Let’s go through it, because every line here is a decision you might make differently.

Getting your Git access inside

This is the first real problem you hit. Your SSH keys live on your host. Inside the container, by default, you have none — the keys aren’t mounted, nothing is forwarded, the container is simply unaware they exist. So git pull fails.

The clean answer is ssh-agent. The agent holds your unlocked private keys in memory and performs signing operations on request. Clients — including clients on the far side of an SSH connection, or in a container — ask the agent to sign; they never see the key material. That’s the whole trick: your private keys stay on the host, and the container just gets a socket to talk to.

A small aside: if you’ve ever noticed a mysterious agent socket appear inside ~/.ssh and wondered whether you’d been hacked — no, that’s ssh-agent. It’s fine.

Two lines wire this up:

"mounts": ["source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind"],
"containerEnv": { "SSH_AUTH_SOCK": "/ssh-agent" }

Mount the host’s agent socket in, then tell the container’s SSH client where to find it.

But the socket needs a stable path, and by default it isn’t stable. That’s handled on the host side, in base.sh, before the container comes up:

export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/ssh-agent.socket"

if [[ ! -S "$SSH_AUTH_SOCK" ]]; then
    ssh-agent -D -a "$SSH_AUTH_SOCK" &
    sleep 0.5
fi

loaded_fingerprints="$(ssh-add -l 2>/dev/null | awk '{print $2}')"
for key in ~/.ssh/*; do
    case "$(basename "$key")" in
        *.pub|config|known_hosts|known_hosts.old|authorized_keys) continue ;;
    esac
    [[ -f "$key" ]] || continue
    head -c 64 "$key" 2>/dev/null | grep -q "PRIVATE KEY" || continue

    fingerprint="$(ssh-keygen -lf "$key" 2>/dev/null | awk '{print $2}')"
    if [[ -n "$fingerprint" ]] && grep -qF "$fingerprint" <<< "$loaded_fingerprints"; then
        continue
    fi
    ssh-add "$key" 2>/dev/null || true
done

Fixed socket path, then load every private key that isn’t already in the agent. The fingerprint check exists so you don’t get re-prompted for passphrases on every container launch, while still picking up newly added keys.

The multiple-identities annoyance

I work with several companies and keep a separate key per client. ssh-add with no arguments only picks up the conventional default names (id_rsa, id_ed25519, and friends), which is why the loop above walks the whole directory.

But there’s a second wrinkle, and it’s the single most irritating part of this setup. Inside the container, ssh needs some file on disk at IdentityFile to know which identity to request from the agent for a given host — especially with IdentitiesOnly yes. That file only needs to be the public key, which isn’t secret, so mounting it costs you nothing security-wise. But Docker mount sources aren’t glob-expanded, so there’s no *.pub shortcut. Every identity gets its own explicit line:

"source=${localEnv:HOME}/.ssh/example.pub,target=/home/vscode/.ssh/example.pub,type=bind,consistency=cached"

Paired with a Host block in a project-local configs/ssh.config that also gets mounted in:

Host example
    HostName github.com
    IdentityFile ~/.ssh/example
    IdentitiesOnly yes

Copy the mount line once per key. It’s tedious. It’s also the worst thing about this setup, which tells you the setup is fine.

Your dotfiles come with you

I want my environment to be my environment — aliases, zsh config, keybindings, all of it. So I mount my dotfiles repo straight in:

"source=${localEnv:HOME}/dotfiles,target=/home/vscode/dotfiles,type=bind,consistency=cached"

And there’s an escape hatch if you don’t want that. The base ships a minimal set of dotfiles — the bare minimum I consider unlivable-without. The apply script checks whether real dotfiles got mounted and falls back:

#!/bin/zsh
DOTFILES_DIR="$HOME/dotfiles"

if [[ -d "$DOTFILES_DIR" && -x "$DOTFILES_DIR/install.sh" ]]; then
	echo "Applying host dotfiles from $DOTFILES_DIR"
	cd "$DOTFILES_DIR" && ./install.sh
else
	echo "No dotfiles found, applying defaults"
	./.devcontainer/base/dotfiles/install.sh
fi

This assumes your dotfiles repo has an executable install.sh at its root, which is a pretty common convention. The default fallback is deliberately tiny:

rm -f ~/.zshrc
cp ./.devcontainer/base/dotfiles/.zshrc ~/.zshrc
rm -f ~/.gitconfig
cp ./.devcontainer/base/dotfiles/.gitconfig ~/.gitconfig

The rm -f before the cp is deliberate. If you don’t delete the existing .zshrc first, zsh’s first-run wizard has already created one, and things break in confusing ways. Nuke it, then override cleanly.

And the default .zshrc is two things:

if command -v mise >/dev/null 2>&1; then
	eval "$(mise activate zsh)"
fi

eval "$(starship init zsh)"

mise activation, because I use mise for everything, and starship for the prompt — which, usefully, shows you your current directory, your Git branch, and a marker telling you you’re inside Docker. When you have five terminals open you want that.

Bringing your AI coding agent’s auth along

Here you have a genuine fork in the road.

Option one: you want the container to use the same Claude Code login and settings you use on the host. Then you mount two things — a directory and a file:

"source=${localEnv:HOME}/.claude/,target=/home/vscode/.claude/,type=bind,consistency=cached",
"source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached"

Claude splits its auth across ~/.claude/ and ~/.claude.json. You probably didn’t know that second file existed. Mount both and you won’t be asked to re-authenticate.

Option two — and this is arguably the better one: don’t forward host credentials. Register a separate, work-specific Claude account inside the container. Put the work SSH keys in there too. Now if you work for two clients, each one lives in its own container with its own subscription, its own credentials, its own keys, fully isolated. I’ve drifted toward this: my work SSH keys now only exist inside dev containers. If a container gets rebuilt, fine, I re-register. Meanwhile my home machine — the one with Steam on it, the one I play games on — has zero work keys on disk. That’s a real security posture, not a theoretical one.

If you skip the mount, post-create.sh generates a minimal ~/.claude/settings.json so the non-auth parts of your setup (statusline, vim mode, TUI settings) still match. It never overwrites an existing file — a mounted ~/.claude is always authoritative.

--network=host

"runArgs": ["--network=host"]

This says: whatever ports open inside the container are just… open, on the host. Run a dev server on :3000 in the container, hit localhost:3000 in your browser. No forwardPorts bookkeeping, no mapping tables. Especially valuable when you’re also running Docker containers from inside the dev container — otherwise you’re chasing a port through three layers.

Be honest about the tradeoff, though: --network=host doesn’t forward ports, it removes the network namespace boundary entirely. The container shares the host’s network stack. Convenient, less isolated. And it’s a Linux-native behavior — on Docker Desktop for macOS and Windows, host networking is a separately-enabled feature with real limitations, so don’t expect identical behavior across the team.

Docker outside of Docker

"ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false }

How do you correctly run Docker containers inside a Docker container? There are two established answers.

Docker-in-Docker runs a nested Docker daemon. It works, but now you’re forwarding ports through three levels: your app’s port, mapped to a port inside the dev container, mapped again to a host port. That gets unpleasant fast.

Docker-outside-of-Docker mounts the host’s Docker socket into the container instead. When you run docker run from inside the dev container, the container actually starts on the host. One less layer, ports behave sanely, and image layer caching is shared. I picked this one and haven’t regretted it.

(The other feature in that block installs Node LTS. It’s there for a specific reason — see the statusline note below.)

Lifecycle commands

"initializeCommand": "mkdir -p $HOME/dotfiles $HOME/.claude $HOME/.config/ccstatusline && touch $HOME/.claude.json",
"postStartCommand": ".devcontainer/base/scripts/post-start.sh",
"postCreateCommand": ".devcontainer/base/scripts/post-create.sh"

Three hooks, three different moments:

  • initializeCommand runs on the host, before the container is created or started. It’s creating the directories and file that the mounts list is about to bind-mount. Docker will happily invent a directory where you meant a file, so touch $HOME/.claude.json matters. mkdir -p and touch are both idempotent — -p won’t error if the directory exists, and touch won’t truncate an existing file.
  • postCreateCommand runs once, when the container is first created.
  • postStartCommand runs every time the container starts, including after a stop.

post-start.sh is one line — mise install, so project dependencies are present.

post-create.sh is the meaty one:

# ~/.ssh gets auto-created root-owned by docker, because only individual
# files inside it are bind-mounted, not the directory itself.
sudo chown vscode:vscode ~/.ssh
sudo chown vscode:vscode ~/.config

.devcontainer/base/scripts/apply-dotfiles.sh

curl -fsSL https://claude.ai/install.sh | bash
npm i -g ccstatusline@latest

claude plugin marketplace add anthropics/claude-plugins-official
claude plugin install superpowers@claude-plugins-official

Those two chown lines look like superstition but aren’t. Because we bind-mount individual files under ~/.ssh rather than the directory, Docker auto-creates the parent directory owned by root. Left alone, the container user can’t write known_hosts, so you get re-prompted to accept GitHub’s host key on every single session. Same story with ~/.config.

The rest is preference: install Claude Code, install the statusline, install the plugins I want everywhere. Put your own things here.

The statusline, and why Node is in features

ccstatusline adds a status bar to Claude Code showing how much of your usage limit you’ve burned, which account you’re on, which directory and branch you’re in. When you’ve got five agent sessions open across different branches, being able to glance and know which one is which is worth a lot.

It ships as an npm package only, which is why devcontainer.json declares the Node feature. And it specifically uses the feature’s Node rather than mise’s, for a subtle reason: claude.sh launches things via devcontainer exec ... zsh -c "claude …", and zsh -c is non-interactive — it never sources .zshrc, so mise activate never runs and mise’s shims aren’t on PATH. The feature exports its bin directory through containerEnv, which lifecycle scripts and devcontainer exec both inherit. Small detail, hours of debugging saved.

Running it

Three host scripts, layered:

# base.sh — starts the ssh-agent, then brings the container up
devcontainer up --workspace-folder .

# shell.sh — base.sh, then drop me into a zsh inside it
./.devcontainer/base/host-scripts/base.sh "$@"
devcontainer exec --workspace-folder . zsh

# claude.sh — base.sh, then launch the agent inside it
./.devcontainer/base/host-scripts/base.sh "$@"
devcontainer exec --workspace-folder . zsh -c "claude --dangerously-skip-permissions"

base.sh creates, shell.sh and claude.sh run. Same split as Docker’s create-vs-start. I aliased the one I actually use:

alias devshell=".devcontainer/base/host-scripts/shell.sh"

And when you change the Dockerfile, the features, the mounts, or anything else that only takes effect at creation time (including postCreateCommand), pass --recreate:

devshell --recreate

which runs devcontainer up --remove-existing-container. The workspace and every bind mount live on the host, so nothing there is lost — only things written exclusively to the container filesystem. I used to kill the container manually and rebuild; this is nicer.

I honestly stopped using claude.sh. I just run devshell and start the agent from inside, because I have an alias for that too.

What it looks like from inside

You’re in. pwd gives you something like /workspaces/lecture-devcontainer — not a path on your machine. ls ~ shows a home directory containing your dotfiles and essentially nothing else. Your local ~/Projects folder? Not here. It doesn’t exist in this world.

Which means: you can run your coding agent with --dangerously-skip-permissions and feel substantially more relaxed about it. No more approving every single action.

Now — let’s be adults about this. “Contained” is not “safe.” The container has your forwarded SSH agent and, with --network=host, the host’s network stack. An agent that goes badly wrong can still push to your repos or exfiltrate whatever it can read. The container is a meaningful blast-radius reduction, not a sandbox you should trust with anything. With well-configured hooks and clear guardrails I’m comfortable working this way, but “comfortable” is doing real work in that sentence.

The upside beyond security is one people underrate: your dependencies and paths inside the container are the same ones your application runs with. Even setting isolation aside, config you write on the host stops silently disagreeing with config the app actually sees. If you work fully inside the container, that class of bug just goes away.

So: never configuring your colleagues’ machines again

Everything above is tuned very specifically to me. It doesn’t have to be. That’s the second half of the pitch.

You can put every package your team’s dev work requires straight into the Dockerfile. Your company depends on a specific GCC version? Write it into the Dockerfile. It depends on something genuinely awful? I once worked on mapping software that needed GDAL — a C library that installs system-wide and that Python then binds against. Getting the right GDAL installed on everyone’s machine was a permanent, recurring pain point. In a dev container it’s one line, written once.

And you can have more than one dev container per repo. Nothing stops you from editing files and living in a terminal inside a container tuned to you — your tools, your dotfiles, your agent — while the same folder is also served by a second container built purely so the dev environment runs correctly: right ports, right env vars, right service registrations.

”What if my colleagues don’t use VS Code?”

Several answers, in increasing order of effort.

VS Code has the deepest integration, and its Python/Jupyter support can be pointed at the container’s interpreter — so a cell executes inside the dev container while the UI stays where it is. Get the JSON right and it’s transparent.

For JetBrains IDEs and others: your teammates can keep editing files on the host and only run commands in the container. Every IDE I know of lets you set a default command that runs when a terminal opens, and most store their config in the repo (.idea/, .vscode/). So it’s configurable, just less magical.

The people this is awkward for are those who use their coding agent purely through a GUI and never touch a terminal. For them, look at running the agent as a server inside the container and connecting your local UI to it remotely. There’s also code-server, the open-source web build of VS Code, which you can run entirely inside the dev container.

Honestly though: running your agent outside the container while your code runs inside it costs you the main benefit. Everything the agent sees — dependency versions, paths, tool availability — should match what the application sees. That’s the point.


Dev containers on their own get you reproducibility and isolation. Combine them with Git worktrees — completely unrelated technology, same workflow — and you get something better: several branches checked out simultaneously, each with its own Claude Code session, all inside one contained environment. That’s the companion post.

Comments