Fixing VS Code Dev Containers That Rebuild From Scratch on Every Reopen Instead of Reusing the Cache
A dev container built once should reopen in seconds on every subsequent session, pulling from Docker's layer cache. Instead, every "Reopen in Container" triggers a full rebuild — reinstalling system packages, re-running npm install, sometimes taking longer than the original build did. Docker's cache exists and works fine on its own; something in the dev container configuration is quietly telling it to ignore that cache.
The Problem
A project defines a dev container via .devcontainer/devcontainer.json and a Dockerfile. The first build takes a few minutes, as expected — installing system dependencies, setting up the toolchain, running npm install. But every subsequent "Reopen in Container" also takes a few minutes, when it should take seconds by reusing Docker's cached layers. Nothing in the project changed between sessions, yet the container behaves as if it's being built for the first time every single time, and developers start avoiding the dev container workflow entirely because the friction outweighs its benefits.
Why It Happens
A layer that changes on every build invalidates every layer that comes after it in the Dockerfile
Docker's build cache is strictly sequential: if any instruction's inputs differ from the last build, that layer and every layer after it get rebuilt, regardless of whether those later layers' own inputs actually changed. A single early instruction with non-deterministic output — copying a file that includes a timestamp, or an ARG that changes on every invocation — silently invalidates the entire cache chain below it.
Copying the whole project before installing dependencies defeats the cache on almost every code change
A common but costly pattern is COPY . . followed by RUN npm install. Because the copy includes every file in the project, any source code change — even one completely unrelated to dependencies — changes the input to the copy layer, which invalidates it and every subsequent layer, including the expensive npm install step that didn't actually need to re-run.
VS Code's own container settings can force a rebuild independent of Docker's cache behavior
Beyond the Dockerfile itself, devcontainer.json settings like build.args containing a value that changes per session (a timestamp, a dynamically generated token), or an incorrectly configured "updateContentCommand"/"onCreateCommand" that re-triggers more than intended, can cause VS Code to treat the container as needing a rebuild even when the underlying image would otherwise be cache-valid.
A misconfigured or missing .dockerignore lets irrelevant file changes invalidate the build context
Without a proper .dockerignore, files like node_modules, build artifacts, or editor state (.vscode/ settings, log files) become part of the build context Docker hashes to determine cache validity. Changes to these files — which have nothing to do with the actual dependencies or source needed for the image — can still trigger cache invalidation on layers that reference the build context broadly.
The Fix
1. Copy only dependency manifests before installing, and the rest of the source after
# Dockerfile
COPY package.json package-lock.json ./
RUN npm install
COPY . .
Copying only package.json and the lockfile before running npm install means that layer's cache stays valid as long as dependencies themselves haven't changed — a source code edit that follows in the later COPY . . layer no longer invalidates the expensive install step above it.
2. Add a .dockerignore that excludes everything not actually needed in the build context
# .dockerignore
node_modules
.git
dist
build
.vscode
*.log
Excluding generated and editor-local files from the build context ensures Docker's cache-hashing isn't affected by changes that have no bearing on what the image actually needs to contain, removing a class of invalidation that has nothing to do with real dependency or source changes.
3. Audit devcontainer.json for non-deterministic build args or lifecycle commands that fire too often
{
"build": {
"dockerfile": "Dockerfile"
// Avoid passing a timestamp or per-session token as a build arg here —
// it invalidates the cache on every single reopen
},
"onCreateCommand": "npm install", // runs once, at container creation
"updateContentCommand": "npm install" // runs on rebuild/reopen — confirm this is actually intended
}
Reviewing which lifecycle command actually needs to run on every reopen versus only once at creation, and removing any build argument whose value changes unnecessarily between sessions, stops VS Code itself from forcing work that Docker's layer cache would otherwise have skipped.
4. Verify cache reuse directly with a manual Docker build before trusting VS Code's rebuild behavior
docker build --progress=plain -f .devcontainer/Dockerfile .
# Look for "CACHED" next to each step on a second run with no relevant changes
Running the build manually and checking which specific layers report CACHED versus rebuilt isolates whether the problem is in the Dockerfile's own layer ordering or in VS Code's dev container configuration deciding to rebuild regardless of Docker's cache — pinpointing which of the fixes above actually applies before guessing.
Why This Works
Each fix addresses a different point where cache invalidation can creep in unnecessarily. Reordering the Dockerfile so dependency installation happens before the full source copy protects the expensive step from unrelated source changes; a proper .dockerignore keeps irrelevant files out of what Docker hashes for cache validity; auditing devcontainer.json removes VS Code's own sources of forced rebuilds independent of Docker; and verifying with a manual build isolates the actual cause before applying a fix blindly.
Conclusion
A dev container rebuilding from scratch on every reopen isn't a Docker caching failure — Docker's layer cache works correctly given what it's told; the Dockerfile or devcontainer configuration is what's invalidating it unnecessarily. Reorder the Dockerfile to install dependencies before copying the full source, add a proper .dockerignore to keep irrelevant files out of the build context, audit devcontainer.json for non-deterministic build args or overly aggressive lifecycle commands, and confirm which layers are actually cached with a manual Docker build before assuming the fix worked.
