Dockerfile order determines which earlier inputs an expensive build operation depends on. If application source is copied before dependency installation, changing that source can make the installation run again even when the dependency declaration is unchanged. Copying the dependency inputs first, installing them, and copying application code afterward creates a smaller dependency boundary that matches the work the installer actually needs.
When it breaks
A broad COPY . . before package installation makes documentation, generated output, or unrelated source edits potential reasons to repeat expensive work. The problem is not that COPY is inherently slow. It is that the later operation depends on filesystem state containing changes that were unnecessary for resolving and installing the selected dependencies.
Copying only a manifest is insufficient when the install also consumes a lockfile, local package source, patches, workspace metadata, or a package-manager configuration file. Omitting a real dependency can preserve a cached result that no longer matches the intended build. The correct boundary includes every actual installer input, not simply whichever single filename appears in a common Dockerfile example.
Cache ordering also does not fix mutable dependencies. An unchanged install command may retain an old result while a clean build resolves different remote content. Pinning versions and verifying hashes addresses artifact selection; controlling COPY order addresses reuse. Both concerns matter, and making a build faster does not establish that its package graph is reproducible or current.
Check it quickly
Run this host Python script with a Docker builder and the pinned base available. It downloads two public idna wheels over HTTPS and verifies their fixed SHA-256 values before any build. Container builds use no network, and pip installs with required hashes from the local wheel directory. The recorded environment used Docker 29.3.1 and Python 3.11.16 inside the Linux arm64 base:
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import urlopen
import hashlib,json,subprocess,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
WHEELS={
'3.10':('https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl','946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3'),
'3.11':('https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl','771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea')}
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
name='xbcatm-order-'+uuid.uuid4().hex[:10];tags=[name+':early',name+':late']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'wheels').mkdir()
for version,(url,digest) in WHEELS.items():
with urlopen(url,timeout=40) as response:data=response.read()
assert hashlib.sha256(data).hexdigest()==digest
(root/'wheels'/url.rsplit('/',1)[1]).write_bytes(data)
install='RUN pip install --no-index --no-deps --require-hashes --find-links=/wheels -r /app/requirements.txt && python -c "import time;open(\'/installed\',\'w\').write(str(time.time_ns()))"\n'
common=f'FROM {BASE}\nLABEL example.run="{name}"\nCOPY wheels /wheels\n'
(root/'early.Dockerfile').write_text(common+'COPY requirements.txt app.py /app/\n'+install+'CMD ["python", "/app/app.py"]\n')
(root/'late.Dockerfile').write_text(common+'COPY requirements.txt /app/\n'+install+'COPY app.py /app/\nCMD ["python", "/app/app.py"]\n')
def requirement(version):
(root/'requirements.txt').write_text(f'idna=={version} --hash=sha256:{WHEELS[version][1]}\n')
def application(message):
(root/'app.py').write_text(f'import idna\nprint({message!r},idna.__version__,idna.encode("bücher.example").decode())\n')
def build():
for tag,kind in zip(tags,['early','late']):docker('build','--network','none','-f',str(root/(kind+'.Dockerfile')),'-t',tag,str(root))
def marks():
return [docker('run','--rm','--network','none',tag,'cat','/installed').stdout for tag in tags]
requirement('3.10');application('first');build();first=marks()
application('second');build();second=marks()
assert first[0]!=second[0] and first[1]==second[1]
for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.10 xn--bcher-kva.example'
print('editing only application code reruns the early-copy dependency install but reuses the late-copy install')
requirement('3.11');build();third=marks()
assert all(a!=b for a,b in zip(second,third))
for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.11 xn--bcher-kva.example'
print('changing the hashed requirement reruns both installs and actually changes idna from 3.10 to 3.11')
finally:docker('image','rm',*tags,check=False)
The early-copy Dockerfile copies both requirements.txt and app.py before pip runs. The late-copy version copies the requirement first, runs the same installation command, and copies the application afterward. Both include the verified wheel directory before installation because those wheel files are genuine inputs. A marker written immediately after pip records whether that installation operation executed.
The initial application uses idna 3.10 to encode bücher.example. Both images run the actual installed library and produce the expected ASCII domain xn--bcher-kva.example. This exercises a real third-party dependency, rather than replacing dependency installation with a sleep or treating a successful image build as proof that the application can import its package.
After only the application message changes from first to second, both images print the new message with version 3.10. The early-copy image gets a different installation marker, while the late-copy image keeps its original marker. The output proves the code update reached both images; the marker comparison identifies which dependency work was unnecessarily repeated.
Next, the requirement changes to idna 3.11 with its matching hash. Both installation markers change, and both applications report version 3.11 with the same expected domain conversion. This is the necessary opposite case: a genuine dependency declaration change must invalidate the install in both layouts. A layout that ignored this input would be fast but incorrect.
The experiment supplies both wheels from the beginning so changing the requirement does not simultaneously alter the wheel directory. That keeps the cause of the second invalidation explicit. Temporary images and files are cleaned up without pruning unrelated builder state. Installation timestamps are test instrumentation, not a recommended input for reproducible production artifacts.
Match the cache boundary to the package boundary
Review what the installer reads, then group those inputs before the install operation. Keep unrelated files outside that COPY scope and use .dockerignore to exclude material the build never needs. An ignored file cannot later satisfy a required COPY, so check actual build-context contents when a supposedly present dependency disappears.
Package-manager cache mounts can reduce downloads when an install genuinely must rerun, but they do not eliminate the need for correct invalidation or verified outputs. Dependency installation and download-cache reuse are different layers of optimization. Test the resulting application after changes to either one.
Measure improvement on representative builds: unchanged inputs, source-only edits, and dependency upgrades. Retain correctness assertions for all three cases. A useful Dockerfile makes the necessary work repeat and the unnecessary work reusable, with enough evidence to tell those cases apart.
Sources
- Optimize Docker cache usage, stable dependency layers and narrow COPY operations, checked 2026-09-12.
- Docker cache invalidation, how changed inputs invalidate dependent work, checked 2026-09-12.
- pip install reference, offline wheel installation and required hashes, checked 2026-09-12.