A contributor runs "Reopen in Container" and waits three to five minutes while their laptop compiles the base image, installs every devcontainer feature, and runs postCreateCommand from cold — work that produces the exact same layers on every machine and should have been done once, in CI. This page moves that build off the contributor's machine: you prebuild the devcontainer image, push it to a registry, and point devcontainer.json at the tag so first-open becomes a pull. It applies the devcontainer configuration standards that sit inside the broader containerized local environment patterns, and it assumes you have already committed to a container-first workflow rather than choosing bare Compose over a devcontainer.

The economics are simple. Feature installs and base-layer builds are deterministic: the same devcontainer.json produces byte-identical layers no matter who runs it. Making forty engineers each spend four minutes rebuilding those layers on first clone — and again after every feature bump — is pure waste when one CI job can bake them into a pushed image that everyone pulls in under twenty seconds. The sections below reproduce the slow build, explain why it recurs, and walk through the prebuild-and-push pipeline with commands you can run against your own repository today.

Diagnostic

First, confirm the time is actually going into a build and not into a mount or a slow postCreateCommand. Prune the local build cache to simulate a fresh clone, then time a cold bring-up:

#!/usr/bin/env bash
set -euo pipefail

# Simulate a brand-new contributor: no cached layers at all.
docker builder prune --all --force >/dev/null
docker image rm -f "$(docker images -q 'vsc-*' 2>/dev/null)" 2>/dev/null || true

start=$(date +%s)
devcontainer up --workspace-folder . --remove-existing-container >/dev/null
end=$(date +%s)
echo "cold first-open took $((end - start))s"

On a repository whose devcontainer.json builds from a Dockerfile plus a handful of features, the bad output looks like this — most of the wall-clock time is layer construction, not the container start itself:

[+] Building 198.4s (23/23) FINISHED
 => [feature node] curl -fsSL https://deb.nodesource.com ...   41.2s
 => [feature docker-in-docker] install ...                     28.7s
 => [feature python] pyenv install 3.12 ...                     52.1s
 => exporting to image ...                                       9.8s
cold first-open took 214s

Now prove the build is redundant across machines by resolving the config and listing the layers each contributor rebuilds. If the image key is absent and a build or dockerFile key is present, every clone pays the full cost above:

#!/usr/bin/env bash
set -euo pipefail

devcontainer read-configuration --workspace-folder . \
  | jq '{image: .configuration.image,
         build: .configuration.build,
         features: (.configuration.features | keys)}'
{
  "image": null,
  "build": { "dockerfile": "Dockerfile" },
  "features": [
    "ghcr.io/devcontainers/features/node:1",
    "ghcr.io/devcontainers/features/python:1",
    "ghcr.io/devcontainers/features/docker-in-docker:2"
  ]
}

An image of null alongside a populated features list is the signature of the problem: there is no shared, prebuilt artifact, so each of those three features runs its install script locally on every workstation. Capture the cold-open seconds now as a baseline; it is the acceptance criterion the prebuild pipeline has to beat, and putting it in the pull request description gives reviewers a concrete before-and-after instead of a claim.

First-open time by build strategy Bar chart comparing cold first-open seconds for building on open, pulling cached layers, and pulling a prebuilt image. Cold First-Open (seconds) build on open 214s cacheFrom pull 71s prebuilt image 18s
Building on open costs 214s; pulling a fully prebuilt image collapses first-open to an 18s registry pull.

Root Cause

The devcontainer CLI treats a build or dockerFile key as an instruction to construct the image locally, and it treats every entry under features as a layer to install at build time. Neither step is shared across machines by default. There is no content-addressable cache the CLI reaches for unless you give it one, so the feature install scripts — apt-get, curl | bash, pyenv install, language toolchain downloads — execute in full on the first devcontainer up of every clone, and again whenever any input to those layers changes.

The recurrence pattern is what makes this expensive over a team's lifetime rather than just annoying once. BuildKit invalidates a layer and everything after it when the layer's inputs change. A bumped feature version, an edited Dockerfile, or a touched file that a COPY reads all bust the cache, so the cost is not paid only at onboarding — it returns after every dependency bump, multiplied by headcount. The fix is to compute those layers once in CI, publish the result to a registry as an immutable artifact, and have devcontainer.json reference that artifact so the CLI pulls it instead of rebuilding. A pull moves bytes over the network; a build runs code. Only the first is fast and identical everywhere.

There is a second, quieter cost. When each laptop builds independently, the layers are not actually identical — a feature that resolves node:1 to whatever minor is current the day you clone means two engineers a month apart get different Node patch releases. Prebuilding does double duty here: it makes first-open fast and freezes the toolchain to a single reviewed artifact, which is the same reproducibility guarantee that digest pinning gives a base image.

Resolution

