Designed by Martin Stoleru. Developed by Alinus Dumitrana

All Articles

Building a Dev Container Library: Lessons from Shipping 17 Features

Why I built a personal library of Dev Container Features and Templates, the sharp edges I hit along the way, and what shipping to GHCR taught me about reproducible dev environments.

Written By Alinus Dumitrana

Every new project started the same way: copy the last devcontainer.json, rip out half of it, paste in the new stack, fight with the Dockerfile for an afternoon, swear at mise for not being on PATH, then commit something I knew would rot.

Eventually I got tired enough to do it properly. The result is alindesign/devcontainers — a set of reusable Dev Container Features and Templates, published to ghcr.io/alindesign, that I now drop into every new repo.

This is the story of what I was actually trying to solve, the bug that taught me the most, and what I'd tell someone before they started.

What I Was Trying to Solve

Dev Containers are great in theory: describe your environment once, anyone who opens the project gets the same setup. In practice, the average devcontainer.json ends up either copy-pasted between repos and silently diverging, or bloated into a 200-line Dockerfile that nobody understands six months later.

I wanted three things:

  1. Composable layers. Pick exactly the stack I need per project — Node, Go, Rust, Python, OCaml, cloud CLIs, Kubernetes tooling — without rewriting setup logic each time.
  2. One source of truth for the boring parts. My zsh config, my CLI tools (fd, rg, bat, fzf, delta, eza, zoxide), my git signing setup, my starship prompt. Versioned once, reused everywhere.
  3. Real auth inside the container. I want aws, gcloud, and Claude Code to just work when I open the container — not "after you run three manual login commands."

Features and Templates were the right primitive. A Feature is a small reusable install script with a JSON manifest; a Template is a starter devcontainer.json that wires features together. Both publish as OCI artifacts on a registry, so consuming them is just a string in a JSON file.

The Shape It Settled Into

Seventeen features, twelve templates. The features split into three buckets:

  • Foundationsdotfiles (shell + CLI tools + nvim + git), mise (toolchain manager).
  • Languagesnode, go, rust, java, ocaml, python, bun. All but rust, ocaml, bun go through mise so versions are pinned per-project.
  • Ops & clouddocker-in-docker, kubectl (+ helm, k9s, kubectx), opentofu, aws-cli, gcloud, ansible, db-tooling (psql/mysql/redis-cli/mongosh), secrets (sops + age + pass), claude (Claude Code CLI).

Templates are just opinionated bundles: claude-dev for AI-assisted work with my host credentials mounted in, cloud-ops for multi-cloud work, devops as the full IaC toolbox, k8s, data-science, plus one per language.

The whole thing builds on a pre-baked base image, ghcr.io/alindesign/devcontainer-base, with dotfiles + mise already burned in — so the common case skips two layer rebuilds.

The bug that reshaped the design

Most features looked great on my laptop. The trouble started in CI.

I'd shipped aws-cli, gcloud, and claude features with bind mounts declared right in the feature manifests: ~/.aws, ~/.config/gcloud, ~/.claude. The intent was nice and tidy. Add the feature, get your host credentials inside the container automatically. Worked perfectly for me, since those directories obviously existed on my machine.

On a fresh CI runner without them, container startup just died with a cryptic OCI runtime error. Bind mounts assume the host path exists. They don't know or care that you might not be on the developer's laptop.

The fix wasn't really about the error message; it was about the boundary. I moved mounts out of the feature manifests and into the templates (claude-dev, cloud-ops), where the user has explicitly opted in to host integration. Features now stay portable across any environment. Templates carry the host wiring for the cases where it makes sense.

The follow-on touch I'm most proud of: the claude-dev template uses initializeCommand on macOS to pull the Claude Code OAuth token from the Keychain and write it to ~/.claude/.credentials.json before the container starts. The container is authenticated on first boot, no manual claude login required. Small detail, disproportionate quality-of-life payoff.

What I Learned

Composition beats configuration. A devcontainer.json that lists 5 features and stops is more maintainable than a 200-line Dockerfile, even if each individual feature is more complex internally. The cognitive load is paid once, by me, and amortized across every project.

OCI as a package registry is underrated. I push features and templates to GHCR the same way I'd push container images. No npm-style registry, no special tooling, just docker push. The devcontainer CLI fetches by OCI ref. It's clean.

Build for someone else's machine, not yours. The mounts bug only existed because every assumption about host state lived inside the feature. Once I started treating features as code that runs on a CI runner I've never seen, the design got sharper. The same instinct applies to most "infrastructure" code I write now.

Test the failure modes, not just the happy path. My CI matrix tests every feature against the Microsoft base image. The bugs that mattered only showed up in environments that weren't mine: a runner without my dotfiles, a different remote user, a missing host directory. Tests in your own environment confirm what you already believe.

AI did a lot of the heavy lifting. A large chunk of the install scripts, the GitHub Actions matrix, and the test scenarios were written with Claude Code. It's also why claude-dev was the first template I shipped: I wanted that workflow inside a clean, reproducible container, not bolted onto whatever the host happens to look like.

What's Next

The library covers most of my day-to-day work now: navigating code, running Node and Go apps, building web apps, and the bits of devops that creep into every project. I'll keep adding features as I bump into new tooling, but the core feels stable.

If you've been copy-pasting devcontainer.json files between projects, build your own library. It's three afternoons of work and pays itself back the next time you start a project.

Source: github.com/alindesign/devcontainers.

Other articles