A multistage Dockerfile can compile software in one stage and copy selected outputs into another stage that becomes the runtime image. The final stage inherits its own base and the files explicitly copied into it, rather than automatically inheriting the builder’s compiler, source tree, and package caches. This creates a useful boundary, but the author must still identify everything the program needs when it executes.

When it breaks

COPY can successfully place a dynamically linked executable into a scratch image even when its ELF interpreter and shared libraries are missing. The image build may finish normally because copying does not execute the program or validate its entire runtime dependency graph. Startup can then report no such file or directory despite the executable itself being present at the configured path.

Scripts need their interpreters, applications may need certificate roots or timezone data, and programs can load plugins or assets only on particular request paths. A simple launch test might miss those later requirements. Selecting a small runtime base is useful only when it provides the actual contract the application relies on, including supported architecture and necessary filesystem paths.

The earlier stage is also not securely erased just because it is absent from the final image’s ancestry. Builder caches and explicitly exported intermediate images can retain its contents. Sensitive build inputs require appropriate secret handling, and copied outputs must be checked for accidental inclusion. Multistage structure controls final filesystem composition; it is not an automatic confidentiality guarantee.

Check it quickly

Run this script with Python, Docker, the pinned Python base, and network access for the builder’s Debian package installation. Runtime containers disable networking. The recorded run used Docker 29.3.1 on Linux arm64 and GCC 12.2.0 from Debian’s 12.2.0-14+deb12u1 package. The package repository is live, so the exercise demonstrates runtime composition rather than promising identical compiler bytes forever:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,tarfile,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=360)
    if check:assert result.returncode==0,result.stderr
    return result
def inspect(image):return json.loads(docker('image','inspect',image).stdout)[0]

name='xbcatm-stages-'+uuid.uuid4().hex[:10];tags=[name+':builder',name+':good',name+':bad'];container=name+'-inspect'
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory)
        (root/'square.c').write_text('#include <stdio.h>\n#include <stdlib.h>\n#include <errno.h>\nint main(int argc,char **argv){\n char *end;long value;\n if(argc!=2){fprintf(stderr,"one integer required\\n");return 2;}\n errno=0;value=strtol(argv[1],&end,10);\n if(errno||end==argv[1]||*end||value<0||value>100){fprintf(stderr,"range 0..100\\n");return 2;}\n printf("%ld\\n",value*value);return 0;\n}\n')
        (root/'Dockerfile').write_text(f'''FROM {BASE} AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /source
COPY square.c .
RUN gcc -O2 -Wall -Wextra -Werror -static -o /square-static square.c && gcc -O2 -Wall -Wextra -Werror -o /square-dynamic square.c
FROM scratch AS good
COPY --from=builder /square-static /square
USER 65532:65532
ENTRYPOINT ["/square"]
FROM scratch AS bad
COPY --from=builder /square-dynamic /square
USER 65532:65532
ENTRYPOINT ["/square"]
''')
        for tag,target in zip(tags,['builder','good','bad']):docker('build','--target',target,'-t',tag,str(root))
        print('compiler:',docker('run','--rm','--network','none',tags[0],'gcc','--version').stdout.splitlines()[0])
        for argument,expected in [('0','0'),('7','49'),('100','10000')]:
            assert docker('run','--rm','--network','none',tags[1],argument).stdout.strip()==expected
        for argument in ['7x','-1','101','','999999999999999999999999999']:
            invalid=docker('run','--rm','--network','none',tags[1],argument,check=False)
            assert invalid.returncode==2 and 'range 0..100' in invalid.stderr
        invalid=docker('run','--rm','--network','none',tags[1],check=False)
        assert invalid.returncode==2 and 'one integer required' in invalid.stderr
        invalid=docker('run','--rm','--network','none',tags[1],'7','8',check=False)
        assert invalid.returncode==2 and 'one integer required' in invalid.stderr
        good=inspect(tags[1]);builder=inspect(tags[0]);assert good['Size']<builder['Size'] and len(good['RootFS']['Layers'])==1
        assert good['Config']['User']=='65532:65532'
        docker('create','--name',container,tags[1],'7');archive=root/'rootfs.tar';docker('export','-o',str(archive),container)
        with tarfile.open(archive) as contents:
            names={entry.name.removeprefix('./') for entry in contents}
            assert 'square' in names and 'usr/bin/gcc' not in names and 'source/square.c' not in names
        bad=docker('run','--rm','--network','none',tags[2],'7',check=False)
        assert bad.returncode!=0 and 'no such file or directory' in bad.stderr
        print('the static final image runs valid input, rejects invalid input, and contains one application layer without compiler or source')
        print('recorded image sizes:',builder['Size'],'builder bytes;',good['Size'],'final bytes')
        print('the dynamic scratch image builds successfully but cannot start because its interpreter/runtime was not copied')
finally:
    docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False)

The builder compiles the same small C program twice, once statically and once dynamically. The program accepts one integer in the range zero through one hundred and prints its square. Compilation enables warnings and treats them as errors. Named final targets copy only the corresponding executable into scratch and select numeric user and group 65532.

The static final image prints 0, 49, and 10000 for inputs zero, seven, and one hundred. It returns status two for trailing text, negative or out-of-range values, an empty argument, and an integer too large for strtol. Missing or extra arguments also return status two. These checks exercise valid boundaries and rejected inputs in the actual final image.

Inspection confirms one filesystem layer in that final image and the intended nonroot numeric user. Exporting a created container shows /square, with no compiler or source file at the builder’s paths. The export is inspected as an archive; it is not extracted over the host filesystem. This verifies final composition independently of whether the Dockerfile merely looks minimal.

Docker reported 109,490,331 bytes for the builder image and 312,508 bytes for the static final image in the recorded run. These are the engine’s image-size fields, not a measurement of unique physical disk allocation or network transfer time. The useful invariant is that the final image excludes the toolchain and is smaller under the same reported accounting method.

The dynamic final target also builds successfully, but starting it fails with a missing-file error because its interpreter/runtime was not copied. This is the critical negative test. The failure is expected and asserted; it disproves the idea that a successful final COPY automatically tells the builder which runtime libraries should be added.

Validate what the last stage actually runs

Choose an appropriate runtime image for the application. Static linking fits this small calculation, but does not prove that every statically linked service needs no external files or runtime-loaded components. For a dynamic application, supply compatible runtime libraries or a suitable runtime base and execute representative functionality inside the final artifact.

Use named stages and narrow COPY paths so reviewers can see the handoff. Avoid copying a broad builder directory that silently includes tests, credentials, caches, or unused tools. Set working directories, writable paths, user identity, and startup arguments according to the runtime’s real requirements.

Test the final stage after dependency or compiler updates, including errors and less common paths that load extra resources. Keep the selected artifact identity with the results. A smaller image is a useful outcome when it still fulfills the service contract; build success alone cannot establish that contract.

Sources

  • Docker multi-stage builds, named stages, target selection, and copying selected artifacts, checked 2026-09-12.
  • Dockerfile reference, COPY –from, scratch, USER, and exec-form entrypoints, checked 2026-09-12.
  • execve(2), missing executable interpreters and execution errors, checked 2026-09-12.