Move the build into CI, push a multi-arch image, and reference it from devcontainer.json. Each step below is verifiable on its own.

  1. Convert the config to reference an image. Replace the local build block with an image key pointing at your registry. Keep features for anything you still want layered at build time in CI, but the pushed image is now the artifact every contributor pulls.

    // .devcontainer/devcontainer.json
    {
      "name": "acme-app",
      "image": "ghcr.io/acme/app-devcontainer:1.4.0",
      "features": {
        "ghcr.io/devcontainers/features/node:1": { "version": "20" },
        "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }
      },
      "postCreateCommand": "pnpm install --frozen-lockfile"
    }
  2. Prebuild and push with the devcontainer CLI, which resolves the features into the image so the pushed artifact already contains them. Build both architectures your team uses so nobody falls back to a local build:

    #!/usr/bin/env bash
    set -euo pipefail
    
    docker buildx create --use --name devcontainer-builder 2>/dev/null || true
    
    devcontainer build \
      --workspace-folder . \
      --image-name "ghcr.io/acme/app-devcontainer:1.4.0" \
      --platform linux/amd64,linux/arm64 \
      --push
  3. Run the prebuild in CI on every change to .devcontainer/ so the pushed image never lags the config. This workflow logs in to the registry, sets up multi-arch emulation, and pushes with the devcontainers/ci action, which reuses the previous image as a cache source:

    # .github/workflows/prebuild-devcontainer.yml
    name: prebuild-devcontainer
    on:
      push:
        paths: [".devcontainer/**"]
      schedule:
        - cron: "0 6 * * 1"
    jobs:
      build:
        runs-on: ubuntu-latest
        permissions:
          contents: read
          packages: write
        steps:
          - uses: actions/checkout@v4
          - uses: docker/setup-qemu-action@v3
          - uses: docker/setup-buildx-action@v3
          - name: Log in to GHCR
            uses: docker/login-action@v3
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
          - name: Build and push devcontainer image
            uses: devcontainers/[email protected]
            with:
              imageName: ghcr.io/acme/app-devcontainer
              imageTag: "1.4.0"
              platform: linux/amd64,linux/arm64
              cacheFrom: ghcr.io/acme/app-devcontainer
              push: always
  4. Pin the reference to a digest for reproducibility. Once the image is pushed, capture its digest and use it in devcontainer.json so the registry can never repoint the tag under two contributors:

    #!/usr/bin/env bash
    set -euo pipefail
    
    digest=$(docker buildx imagetools inspect \
      "ghcr.io/acme/app-devcontainer:1.4.0" \
      --format '{{json .Manifest.Digest}}' | tr -d '"')
    echo "pin this in devcontainer.json: ghcr.io/acme/app-devcontainer@${digest}"
  5. Keep a cacheFrom fallback for local rebuilds. If a contributor must rebuild — they changed the Dockerfile on a branch the prebuild has not run against yet — a build.cacheFrom pointing at the pushed image lets BuildKit pull the unchanged layers instead of recomputing them, which is the middle bar in the chart above.

    // .devcontainer/devcontainer.json (branch-build fallback)
    {
      "build": {
        "dockerfile": "Dockerfile",
        "cacheFrom": "ghcr.io/acme/app-devcontainer:1.4.0"
      }
    }

The pipeline this describes is one directed path: CI builds the image from the committed config, pushes it to the registry under an immutable tag and digest, and each contributor's devcontainer up resolves that reference to a pull. The diagram traces where the expensive work now happens exactly once.

Prebuild and pull pipeline A left-to-right flow from a CI build, to a registry push, to a fast pull on the contributor machine. Build Once, Pull Everywhere CI build features + layers Registry push tag + digest Contributor pull first-open in 18s The costly layer work runs once in CI, not on every laptop.
The prebuild pipeline: the layer work happens once in CI and every contributor resolves the reference to a pull.

If your image needs to run on both Intel and Apple Silicon workstations, the multi-arch push in step 2 depends on the same buildx tooling covered in building multi-arch images locally with Buildx; a single-arch image forces the mismatched half of your team back onto a slow emulated build.

Prebuild rollout sequence Four ordered steps from converting the config to referencing a digest-pinned image. Rollout Sequence 1 — swap build for image key 2 — build multi-arch, push 3 — prebuild in CI on change 4 — pin the digest
The rollout in order: reference an image, push it multi-arch, keep CI in sync, then pin the digest.

Expected Output

After the prebuild lands, a cold first-open pulls the image rather than building it. Re-run the timing script from the Diagnostic and the log shows a pull, not a build graph:

#!/usr/bin/env bash
set -euo pipefail
docker image rm -f ghcr.io/acme/app-devcontainer:1.4.0 2>/dev/null || true
devcontainer up --workspace-folder . --remove-existing-container 2>&1 \
  | grep -E 'Pulling|Pull complete|Running the postCreateCommand'
Pulling from acme/app-devcontainer
1.4.0: Pulling from acme/app-devcontainer
Digest: sha256:9f2c...
Status: Downloaded newer image for ghcr.io/acme/app-devcontainer:1.4.0
Running the postCreateCommand: pnpm install --frozen-lockfile
cold first-open took 18s

