Skip to content
Go back

Devcontainers for a Whole Team

By KingPin 12 min read
Contents

Clone, Open, Wait

A devcontainer on your own laptop is a nice trick. A devcontainer the whole team uses is a different animal, because now it has to survive Linux users, Mac users, a CI runner, and the new hire who starts Monday.

The goal is a one-line onboarding doc: clone, open, wait. No 40-step README that was last accurate when Node 16 was current. To get there you need two things: a Compose-based devcontainer that brings its own database, and one prebuilt image that laptops and CI both pull. This post builds both and tests them.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/devcontainers-for-teams

I assume you know the basic devcontainer.json schema. If you want the editor-agnostic angle (DevPod, Neovim, no VS Code), start with Devcontainers Without VS Code Lock-In. Everything here runs through the devcontainer CLI, so the editor does not matter.

The Compose-Based Setup

One container is fine until your app needs Postgres. Then you want a second container, and devcontainer.json can point at a Compose file instead of an image. The app container is where your editor and tools live. Postgres is a sidecar.

.devcontainer/devcontainer.json
{
"name": "devcontainers-for-teams",
"dockerComposeFile": "compose.yaml",
"service": "app",
"workspaceFolder": "/workspaces/devcontainers-for-teams",
"remoteUser": "vscode",
"updateRemoteUserUID": true,
"features": {
"ghcr.io/devcontainers/features/github-cli:1.1.3": {}
},
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"postCreateCommand": "python app.py",
"postStartCommand": "echo devcontainer started",
"customizations": {
"vscode": {
"extensions": ["ms-python.python"]
}
}
}
.devcontainer/compose.yaml
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspaces/devcontainers-for-teams:cached
command: sleep infinity
env_file:
- path: ../.env
required: false
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://dev:dev@db:5432/app}
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
env_file:
- path: ../.env
required: false
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
POSTGRES_DB: ${POSTGRES_DB:-app}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-dev} -d $${POSTGRES_DB:-app}"]
interval: 2s
timeout: 3s
retries: 15
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

Two details matter here. The app service runs sleep infinity because the devcontainer needs a container that stays alive while you attach to it. And the healthcheck is not decoration.

My first up failed with Connection refused. depends_on: [db] only waits for the Postgres container to start, not for Postgres to accept connections, so postCreateCommand raced the database and lost. The healthcheck plus condition: service_healthy fixed it. Your 2 AM self will thank you for writing that once instead of debugging it on a new hire’s machine.

The Dockerfile bakes dependencies into the image:

.devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/python:3.13-trixie
# Dependencies live in the image so a prebuild carries them.
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt

The test is one round trip against the real database:

tests/test_app.py
import app
def test_round_trip():
with app.connect() as conn:
app.init(conn)
conn.execute("TRUNCATE notes")
app.add_note(conn, "first")
app.add_note(conn, "second")
assert app.list_notes(conn) == ["first", "second"]

Run it without opening any editor:

Terminal window
npx -y @devcontainers/[email protected] up --workspace-folder .
npx -y @devcontainers/[email protected] exec --workspace-folder . python -m pytest -q

On my machine (Docker 29.8, CLI 0.89.0) that ended with 1 passed, running against the Postgres sidecar.

Pin Everything You Can Pin

“Works on my machine” is just unpinned versions with extra steps. A team devcontainer has four moving parts, and each one drifts if you let it.

  1. Base image. Use a version tag like python:3.13-trixie, never latest. For a hard pin, reference the digest (image@sha256:...) and let Dependabot or Renovate bump it.
  2. Features. Reference a full version, as in github-cli:1.1.3. A bare :1 follows the latest 1.x release. Features install whatever tool versions they default to, so for tools like Node or Python also set the Feature’s version option.
  3. Language dependencies. A lockfile or exact == pins, as in requirements.txt.
  4. Service images. postgres:17-alpine, not postgres.

The CLI also writes a devcontainer-lock.json next to your config. It records the resolved digest of each Feature:

.devcontainer/devcontainer-lock.json
{
"features": {
"ghcr.io/devcontainers/features/github-cli:1.1.3": {
"version": "1.1.3",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:bd7ab48a8322...",
"integrity": "sha256:bd7ab48a8322..."
}
}
}

Commit it. In CI, pass --frozen-lockfile to devcontainer build so the build fails if the lockfile is missing or would change.

Notice the base image devcontainers/python also pulled in Features of its own (common-utils, git, node, python). Those show up in the metadata label later. Pin your own, and know the image brings friends.

