Why instruction order decides between minutes and seconds
A poorly ordered Dockerfile can rebuild every single layer on every build, even when only one line of application code changed. Understanding how Docker invalidates its layer cache based on Dockerfile order lets teams cut CI build times dramatically, without spending a cent more on hardware.
Table of Contents
- 1. How Docker Builds Images From Layers
- 2. How a Single Change Breaks the Chain
- 3. The Most Common Anti-Pattern: COPY . . Too Early
- 4. The Fix: Copy Dependency Files First
- 5. The Same Principle for PHP Projects Using Composer
- 6. Real Build Times Compared
- 7. The Role of .dockerignore in Cache Behavior
- 8. Multi-Stage Builds as an Additional Lever
- 9. A Checklist for a Cache-Friendly Dockerfile
- 10. Summary
- 11. FAQ
1. How Docker Builds Images From Layers
Every instruction in a Dockerfile that changes the filesystem state, such as RUN, COPY, or ADD, produces its own immutable layer at build time. These layers stack on top of each other to form the final image. The key advantage of this model is that Docker can cache each layer individually, so a rebuild does not necessarily have to start from scratch.
For this cache to be usable, Docker checks before each layer whether an identical instruction with an identical context has already been executed once before. If so, the cached layer is reused without actually re-running the instruction. That saves valuable time for unchanged sections, but the wrong order can produce exactly the opposite effect.
2. How a Single Change Breaks the Chain
A layer's cache becomes invalid as soon as either the instruction itself changes, or, in the case of COPY and ADD, the content of the copied files changes. Once a layer is invalidated, every subsequent layer in the Dockerfile is automatically invalidated too, even if those later instructions themselves remained completely unchanged. Docker cannot bypass this chain reaction, since every layer builds on the filesystem state of the previous one.
That is exactly what makes Dockerfile order the decisive factor: a layer that changes frequently should always sit as late as possible in the file, while rarely changing but computationally expensive steps such as package installation should be placed as early as possible, to benefit from the longest possible reusable cache prefix.
# Cache chain: if layer 2 changes, 3 and 4 are invalidated too
FROM node:20-alpine # Layer 1: rarely changes
COPY package.json . # Layer 2: changes occasionally
RUN npm install # Layer 3: depends on layer 2
COPY . . # Layer 4: changes on every commit
3. The Most Common Anti-Pattern: COPY . . Too Early
By far the most common mistake is a Dockerfile that puts the entire source code at the very top with a single COPY . ., followed by installing the dependencies. Since that COPY instruction includes the entire project content, its hash changes on practically every commit, regardless of whether a dependency actually changed or just a single line of code in a completely unrelated file.
The consequence is that the following installation step, such as npm install or composer install, re-runs on every single build, even if package.json or composer.json has not changed in weeks. On larger projects with many dependencies, that means several minutes of unnecessary wait time per build, which quickly adds up to significant cost in a CI pipeline with a high build frequency.
# Anti-pattern: every code change invalidates npm install
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
4. The Fix: Copy Dependency Files First
The established fix is to copy only the dependency manifest, i.e. package.json (plus package-lock.json) or composer.json (plus composer.lock), separately and before the rest of the source code. Only after that comes the installation command, and only in the last step is the remaining source code added with its own COPY instruction.
With this order, the expensive installation step stays valid in the cache until a dependency actually changes. Pure code changes, which make up the vast majority of everyday commits, then only invalidate the final, cheap COPY layer, while the entire installation step is reused from cache.
# Optimized: npm install stays cached
# as long as package.json/lock does not change
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]
5. The Same Principle for PHP Projects Using Composer
PHP projects using Composer follow exactly the same principle, except composer.json and composer.lock are the relevant manifest files here. It is also worth running composer install with the --no-scripts flag before the rest of the project code is present, as long as the Composer scripts themselves do not strictly depend on the application code, to keep that cache layer as lean as possible.
In Magento projects with many dependencies, this difference is especially noticeable, since composer install there can take several minutes depending on the number of modules and network speed. A correct layer order turns this step from several minutes into practically zero seconds for pure code changes.
# Composer example with an optimized layer order
FROM php:8.4-fpm
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader
COPY . .
RUN composer dump-autoload --optimize
6. Real Build Times Compared
In a test project with roughly 40 npm dependencies and a small code change in a single JavaScript file, the unoptimized Dockerfile with a leading COPY . . took about 90 seconds on every build, since npm install was fully re-run each time. After switching to the optimized order, build time for the exact same code change dropped to under 5 seconds, since only the final COPY layer and the final build step had to run again.
For an actual dependency change, such as adding a new npm package, build time stays similarly high in both variants, since the installation step has to run again regardless in that case. The benefit of the optimized order therefore shows up specifically in the vast majority of everyday commits, which touch code but not dependencies.
7. The Role of .dockerignore in Cache Behavior
An often overlooked factor in cache invalidation is the .dockerignore file. If it is missing or incomplete, directories like node_modules, .git, or local log files end up in the build context and potentially in the hash of a COPY instruction, even though they are entirely irrelevant to the build and often change unintentionally between two commits.
A carefully maintained .dockerignore file not only reduces the amount of data sent to the Docker daemon, it also ensures that a COPY layer's hash only changes when actually relevant source files change, which significantly improves the predictability of cache behavior.
# Typical .dockerignore for a Node/PHP project
node_modules
vendor
.git
*.log
var/cache
var/log
.env
8. Multi-Stage Builds as an Additional Lever
Multi-stage builds further amplify the effect of a good layer order, since build dependencies like compilers or dev tools live in a separate stage that only needs to run again on an actual code change, while the final, lean production stage simply takes over the finished artifacts. Combined with an optimized COPY order, this reduces both build time and the final image size.
It matters that cache rules apply independently within each stage, which means the instruction order in every single stage has to be optimized separately, and a good structure in the build stage does not automatically carry over to the runtime stage.
# Multi-stage build with optimized cache order
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
9. A Checklist for a Cache-Friendly Dockerfile
Anyone auditing an existing Dockerfile for cache-friendliness should work through it systematically from top to bottom, asking for every instruction how often its content actually changes. Instructions with low change frequency belong near the top, instructions with high change frequency, above all copying the application code itself, consistently belong at the very end.
The table below summarizes the key principles once more together with their impact on build time, ordered by the effect observed in practice on the cache hit rate for everyday commits.
| Principle | Placed Incorrectly | Placed Correctly | Effect on Pure Code Changes |
|---|---|---|---|
| Copy dependencies first | COPY . . before RUN npm install | COPY package.json before RUN npm install | Installation step stays cached |
| Rarely changing layers on top | apt-get install after COPY . | apt-get install before COPY . | Package installation stays cached |
| Maintain .dockerignore | node_modules in the build context | node_modules excluded | More stable hash for the COPY layer |
| Use multi-stage builds | Build tools in the final image | Build tools only in the build stage | Smaller image, independent cache |
| Include lock files | Only package.json copied | package.json and package-lock.json copied | Reproducible installation |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
Build Cache Invalidation: The Essentials at a Glance
Core rule
Rarely changing instructions first, frequently changing instructions last in the Dockerfile.
Biggest lever
Copy and install manifest files like package.json before the rest of the code.
Observed effect
Build time for pure code changes dropped from 90 to under 5 seconds in a test project.
Additional lever
Maintain .dockerignore and use multi-stage builds to separate build and runtime.