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.
{ "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"] } }}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:
FROM mcr.microsoft.com/devcontainers/python:3.13-trixie
# Dependencies live in the image so a prebuild carries them.COPY requirements.txt /tmp/requirements.txtRUN pip install --no-cache-dir -r /tmp/requirements.txtThe test is one round trip against the real database:
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:
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.
- Base image. Use a version tag like
python:3.13-trixie, neverlatest. For a hard pin, reference the digest (image@sha256:...) and let Dependabot or Renovate bump it. - Features. Reference a full version, as in
github-cli:1.1.3. A bare:1follows the latest 1.x release. Features install whatever tool versions they default to, so for tools like Node or Python also set the Feature’sversionoption. - Language dependencies. A lockfile or exact
==pins, as inrequirements.txt. - Service images.
postgres:17-alpine, notpostgres.
The CLI also writes a devcontainer-lock.json next to your config. It records the resolved digest of each Feature:
{ "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.
| Hook | Runs | Use it for |
|---|---|---|
initializeCommand | On the host, every create and start | Rare. Host-side checks. |
onCreateCommand | In the container, first creation | One-time setup, no user secrets |
updateContentCommand | In the container, after onCreateCommand, when new content arrives | Dependency installs that track the repo |
postCreateCommand | In the container, after the above, once the container belongs to a user | Setup that needs user-scoped secrets |
postStartCommand | Every time the container starts | Starting daemons, cheap refreshes |
postAttachCommand | Every time a tool attaches | Editor-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.
--workspace-folder . \ --image-name devcontainers-for-teams:testThe interesting part is what lands on the image. The CLI writes your config into a devcontainer.metadata label:
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:
devcontainer build --workspace-folder . --image-name ghcr.io/your-org/devcontainers-for-teams:abc123docker push ghcr.io/your-org/devcontainers-for-teams:abc123If 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::
services: app: image: ghcr.io/your-org/devcontainers-for-teams:abc123Because 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.
name: devcontainer-cion: 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 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: 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:
# Copy to .env (gitignored). Local dev values only. Real secrets never go in the image.POSTGRES_USER=devPOSTGRES_PASSWORD=devPOSTGRES_DB=appDATABASE_URL=postgresql://dev:dev@db:5432/appThe 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:
- You are the only developer and your setup is
python3 -m venv. - The project has no services and one toolchain that installs in under a minute.
- Your team all runs the same OS, the same versions, and rarely onboards anyone.
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.