Lifecycle Hooks: What Runs When

This is the part that bites people. There are several hooks, and they differ in when they run and where.

HookRunsUse it for
initializeCommandOn the host, every create and startRare. Host-side checks.
onCreateCommandIn the container, first creationOne-time setup, no user secrets
updateContentCommandIn the container, after onCreateCommand, when new content arrivesDependency installs that track the repo
postCreateCommandIn the container, after the above, once the container belongs to a userSetup that needs user-scoped secrets
postStartCommandEvery time the container startsStarting daemons, cheap refreshes
postAttachCommandEvery time a tool attachesEditor-session niceties

The order within creation is onCreateCommand, then updateContentCommand, then postCreateCommand. If one fails, the rest are skipped. I saw that live: my failing postCreateCommand meant postStartCommand never ran.

Which ones run at prebuild time? Per the CLI’s own flag help, devcontainer up --prebuild “stops after onCreateCommand and updateContentCommand, rerunning updateContentCommand if it has run before.” So those two are the prebuild hooks. The spec says the same for cloud services: they use onCreateCommand when caching or prebuilding, so it typically has no access to user-scoped secrets, and postCreateCommand is where user secrets can appear.

One more key is waitFor. It defaults to updateContentCommand, so editors connect once that finishes and postCreateCommand continues in the background. Slow, non-urgent work belongs in postCreateCommand.

The gotcha: devcontainer build runs none of these hooks. It builds the image from your Dockerfile and Features. Anything a hook installs is not in the prebuilt image. That is why the example installs Python dependencies in the Dockerfile with RUN pip install and not in postCreateCommand. Hooks configure the container. The Dockerfile builds the image.

Prebuild One Image for Laptops and CI

Building the devcontainer from scratch on every laptop is a coffee break. Building it again in CI is a second coffee break. Prebuild it once and both pull the result.

Terminal window
npx -y @devcontainers/[email protected] build \
--workspace-folder . \
--image-name devcontainers-for-teams:test

The interesting part is what lands on the image. The CLI writes your config into a devcontainer.metadata label:

Terminal window
docker inspect devcontainers-for-teams:test \
--format '{{index .Config.Labels "devcontainer.metadata"}}'

I ran that. The label is a JSON array: one entry per base-image Feature (common-utils, git, node, python), one from the base image itself that sets remoteUser, one for github-cli:1.1.3, and a final entry holding my devcontainer.json values: the lifecycle hooks, remoteUser, and updateRemoteUserUID. A tool that pulls the prebuilt image reads that label and applies the config, so the image and its settings travel together.

There is a sharp edge. The CLI’s build command takes --push and --platform, but with a Compose-based config version 0.89.0 refuses both: --platform or --push not supported. For a Compose config, build locally and push yourself:

Terminal window
devcontainer build --workspace-folder . --image-name ghcr.io/your-org/devcontainers-for-teams:abc123
docker push ghcr.io/your-org/devcontainers-for-teams:abc123

If your devcontainer is a single image or Dockerfile with no Compose, devcontainer build ... --push does the push in one step, per the prebuild guide. Tag with the git SHA so every image maps to one commit. Teammates then point the app service at the image instead of build::

.devcontainer/compose.yaml (consumer)
services:
app:
image: ghcr.io/your-org/devcontainers-for-teams:abc123

Because the metadata label rides along, the lifecycle hooks and user settings still apply. Do not swap the whole config for an image-only devcontainer.json: that drops the Postgres sidecar, and the label’s postCreateCommand then fails on the missing db host. I did not run this consumer flow against a registry, so test it with your own registry before rolling it out.

Run Your Tests in the Same Container

CI that uses a different environment from dev is how you get “green on CI, red locally.” The devcontainers/ci GitHub Action builds your devcontainer and runs a command inside it. The version tag is v0.3, and the inputs that matter are imageName, cacheFrom, push, and runCmd.

.github/workflows/devcontainer-ci.yml
name: devcontainer-ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/your-org/devcontainers-for-teams
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Same compose.yaml, same Dockerfile, same Features as a laptop.
- name: Run the tests inside the devcontainer
uses: devcontainers/[email protected]
with:
imageName: ${{ env.IMAGE }}
cacheFrom: ${{ env.IMAGE }}
push: never
runCmd: python -m pytest -q
publish:
# Build with --frozen-lockfile so the pushed image matches the committed Feature lockfile.
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: npm install -g @devcontainers/[email protected]
- run: devcontainer build --workspace-folder . --frozen-lockfile --image-name "$IMAGE:${{ github.sha }}"
- run: docker push "$IMAGE:${{ github.sha }}"