The absence of any [+] Building line is the assertion that matters: the feature installs that took 122 seconds are gone from the contributor path because they already ran in CI. The only local work left is the pull and postCreateCommand, and the latter is project-level dependency resolution you can shorten separately by caching the package store, in the same spirit as standardizing devcontainer.json across a monorepo.

Prevention

  • Gate the reference on a freshness check. Add a CI step that fails the build if devcontainer.json references a tag the registry does not have, so a config change can never merge ahead of its prebuilt image:

    #!/usr/bin/env bash
    set -euo pipefail
    ref=$(jq -r '.image' .devcontainer/devcontainer.json)
    if ! docker buildx imagetools inspect "$ref" >/dev/null 2>&1; then
      echo "ERROR: $ref is not in the registry; run the prebuild first" >&2
      exit 1
    fi
    echo "referenced image present: $ref"
  • Rebuild on a schedule. The weekly cron in the workflow above catches upstream security patches to the base image and feature scripts even when nobody touches .devcontainer/, so the pushed artifact never rots.

  • Choose the strategy deliberately per repo. A pure image reference gives the fastest first-open but forbids local edits without a rebuild; a build with cacheFrom keeps editability at the cost of a slower cold path. The decision below routes each repository to the right one.

Image reference versus cacheFrom build A decision node routing a repository to a pure image reference or a cacheFrom build based on how often the Dockerfile changes. Which Reference To Use Do contributors edit the Dockerfile often? No pure image + digest Yes build with cacheFrom
Stable Dockerfiles want a digest-pinned image; frequently edited ones want a build that pulls cached layers.

Platform Caveats

macOS (Docker Desktop): Apple Silicon laptops need the linux/arm64 manifest or they pull the amd64 variant and run it under Rosetta, which erases much of the prebuild win. Confirm both platforms exist with docker buildx imagetools inspect ghcr.io/acme/app-devcontainer:1.4.0 and check for two Platform lines before you announce the image to the team. WSL2: The pull lands in the Docker Desktop VM, so keep the repository on the Linux filesystem (~/code, not /mnt/c) — a fast image pull is wasted if postCreateCommand then reads the source over the slow 9p bridge. Apple Silicon (ARM64): Feature install scripts occasionally lack arm64 binaries and fall back to compiling from source, which makes the CI build slower but is exactly why you want it in CI rather than on the laptop. If the emulated amd64 build in CI times out, split the two platforms into parallel jobs and let docker buildx imagetools create merge them into one manifest.

Rollback

If a pushed image is broken — a bad feature version, a corrupt layer — point devcontainer.json back at the last-good digest and rebuild the container; because the digest is immutable, it always reproduces the environment that worked:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .devcontainer/devcontainer.json
devcontainer up --workspace-folder . --remove-existing-container

If you must abandon the prebuild entirely and let contributors build locally again, swap the image key for the build block with cacheFrom shown in step 5 — that restores the local-build path while still pulling any layers the registry can serve, so the fallback is degraded but not catastrophic.

Frequently Asked Questions

Can I keep the features block if I switch to a prebuilt image?

Yes. When you run devcontainer build against a config that has both an image (or build) and a features block, the CLI resolves the features into the pushed image, so the artifact in the registry already contains them. Contributors who pull that image get the feature layers for free. Keep the features list in devcontainer.json as the source of truth the CI prebuild reads; do not delete it just because the image is prebuilt, or the next rebuild will produce an image without those tools.

Should I reference the image by tag or by digest?

Reference it by digest when reproducibility matters, which for a shared devcontainer it almost always does. A tag like :1.4.0 can be repushed, so two contributors who pull it a week apart can silently get different images; a @sha256: digest is immutable and guarantees identical layers everywhere, including CI. Use a moving tag only for a fast-iterating internal branch where you accept the drift. Capture the digest with docker buildx imagetools inspect right after the push and commit it in the same change as the config bump.

What if a contributor is on a branch whose devcontainer image has not been prebuilt yet?

Give them a build block with cacheFrom pointing at the mainline image. BuildKit pulls every layer that has not changed on the branch and only rebuilds the ones the branch actually touched, so a cold build that would take three minutes finishes in well under one. This is the middle bar in the first chart — slower than a pure pull, far faster than building from scratch — and it means an un-prebuilt branch degrades gracefully instead of blocking work.

Does prebuilding remove the need to pin the base image inside the Dockerfile?

No — the two solve different problems. Prebuilding freezes the assembled image so contributors do not rebuild it, but the CI job that assembles it still resolves whatever the Dockerfile's FROM and the feature versions point at. If those are floating tags, each scheduled rebuild can pull a different base, so pin the base image to a digest and pin feature versions exactly, as the devcontainer configuration standards require. Prebuilding and pinning together give you a fast and reproducible first-open.