Most devcontainer configs I've seen in the wild are copy-pasted from a GitHub template, tweaked once, and then quietly abandoned when something breaks six months later. The container starts, sure. But it's bloated, slow to rebuild, and nobody remembers why half the extensions are in there.
Devcontainer configuration best practices aren't just about getting a container to launch. They're about making your dev environment reproducible, fast, and honest about its own dependencies. If you're a solo engineer running five projects, a bad config means you're debugging your environment instead of shipping. If you're on a small team, a bad config means every new joiner spends their first day fighting Docker.
This post covers what I've learned from running devcontainers across a dozen projects — what to lock down, what to leave flexible, and where most configs quietly rot.
Start With the Minimal Base Image
The single biggest mistake I see: using mcr.microsoft.com/devcontainers/universal as a base. It's 10+ GB. It has Ruby, Python, Java, Node, and a bunch of other runtimes you're probably not using. It's the "I'll deal with it later" image.
Pick the language-specific image instead:
{
"image": "mcr.microsoft.com/devcontainers/python:3.12"
}
Or go further and pin a digest:
{
"image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye"
}
Pinning to a specific Debian version matters. bullseye vs bookworm can mean different default libssl versions, which will silently break things like psycopg2 or cryptography if you're not careful.
If your project needs multiple runtimes — say, Python backend and a Node-based build tool — resist the urge to grab universal. Instead, use a Dockerfile and layer what you actually need:
FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye
# Install Node via nvm, pinned version
ARG NODE_VERSION=20.11.0
RUN curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash \
&& . ~/.nvm/nvm.sh \
&& nvm install ${NODE_VERSION} \
&& nvm alias default ${NODE_VERSION}
Yes, it's more work upfront. But you know exactly what's in there.
Pin Everything, Including Features
Devcontainer Features are genuinely useful — they let you bolt on tools like git-lfs, docker-in-docker, or kubectl without maintaining a Dockerfile from scratch. But they have a versioning trap.
This is what most people write:
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
}
}
That :2 is a major version tag, not a pinned version. The feature can update under you on a rebuild. Instead, pin to a specific version:
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2.12.1": {
"version": "26.1.3"
}
}
}
Check the feature's release page on GitHub for available tags. Yes, this means you'll need to manually bump versions. That's the point — you should know when your environment changes.
Same rule applies to any apt packages you install in postCreateCommand. Don't do apt-get install -y postgresql-client. Do apt-get install -y postgresql-client-16. Unversioned package installs are a time bomb.
Structure Your devcontainer.json Cleanly
Here's a template I actually use for Python/FastAPI projects. I'll walk through each section.
{
"name": "my-api-dev",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
"NODE_VERSION": "20.11.0"
}
},
"runArgs": ["--network=host"],
"mounts": [
"source=${localWorkspaceFolder}/.devcontainer/bash_history,target=/home/vscode/.bash_history,type=bind,consistency=cached"
],
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.black-formatter",
"charliermarsh.ruff",
"mtxr.sqltools"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}
}
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"remoteUser": "vscode"
}
A few things worth calling out:
--network=host — I use this when the container needs to talk to services running on the host (local Postgres, Redis, etc.) without extra Docker networking config. It's not always appropriate, but for solo dev it saves a lot of host.docker.internal headaches.
Persisting bash history — That mount keeps your shell history across container rebuilds. Sounds small. You'll miss it the first time you rebuild and lose 200 commands.
postCreateCommand as a script, not inline — Never do this:
"postCreateCommand": "pip install -r requirements.txt && pre-commit install && cp .env.example .env"
That one-liner becomes unreadable fast. Put it in .devcontainer/post-create.sh and version control it like any other script.
Separate Dev Dependencies From Runtime
This is where devcontainer configs and production Dockerfiles often get confused. Your devcontainer should have everything you need to develop — linters, formatters, test runners, debugging tools. Your production image should have none of that.
Keep them separate. Your .devcontainer/Dockerfile is not your Dockerfile. If you're using multi-stage builds for production, your devcontainer can reuse the base stage:
# .devcontainer/Dockerfile
FROM your-registry/your-app:base-stage AS dev
RUN pip install --no-cache-dir \
pytest==8.2.0 \
ruff==0.4.4 \
black==24.4.2 \
ipdb==0.13.13
This way your dev environment stays close to production without polluting your production image with dev tooling.
If you're not using multi-stage builds yet, setting up a local Docker registry can help you cache and reuse base images across projects without re-pulling from DockerHub every time.
Handle Secrets Without Baking Them In
I've seen .env files committed to repos because someone added them to postCreateCommand and forgot to .gitignore them. Don't do that.
For local secrets, use a .env.local pattern and document it:
# .devcontainer/post-create.sh
if [ ! -f ../.env ]; then
cp ../.env.example ../.env
echo "⚠️ .env created from .env.example — fill in your secrets"
fi
For team setups where secrets need to be shared, use devcontainer.json's remoteEnv to pull from the host environment rather than hardcoding:
{
"remoteEnv": {
"DATABASE_URL": "${localEnv:DATABASE_URL}",
"STRIPE_SECRET_KEY": "${localEnv:STRIPE_SECRET_KEY}"
}
}
The ${localEnv:VAR} syntax reads from your host machine's environment. The variable never touches the repo. Document which variables are required in your README.md or a .env.example — that's your contract.
Keep Rebuild Times Honest
A devcontainer that takes 8 minutes to build from scratch is one that people stop rebuilding. Then they start hacking around in the running container, and suddenly your "reproducible environment" isn't.
A few things that actually help:
Order your Dockerfile layers by change frequency. System packages change rarely; your pip requirements change often. Put system packages first:
# Changes rarely — cache hit most of the time
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev=15.* \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Changes more often — separate layer
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements.txt
Use BuildKit. It's been the default since Docker 23.0, but if you're on an older setup, DOCKER_BUILDKIT=1 makes a real difference for cache utilization.
Don't run apt-get update without apt-get install in the same RUN statement. Classic mistake. The update layer gets cached, the package list goes stale, and you get mysterious "package not found" errors weeks later.
If you want to go deeper on optimizing container builds for local dev, I covered layer caching strategies in this guide on self-hosted CI/CD setup.
Comparison: Common Devcontainer Approaches
| Approach | Rebuild Speed | Reproducibility | Maintenance Cost |
|---|---|---|---|
| Universal base image | Slow (large pull) | High | Low |
| Language-specific image | Fast | High | Low |
| Custom Dockerfile, no pinning | Medium | Low (drifts) | Medium |
| Custom Dockerfile, fully pinned | Medium | Very High | High |
| Features only, no Dockerfile | Fast | Medium | Low |
| Features + pinned versions | Fast | High | Medium |
For most solo projects: language-specific image + pinned Features + a post-create.sh script. That's the sweet spot. You get reproducibility without the overhead of maintaining a full custom Dockerfile for every project.
For team projects or anything that touches production parity: Custom Dockerfile, fully pinned. The maintenance cost is worth it when onboarding time and "works on my machine" bugs are real costs.
One More Thing: Document Your Own Config
This sounds obvious. Nobody does it.
Add a ## Development Environment section to your README.md that answers:
- What does the container assume about the host? (Docker version, available ports, env vars)
- How long does the first build take?
- What's in
post-create.shand why? - How do you connect to the database / message broker / whatever?
Future-you six months from now will thank present-you. So will anyone else who touches the repo.
A CONTRIBUTING.md with a "Getting started in 5 minutes" section that actually works is worth more than any CI badge.
What to Do Tomorrow
Devcontainer configuration best practices aren't a one-time checklist. They're habits you build into how you start every project.
Here's the concrete thing: open your most-used project's devcontainer.json right now. Check three things — is the base image pinned to a specific OS version? Are your Features pinned to exact versions? Is your postCreateCommand a readable script or a one-liner that's grown arms and legs?
Fix whichever one is worst. Rebuild. Make sure it still works. Commit it.
That's it. One config, properly maintained, is worth ten configs that technically start but slowly drift into chaos.