The action’s push input takes never, filter, or always. The action pushes with a plain docker push after runCmd passes, so it handles Compose configs too: push: filter plus refFilterForPush: refs/heads/main and eventFilterForPush: push pushes only from main. I keep a separate publish job anyway, because it runs the exact CLI commands I tested locally and adds --frozen-lockfile. I did not run this workflow on GitHub Actions, so expect to adjust it.

Secrets Stay Out of the Image

An image on a registry is readable by everyone with pull access, and docker history shows every ENV value and any build arg a RUN step used. Never COPY .env or pass a token as a build arg.

The rule is simple. Dev-only values (the local Postgres password dev) can live in .env.example and compose defaults. Real secrets arrive at runtime:

.env.example
# Copy to .env (gitignored). Local dev values only. Real secrets never go in the image.
POSTGRES_USER=dev
POSTGRES_PASSWORD=dev
POSTGRES_DB=app
DATABASE_URL=postgresql://dev:dev@db:5432/app

The Compose file loads ../.env with required: false, so a fresh clone works with the defaults and a teammate who needs overrides copies the example. For secrets from the host shell, remoteEnv can read them with ${localEnv:MY_TOKEN}. That value exists in the running container’s tools, not in the image layers. Spec note: onCreateCommand runs in prebuilds that have no user secrets, so anything needing a token goes in postCreateCommand.

The Linux UID Trap

On Linux, a bind-mounted workspace keeps the host file owner. Your laptop user is UID 1000 most of the time, but a teammate on a shared box might be 1001. The container’s vscode user is 1000, so for that teammate, files created inside the container come out owned by a different UID on the host, and git complains about dubious ownership.

updateRemoteUserUID handles this. Per the spec it defaults to true. On Linux, if remoteUser or containerUser is set, the tool updates that user’s UID and GID to match the host user. I saw the CLI create a local image named vsc-devcontainers-for-teams-...-uid to do exactly that. The spec limits this update to Linux hosts.

Two rules keep it working: set remoteUser explicitly, and leave updateRemoteUserUID at its default unless you have a reason. Note that the UID rewrite happens at container creation, so it costs a small extra image build on Linux and does not touch your prebuilt image.

When This Is Overkill

Using a full Compose devcontainer for a single-file script is like hiring a forklift to move a couch. It works, and your neighbors will have questions.

Skip it when:

Reach for it when onboarding takes more than an afternoon, when CI keeps failing on things that pass locally, or when you have a database, a message queue, and three language runtimes. That is the point where a 40-step README starts lying to you.

The SumGuy Take

Put the devcontainer in the repo, pin the versions, and make CI run the tests inside it. Prebuild the image once per commit so nobody waits on a cold build. Keep secrets at runtime.

The reward is a new hire who clones, opens, waits, and runs pytest against a real database before the first coffee is cold.

Common Questions

Can I use a devcontainer in CI without VS Code?

Yes. The devcontainer CLI runs up, exec, and build with no editor installed, and the devcontainers/ci GitHub Action wraps those same steps for a workflow. The companion example runs devcontainer up and devcontainer exec from a plain terminal and passes its Postgres-backed test.

Do devcontainers work with Podman?

Mostly yes. The devcontainer CLI accepts --docker-path and --docker-compose-path, so you can point it at Podman binaries. Podman’s rootless user mapping interacts with bind-mount ownership differently from Docker, so test file permissions on your own setup before standardizing on it. I did not test Podman for this article.

Does a prebuilt devcontainer image work on Apple Silicon?

Yes, if the image includes an arm64 variant. The Microsoft devcontainers/python base images list both linux/amd64 and linux/arm64. Your own prebuilt image only has the platforms you build. A Compose config rejects devcontainer build --platform, so build on an arm64 runner or with docker buildx. An amd64-only image runs under emulation, slowly.

How do I roll back a bad prebuilt image?

Point the config back at the previous tag. If you tag every image with the git commit SHA, rolling back means changing one line in devcontainer.json or compose.yaml to an older SHA and rebuilding the container. Keep old tags in the registry for at least a few releases so the rollback target exists.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Train a Tiny GPT, Part 5: Fine-Tuning
Next Post
Migrating 10 Years of Bookmarks

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts