Lesser-Known Dockerfile Instructions and Flags: Heredocs, SHELL, ADD from Git/URLs, COPY --exclude/--parents/--link and Build Checks
Used carefully, the newer Dockerfile flags make a build both shorter and safer: set -eu in heredocs, --checksum on ADD, --parents and --exclude on COPY, RUN --network=none for publish. With real output we show that the frontend's docker/dockerfile:1 tag was still on 1.27.1 on the day of writing and how behaviour changes with 1.29.0, then combine SHELL with pipefail, ONBUILD, build checks and hadolint in Bookstore.Api's new Dockerfile.
Version note: The examples in this post were run with Docker Engine 29.9.3 (BuildKit 0.34.2), Dockerfile frontend 1.29.0 (1.27.1 for comparison), hadolint 2.15.1, .NET SDK 10.0.404 and go-sqlcmd 1.10.0.
Guess First
The Dockerfile below writes a multi-line RUN as a heredoc. The cp on the third line tries to copy a file that does not exist. What does the build do: (a) stop at the cp line, (b) carry on and finish successfully? Second question: which version of the frontend builds a Dockerfile that says # syntax=docker/dockerfile:1 today?
# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
RUN <<EOT
echo "1: creating /data"
mkdir /data
cp /missing/books.csv /data/
echo "3: done"
EOT
In posts 5 to 7 we covered the basic Dockerfile instructions, cache mounts, secrets and multi-stage builds. This post covers the flags added in recent years that most projects still do not use, and a few old instructions that are rarely seen. At the end we rewrite the API Dockerfile of Bookstore, the online bookstore used as the shared example across this site's series, with them and run it through build checks and hadolint. The lab files are under labs/16-dockerfile/, and labs/16-dockerfile.sh runs all of them.
The syntax Line: Pinning the Frontend Version
The # syntax= directive on the first line tells BuildKit which frontend, the parser that turns a Dockerfile into build steps, should read the file. The frontend is pulled from Docker Hub as an image; without the line, the version bundled with the Engine is used. According to the docs, docker/dockerfile:1 means the latest stable release of the 1.x series. Let us look at where the tags point today:
for t in 1 1.27.1 1.28.0 1.29.0; do
printf "dockerfile:%-7s " $t
docker buildx imagetools inspect docker/dockerfile:$t --format '{{.Manifest.Digest}}' | cut -c1-19
done
dockerfile:1 sha256:4edf897a3ffa
dockerfile:1.27.1 sha256:4edf897a3ffa
dockerfile:1.28.0 sha256:bb22d9815c72
dockerfile:1.29.0 sha256:572cebbc2b15
On the day I wrote this post (15 November 2026), the 1 tag pointed to 1.27.1 from 30 September, even though 1.28.0 (7 October) and 1.29.0 (11 November) had been released. So :1 is not always the newest; it is a moving tag that changes when Docker moves it. The difference matters. According to the 1.29.0 release notes, unknown flags on ARG and STOPSIGNAL are no longer silently ignored:
# syntax=docker/dockerfile:1
FROM alpine:3.24
ARG --required APP_VERSION=1.3.0
RUN echo "version: $APP_VERSION"
# with syntax=docker/dockerfile:1
#7 [2/2] RUN echo "version: 1.3.0"
#7 0.077 version: 1.3.0
# with syntax=docker/dockerfile:1.29.0
ERROR: failed to build: failed to solve: dockerfile parse error on line 3: unknown flag: --required
ARG has no --required flag. 1.27.1 ignores it and the build is green; 1.29.0 reports a parse error. The day :1 moves to 1.29, this Dockerfile breaks without anyone touching it. It works the other way too: 1.27.1 was a security fix (it rejects Dockerfiles larger than 16 MiB), and a Dockerfile pinned to an exact version does not pick up fixes like that on its own.
My preference is to pin the exact version (docker/dockerfile:1.29.0) and update the line deliberately, in the same process as the base images. If you use check=error=true, which we will see below, Docker's docs recommend pinning as well: a new frontend brings new checks, and an unpinned Dockerfile can break one morning because of a new rule. The strictest option is pinning by digest (docker/dockerfile:1.29.0@sha256:...), the same as pinning images in post 4.
Heredocs: Multi-line RUN and Files Inside the Image
Heredocs have been stable since frontend 1.4 (2022). The lines between RUN <<EOT and EOT are passed to the default shell as a script, so you no longer need && \ at the end of each line. Here is the output of the build from Guess First:
#7 [2/2] RUN <<EOT (echo "1: creating /data"...)
#7 0.083 1: creating /data
#7 0.083 cp: can't stat '/missing/books.csv': No such file or directory
#7 0.084 3: done
#7 DONE 0.1s
The answer to the first question is (b): cp failed but the build finished successfully. A heredoc runs like a shell script, and a shell script does not stop on errors by default; the exit code of the step is the exit code of the last command. The && chain did not have this problem, because every command depended on the success of the previous one. The fix is set -eu as the first line of the script (-e stops at the first error, -u treats an undefined variable as an error):
#7 [2/2] RUN <<EOT (set -eu...)
#7 0.084 1: creating /data
#7 0.084 cp: can't stat '/missing/books.csv': No such file or directory
#7 ERROR: process "/bin/sh -c set -eu\necho \"1: creating /data\"\nmkdir /data\ncp /missing/books.csv /data/\necho \"3: done\"\n" did not complete successfully: exit code: 1
With COPY, a heredoc creates a file inside the image that does not exist in the build context: a small entrypoint script or a config file. One rule matters here: if the heredoc delimiter is unquoted (<<EOF), the variables inside are expanded at build time; if it is quoted (<<'EOF'), the text is copied as is and the variables are expanded when the container runs. For scripts you almost always want the second. Unquoted, it looks like this:
# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
COPY <<EOF /usr/local/bin/apply-db.sh
for f in /db/*.sql; do
echo "applying $f to $SQLCMDSERVER"
done
EOF
RUN cat /usr/local/bin/apply-db.sh
#9 [3/3] RUN cat /usr/local/bin/apply-db.sh
#9 0.080 for f in /db/*.sql; do
#9 0.080 echo "applying to "
#9 0.080 done
The loop variable $f and the environment variable $SQLCMDSERVER became empty strings at build time, and the script is broken. The good news is that docker build --check catches it:
Check complete, 2 warnings have been found!
WARNING: UndefinedVar - https://docs.docker.com/go/dockerfile/rule/undefined-var/
Usage of undefined variable '$SQLCMDSERVER'
Dockerfile.heredoc-unquoted:3
WARNING: UndefinedVar - https://docs.docker.com/go/dockerfile/rule/undefined-var/
Usage of undefined variable '$f'
Dockerfile.heredoc-unquoted:3
ADD: URLs, Checksums and Git Sources
In post 5 we recommended COPY for local files and said that ADD has extra jobs such as URL and Git sources. Used properly, those extra jobs are valuable. Picture a small image that runs Bookstore's database scripts (db/*.sql, post 11) as a job, with Microsoft's Go-based sqlcmd inside (labs/16-dockerfile/Dockerfile.dbinit):
# syntax=docker/dockerfile:1.29.0
ARG SQLCMD_VERSION=1.10.0
# one stage per architecture, because every download has its own checksum
FROM scratch AS sqlcmd-amd64
ARG SQLCMD_VERSION
ADD --unpack=true --checksum=sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987 \
https://github.com/microsoft/go-sqlcmd/releases/download/v${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2 /
FROM scratch AS sqlcmd-arm64
ARG SQLCMD_VERSION
ADD --unpack=true --checksum=sha256:9faaa981f9c374f319ac796dedb4678499b8596c87d5b6c512e9b0e7a3b74f8e \
https://github.com/microsoft/go-sqlcmd/releases/download/v${SQLCMD_VERSION}/sqlcmd-linux-arm64.tar.bz2 /
# TARGETARCH picks the stage; BuildKit skips the other one
# hadolint ignore=DL3006
FROM sqlcmd-${TARGETARCH} AS sqlcmd
FROM alpine:3.24 AS final
COPY --from=sqlcmd /sqlcmd /usr/local/bin/sqlcmd
COPY db/ /db/
COPY --chmod=755 <<'EOF' /usr/local/bin/apply-db.sh
#!/bin/sh
set -eu
for f in /db/*.sql; do
echo "applying $f to $SQLCMDSERVER"
sqlcmd -C -b -i "$f"
done
EOF
USER 1000
ENTRYPOINT ["/usr/local/bin/apply-db.sh"]
Three things work together here. --checksum verifies the SHA-256 digest of the downloaded file; if the file behind the URL changes, or someone tampers with it on the way, the build stops. --unpack=true extracts a remote archive; local tar files are extracted by default, remote ones are not. Each architecture has its own file and checksum, so there are two stages, and FROM sqlcmd-${TARGETARCH} picks the right one. The x64 build:
docker build --progress=plain -f labs/16-dockerfile/Dockerfile.dbinit -t dockerseries-bookstore-dbinit:1.3.0 .
#9 [sqlcmd-amd64 1/1] ADD --unpack=true --checksum=sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987 https://github.com/microsoft/go-sqlcmd/releases/download/v1.10.0/sqlcmd-linux-amd64.tar.bz2 /
#9 DONE 1.0s
#11 [final 2/4] COPY --from=sqlcmd /sqlcmd /usr/local/bin/sqlcmd
#12 [final 3/4] COPY db/ /db/
#13 [final 4/4] COPY --chmod=755 <<EOF /usr/local/bin/apply-db.sh
The image works, and set -eu catches the missing environment variable before anything reaches the database (running it against SQL Server works as in posts 11 and 12):
docker run --rm --entrypoint sqlcmd dockerseries-bookstore-dbinit:1.3.0 --version
docker run --rm dockerseries-bookstore-dbinit:1.3.0
sqlcmd: Install/Create/Query SQL Server, Azure SQL, and Tools
Version: v1.10.0
...
/usr/local/bin/apply-db.sh: line 4: SQLCMDSERVER: parameter not set
With one character of the checksum changed (the computed value first, then the expected one):
#9 ERROR: digest mismatch sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987: sha256:92516d98c63e99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
You compute the checksum once yourself (sha256sum, or Get-FileHash in PowerShell), or take it from the release page if the project publishes one. URLs that require authentication need the HTTP header secrets Docker documents for builds, or a RUN curl with a secret mount as in post 6.
For a Git source, the checksum is the commit SHA. A tag can be moved to another commit; a commit cannot change:
# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
# a tag can move; the commit checksum cannot
ADD --checksum=affbc17e1e0e97b0bb625ef8185afea3d746067f https://github.com/microsoft/go-sqlcmd.git#v1.10.0 /src/go-sqlcmd
RUN ls /src/go-sqlcmd | head -5 && test ! -d /src/go-sqlcmd/.git
#7 [2/3] ADD --checksum=affbc17e1e0e97b0bb625ef8185afea3d746067f https://github.com/microsoft/go-sqlcmd.git#v1.10.0 /src/go-sqlcmd
#7 0.643 affbc17e1e0e97b0bb625ef8185afea3d746067f refs/tags/v1.10.0
...
#9 [3/3] RUN ls /src/go-sqlcmd | head -5 && test ! -d /src/go-sqlcmd/.git
#9 0.083 CODE_OF_CONDUCT.md
#9 0.083 Dockerfile
#9 0.083 LICENSE
#9 0.083 NOTICE.md
#9 0.083 README.md
# with one character of the checksum changed
ERROR: failed to build: failed to solve: failed to load cache key: expected checksum to match affbc17e1e0f97b0bb625ef8185afea3d746067f, got affbc17e1e0e97b0bb625ef8185afea3d746067f
The .git folder is not copied by default; use --keep-git-dir=true if you need it. A private repository needs an SSH address and docker build --ssh default (post 6).
New COPY Flags: --parents, --exclude and --link
In .NET projects we copy only the csproj files first, for the restore cache (post 6). Copying them with a wildcard loses the folder structure; --parents, stable since frontend 1.20, keeps it:
# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS flat
WORKDIR /src
COPY src/*/*.csproj tests/*/*.csproj ./
RUN find . -type f | sort
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS parents
WORKDIR /src
COPY --parents Bookstore.slnx global.json src/*/*.csproj tests/*/*.csproj ./
RUN find . -type f | sort
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore Bookstore.slnx
#10 [flat 4/4] RUN find . -type f | sort
#10 0.079 ./Bookstore.Api.csproj
#10 0.079 ./Bookstore.Tools.Tests.csproj
#10 0.079 ./Bookstore.Tools.csproj
#10 [parents 4/5] RUN find . -type f | sort
#10 0.073 ./Bookstore.slnx
#10 0.073 ./global.json
#10 0.073 ./src/Bookstore.Api/Bookstore.Api.csproj
#10 0.073 ./src/Bookstore.Tools/Bookstore.Tools.csproj
#10 0.073 ./tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj
#11 [parents 5/5] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages dotnet restore Bookstore.slnx
#11 4.186 Restored /src/src/Bookstore.Tools/Bookstore.Tools.csproj (in 3.55 sec).
#11 4.619 Restored /src/src/Bookstore.Api/Bookstore.Api.csproj (in 4 sec).
#11 4.781 Restored /src/tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj (in 4.16 sec).
The plain COPY put the three csproj files into /src as a flat list, and the paths Bookstore.slnx expects are gone. With --parents the structure stays and one command restores the whole solution; you no longer need a separate COPY line per csproj.
--exclude, stable since frontend 1.19, is the instruction-level counterpart of .dockerignore. .dockerignore applies to the whole build context, and in our repository the API, Tools and tests share one context. Because Bookstore.Api's Dockerfile lives in src/Bookstore.Api/, even fixing a comment in the Dockerfile changes the COPY of that folder and publish runs again. Adding a comment line to the end of the Dockerfile from post 15 and building again gives:
#14 [build 6/7] COPY src/Bookstore.Api/ src/Bookstore.Api/
#14 DONE 0.0s
#15 [build 7/7] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -c Release -a amd64 --no-restore -o /app -p:Version=1.0.0
#15 DONE 1.4s
The new Dockerfile leaves out the files that only matter on a developer machine (paths are relative to the source folder):
COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json \
src/Bookstore.Api/ src/Bookstore.Api/
#10 [build 5/6] COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json src/Bookstore.Api/ src/Bookstore.Api/
#10 CACHED
#16 [build 6/6] RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF (set -eu...)
#16 CACHED
After the same change, publish came from the cache. On top of that, appsettings.Development.json no longer enters the build stage at all, a second safeguard next to the csproj setting from post 8. The alternative is a Dockerfile.dockerignore file next to the Dockerfile that applies only to that Dockerfile; but it replaces the .dockerignore at the root instead of adding to it.
--link (since 1.4) produces the copied files as a layer that does not depend on the layers before it. According to the docs this pays off in two places: when the base image is updated, the application layer can be placed on top of the new base without being rebuilt, and on push BuildKit can send only the new layers without pulling the base image's layers. The price is that a COPY --link cannot read files from the previous state. The most common consequence:
# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled
COPY --link --chown=app:app data/books.csv /app/data/
# 1.29.0
ERROR: failed to build: failed to solve: --chown=app:app: user and group names can't be resolved when used with --link, use numeric uid[:gid] instead
# the same file with 1.27.1
ERROR: failed to build: failed to solve: failed to load cache key: invalid user index -1
Turning the name app into a uid means reading /etc/passwd from the base image, and --link does not allow that. The 1.28.0 release notes say this combination is now detected better; 1.29.0 gives a clear message, 1.27.1 a cryptic one. The fix is a numeric id: --chown=1654:1654 (the APP_UID from post 8). We already used --chmod for the nginx script in post 10.
SHELL, VOLUME, ONBUILD and LABEL
SHELL changes the shell that runs the shell form of RUN, CMD and ENTRYPOINT. Its most important use on Linux is pipes: with the default /bin/sh -c, the exit code of a pipe is the exit code of its last command.
# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/sdk:10.0
# the script name has a typo: curl gets a 404
RUN curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0
RUN dotnet --list-sdks
#7 [2/3] RUN curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0
#7 0.425 curl: (22) The requested URL returned error: 404
#7 DONE 0.4s
#8 [3/3] RUN dotnet --list-sdks
#8 0.093 10.0.404 [/usr/share/dotnet/sdk]
#8 DONE 0.1s
curl got a 404, bash ran the empty input, returned 0, and the build is green. We believe the .NET 11 SDK is installed; it is not in the list. With SHELL ["/bin/bash", "-o", "pipefail", "-c"] under FROM, the same step stops:
#7 0.232 curl: (22) The requested URL returned error: 404
#7 ERROR: process "/bin/bash -o pipefail -c curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0" did not complete successfully: exit code: 22
In Windows images the main job of SHELL is switching to PowerShell; we will see that in the next post.
Try not to use VOLUME in application images: every container started from such an image creates an anonymous volume (post 11), and derived images cannot remove the definition (FROM --reset=volume exists only in the labs frontend for now). Passing the volume with -v at run time is clearer.
ONBUILD adds a trigger to the image that says "run this in the build of any image derived from me":
# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
WORKDIR /seed
# these run later, in every image built FROM this one
ONBUILD COPY data/books.csv ./
ONBUILD RUN wc -l books.csv
# syntax=docker/dockerfile:1.29.0
FROM dockerseries-onbuild-base:1.0
RUN ls /seed
#8 [2/4] ONBUILD COPY data/books.csv ./
#9 [3/4] ONBUILD RUN wc -l books.csv
#9 0.088 7 books.csv
#10 [4/4] RUN ls /seed
#10 0.102 books.csv
Two steps that are not in the child Dockerfile ran. It looks handy, but whoever reads the Dockerfile cannot see what runs, and when the base image changes, the child's build steps change too. The BUILDKIT_DISABLE_ONBUILD build argument in 1.29.0 turns these triggers off in every stage:
docker build --build-arg BUILDKIT_DISABLE_ONBUILD=1 -f labs/16-dockerfile/Dockerfile.onbuild-child .
#7 [2/2] RUN ls /seed
With the child's syntax line set to 1.27.1, the same argument had no effect and the triggers ran again. One more reason to pin the frontend.
We saw LABEL replacing MAINTAINER in post 15. Registries and scanners read labels written with the Open Container Initiative names (org.opencontainers.image.version, .revision, .source); below we add the commit to Bookstore.Api as well.
Build Checks and hadolint
Frontend 1.29.0 has 22 build check rules, one of them experimental. By default findings are warnings and the build continues; the # check=error=true directive turns them into errors that stop the build. Bookstore.Api's new Dockerfile has this line; when someone writes the stage name with a lowercase as:
- FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 5)
ERROR: failed to build: failed to solve: lint violation found for rules: FromAsCasing
To turn off specific rules you use skip (# check=skip=JSONArgsRecommended;error=true); in CI the same setting can be passed with --build-arg BUILDKIT_DOCKERFILE_CHECK=....
hadolint is an open source linter that checks a Dockerfile independently of Docker; it also looks inside RUN lines with ShellCheck. We put a .hadolint.yaml at the repository root (failure-threshold: warning, so info-level findings do not fail the build) and ran it as a container:
for f in labs/16-dockerfile/Dockerfile.pipefail labs/15-legacy/Dockerfile.2021 src/Bookstore.Api/Dockerfile; do
echo "== $f"
docker run --rm -i -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml:ro" hadolint/hadolint:v2.15.1 hadolint --no-color - < "$f"
done
== labs/16-dockerfile/Dockerfile.pipefail
-:4 DL4006 warning: Set the SHELL option -o pipefail before RUN with a pipe in it. ...
== labs/15-legacy/Dockerfile.2021
-:2 DL4000 error: MAINTAINER is deprecated
-:6 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
-:13 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments
== src/Bookstore.Api/Dockerfile
-:34 DL3066 info: Non-numeric user-id may not be resolvable by host system
In post 15 the build check reported MaintainerDeprecated, LegacyKeyValueFormat and JSONArgsRecommended for the same 2021 Dockerfile. The two tools partly overlap and partly see different things:
| Case | docker build --check | hadolint 2.15.1 |
|---|---|---|
MAINTAINER, shell form ENTRYPOINT | Warning | DL4000, DL3025 |
Old ENV key value format | LegacyKeyValueFormat | None |
RUN with a pipe, no pipefail | None | DL4006 |
Consecutive RUNs | None | DL3059 (info) |
| Undefined variable in an unquoted heredoc | UndefinedVar | None |
| Where the rules come from | Docker's own parser, base image config | Its own parser and ShellCheck |
Using both makes sense. I hit two rough edges in hadolint 2.15.1. If a new instruction follows the heredoc's closing line directly, it reports a parse error (unexpected 'U' expecting a new line followed by the next instruction); that is why there is an empty line after EOF in Dockerfile.dbinit. It also mistakes a stage name with a variable, such as FROM sqlcmd-${TARGETARCH}, for an untagged image (DL3006); we added # hadolint ignore=DL3006 to that line. The DL3066 in the API is about USER $APP_UID; the value is numeric (1654) in the .NET images.
The New Bookstore.Api Dockerfile
# syntax=docker/dockerfile:1.29.0
# check=error=true
ARG DOTNET_VERSION=10.0
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION=1.0.0
WORKDIR /src
COPY --parents global.json src/Bookstore.Api/*.csproj ./
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore src/Bookstore.Api/Bookstore.Api.csproj -a $TARGETARCH
# files that only matter on a developer machine never reach the build stage
COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json \
src/Bookstore.Api/ src/Bookstore.Api/
# publish needs no network: every package came from the restore step
RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF
set -eu
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -c "$BUILD_CONFIGURATION" -a "$TARGETARCH" \
--no-restore -o /app -p:Version="$APP_VERSION"
test -f /app/Bookstore.Api.dll
test ! -e /app/appsettings.Development.json
EOF
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled-extra AS final
ARG APP_VERSION=1.0.0
ARG GIT_SHA=unknown
WORKDIR /app
COPY --link --from=build /app ./
LABEL org.opencontainers.image.title="Bookstore.Api" \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$GIT_SHA
EXPOSE 8080
USER $APP_UID
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["dotnet", "Bookstore.Api.dll", "healthcheck"]
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
Compared with the file from post 15: the frontend is pinned to 1.29.0 and checks are errors; the csproj is copied with --parents; developer files stay out with --exclude; publish runs in a heredoc with set -eu and two checks (is the dll there, did the development settings leak); the copy into the final stage uses --link; and the commit SHA is added to the image as a label.
The --network=none on publish is deliberate: restore needs the network, publish does not. Every package was downloaded in the previous step, so publish has no reason to go to the internet; if a package or an MSBuild task tries to download something during the build, the step fails. Here is what happens when the same flag is put on restore:
#10 6.123 /src/src/Bookstore.Api/Bookstore.Api.csproj : error NU1301: Unable to load the service index for source https://api.nuget.org/v3/index.json.
#10 6.123 /src/src/Bookstore.Api/Bookstore.Api.csproj : error NU1301: Resource temporarily unavailable (api.nuget.org:443)
Check and build for x64:
docker build --check -f src/Bookstore.Api/Dockerfile .
docker build --progress=plain -f src/Bookstore.Api/Dockerfile \
--build-arg APP_VERSION=1.3.0 --build-arg GIT_SHA=3f9c2e1 -t dockerseries-bookstore-api:1.3.0 .
Check complete, no warnings found.
...
#12 [build 3/6] COPY --parents global.json src/Bookstore.Api/*.csproj ./
#13 [build 4/6] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages dotnet restore src/Bookstore.Api/Bookstore.Api.csproj -a amd64
#13 1.425 Restored /src/src/Bookstore.Api/Bookstore.Api.csproj (in 970 ms).
#14 [build 5/6] COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json src/Bookstore.Api/ src/Bookstore.Api/
#15 [build 6/6] RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF (set -eu...)
#15 1.262 Bookstore.Api -> /src/src/Bookstore.Api/bin/Release/net10.0/linux-x64/Bookstore.Api.dll
#15 1.280 Bookstore.Api -> /app/
#16 [final 3/3] COPY --link --from=build /app ./
The image works as before:
docker run -d --name dockerseries-api -p 127.0.0.1:8080:8080 dockerseries-bookstore-api:1.3.0
curl http://127.0.0.1:8080/health
docker image inspect dockerseries-bookstore-api:1.3.0 --format '{{json .Config.Labels}}'
Healthy
{"org.opencontainers.image.revision":"3f9c2e1","org.opencontainers.image.title":"Bookstore.Api","org.opencontainers.image.version":"1.3.0"}
In docker-bake.hcl the default tag is now 1.3.0, and the api target passes the new GIT_SHA variable as an argument; in CI that is GIT_SHA=$(git rev-parse --short HEAD) docker buildx bake api. The lint group from post 6 reports "no warnings found" for all three Dockerfiles.
In Production
Upgrade the frontend as a separate change: bump the syntax line, run docker buildx bake lint and the full build in CI, and read the release notes. Breakages like the ARG example in 1.29.0 then show up in a pull request rather than on deploy day.
Every file you download with ADD is now part of your supply chain. The checksum guarantees that the file has not changed, but it makes updates manual: change the version and the checksum in the same commit. Image scanning is covered in the DevSecOps series post "Container and IaC Scanning: Image Vulnerabilities, Base Image Choice and Manifest Scanning".
Put --network=none on every step that does not need to download packages: publish, tests, code generation. It also makes it harder for a malicious package running during the build to send data out.
Run build checks and hadolint in CI as a separate step before the build; both take seconds. When you turn a finding off with skip or # hadolint ignore=, write down why next to it.
Common Pitfalls
Forgetting set -eu in a heredoc. It is the most common mistake when moving from an && chain to a heredoc; the build in Guess First finished green.
Writing a script in a COPY heredoc with an unquoted delimiter. Variables such as $f become empty at build time; use <<'EOF' for scripts.
Using ADD for local files out of habit. A local tar archive is extracted silently; if you only want to copy it, use COPY.
Losing the error of a pipe in RUN a | b. Turn on pipefail with SHELL or split the pipe into two steps.
Never pinning the syntax line, or pinning it and never updating it. The first brings a surprise breakage one day; the second misses security fixes.
A Small Experiment
★☆☆ Reduce the two csproj COPY lines in the restore stage of src/Bookstore.Tools/Dockerfile to a single line with --parents. When you change Bookstore.Api.csproj, does the Tools restore step still come from the cache? If not, how would you narrow the wildcard?
★★☆ Run the dockerseries-bookstore-dbinit image against the SQL Server container on the network from post 12. Pass the password from a file as in post 13 instead of an environment variable, and change the script accordingly.
★★★ Set the syntax line of the Bookstore.Api Dockerfile to docker/dockerfile:1-labs and try FROM --reset: can you reset an ENV or a label from the base image? Then drop the line to 1.27.1 and compare the docker build --check output.
Everything in this post relied on BuildKit and Linux containers. In the next post we move to the containers of the operating system on most readers' desks: putting an old back office written with .NET Framework 4.8 and IIS into a Windows container, Server Core and Nano Server, isolation modes, and why there are no heredocs or cache mounts there.