Mert Özen Further With Every Line
DevOps Docker — #05

Dockerfile Basics: FROM, RUN, COPY, WORKDIR, ENV, ARG, USER, EXPOSE, CMD and ENTRYPOINT

Mert Özen Nov 14, 2026 13 min 11 views
Dockerfile Basics: FROM, RUN, COPY, WORKDIR, ENV, ARG, USER, EXPOSE, CMD and ENTRYPOINT

In a Dockerfile, RUN, COPY, ADD and WORKDIR touch the file system and produce layers; ENV, ARG, USER, EXPOSE, CMD and ENTRYPOINT are only written to the image config. We write the first Dockerfile for Bookstore.Tools, catch the draft's mistakes with docker build --check, and see with real output how ENTRYPOINT and CMD combine, how a token passed as ARG shows up in docker history, what .dockerignore changes, why EXPOSE opens no port, and the apt-get cache trap.

Version note: The examples in this post were run with Docker Engine 29.9.3 (BuildKit, Dockerfile 1 frontend), the mcr.microsoft.com/dotnet/sdk:10.0 image (SDK 10.0.404) and Microsoft.Extensions.Hosting 10.0.15.

Guess First

A Dockerfile declares ARG NUGET_TOKEN and uses it in a RUN line to pull packages from a private NuGet feed. The image was built and pushed to a registry; you left the token neither in the Dockerfile nor in the image's file system. Can someone who pulls the image see the token? Second question: the Dockerfile has EXPOSE 8080. If you start the container without passing -p to docker run, can you connect to 8080 from the host?

Bookstore.Tools and the First Draft

In post 3 we moved Bookstore.Tools, the catalog import tool of Bookstore, the online bookstore used as the shared example across this site's series, onto the Generic Host. In this post we gave it real work: it reads the books in data/books.csv, validates the ISBN-13 check digit, skips invalid rows and imports the rest in batches. The settings come from configuration:

namespace Bookstore.Tools;

public sealed class ImportOptions
{
    public const string SectionName = "Import";

    public string File { get; set; } = "data/books.csv";
    public int BatchSize { get; set; } = 2;
    public int DelayMs { get; set; } = 1000;
}
builder.Services.Configure<ImportOptions>(builder.Configuration.GetSection(ImportOptions.SectionName));

Because the Generic Host's configuration reads environment variables and command-line arguments, the same setting can be given to a container in two ways: the Import__BatchSize=5 environment variable or the --Import:BatchSize=5 argument. Both will help us show the difference between ENV and CMD shortly.

Emre's first Dockerfile draft looks like this:

FROM mcr.microsoft.com/dotnet/sdk:10.0 as build
ARG NUGET_TOKEN
WORKDIR /src
COPY . .
RUN dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -c Release -o /app
WORKDIR /app
ENV Import__File $IMPORT_FILE
EXPOSE 8080
CMD dotnet Bookstore.Tools.dll

It builds and runs, but it contains five problems. The easy way to catch them before building is BuildKit's build checks: since Dockerfile frontend 1.8 and Buildx 0.15, docker build --check lints the Dockerfile against a set of rules without producing an image:

docker build --check -f labs/05-build-check/Dockerfile.first .
echo "check exit code: $?"
Check complete, 5 warnings have been found!

WARNING: FromAsCasing - https://docs.docker.com/go/dockerfile/rule/from-as-casing/
'as' and 'FROM' keywords' casing do not match
labs/05-build-check/Dockerfile.first:1
   1 | >>> FROM mcr.microsoft.com/dotnet/sdk:10.0 as build

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NUGET_TOKEN")
labs/05-build-check/Dockerfile.first:2
   2 | >>> ARG NUGET_TOKEN

WARNING: UndefinedVar - https://docs.docker.com/go/dockerfile/rule/undefined-var/
Usage of undefined variable '$IMPORT_FILE'
labs/05-build-check/Dockerfile.first:7
   7 | >>> ENV Import__File $IMPORT_FILE

WARNING: LegacyKeyValueFormat - https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
"ENV key=value" should be used instead of legacy "ENV key value" format
labs/05-build-check/Dockerfile.first:7
   7 | >>> ENV Import__File $IMPORT_FILE

WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
labs/05-build-check/Dockerfile.first:9
   9 | >>> CMD dotnet Bookstore.Tools.dll
check exit code: 1

I shortened the line blocks under each warning. --check exits with a non-zero code when it finds warnings, which is all you need to run this step in CI before building. If you want warnings to stop a regular build, add the check=error=true directive at the top of the Dockerfile, below the syntax line. The checks do not catch the remaining problems, COPY . . and EXPOSE; we will get to them below.

Instructions: Which Are Layers and Which Are Metadata?

The corrected Dockerfile lives in src/Bookstore.Tools/Dockerfile; the build context is the repository root:

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0

ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION=1.0.0

WORKDIR /src
COPY Bookstore.slnx ./
COPY src/ src/
RUN dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -c $BUILD_CONFIGURATION -o /app -p:Version=$APP_VERSION

WORKDIR /app
COPY --chown=app:app data/books.csv data/books.csv

LABEL org.opencontainers.image.title="Bookstore.Tools" \
      org.opencontainers.image.version=$APP_VERSION
ENV DOTNET_ENVIRONMENT=Production \
    Import__File=data/books.csv

USER app
ENTRYPOINT ["dotnet", "Bookstore.Tools.dll"]
CMD ["--Import:DelayMs=500"]
docker build --check -f src/Bookstore.Tools/Dockerfile .
docker build -f src/Bookstore.Tools/Dockerfile -t dockerseries-bookstore-tools:1.0.0 .
Check complete, no warnings found.
...
#7 [1/7] FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:fe0fd4e93bd2fbb47c883b7a6369e5c8d3db8bf01cdc5981f6ee35fbdf3ff4fb
#8 [2/7] WORKDIR /src
#9 [3/7] COPY Bookstore.slnx ./
#10 [4/7] COPY src/ src/
#11 [5/7] RUN dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -c Release -o /app -p:Version=1.0.0
#11 4.835   Restored /src/src/Bookstore.Tools/Bookstore.Tools.csproj (in 4.39 sec).
#11 5.513   Bookstore.Tools -> /app/
#11 DONE 5.6s
#12 [6/7] WORKDIR /app
#13 [7/7] COPY --chown=app:app data/books.csv data/books.csv
#14 naming to docker.io/library/dockerseries-bookstore-tools:1.0.0 done

The # syntax=docker/dockerfile:1 on the first line is not a comment but a parser directive: it tells BuildKit to interpret the Dockerfile with the latest frontend of the 1.x series, so new features arrive without upgrading Docker Engine. BuildKit shows the build as seven steps, but it does not produce seven layers. We can see that in the image's history:

docker history dockerseries-bookstore-tools:1.0.0
IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
f13daf0ef57b   47 seconds ago       CMD ["--Import:DelayMs=500"]                    0B        buildkit.dockerfile.v0
<missing>      47 seconds ago       ENTRYPOINT ["dotnet" "Bookstore.Tools.dll"]     0B        buildkit.dockerfile.v0
<missing>      47 seconds ago       USER app                                        0B        buildkit.dockerfile.v0
<missing>      47 seconds ago       ENV DOTNET_ENVIRONMENT=Production Import__Fi…   0B        buildkit.dockerfile.v0
<missing>      47 seconds ago       LABEL org.opencontainers.image.title=Booksto…   0B        buildkit.dockerfile.v0
<missing>      47 seconds ago       COPY --chown=app:app data/books.csv data/boo…   16.4kB    buildkit.dockerfile.v0
<missing>      47 seconds ago       WORKDIR /app                                    4.1kB     buildkit.dockerfile.v0
<missing>      47 seconds ago       RUN |2 BUILD_CONFIGURATION=Release APP_VERSI…   35.8MB    buildkit.dockerfile.v0
<missing>      50 seconds ago       COPY src/ src/ # buildkit                       2.93MB    buildkit.dockerfile.v0
<missing>      50 seconds ago       COPY Bookstore.slnx ./ # buildkit               12.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /src                                    8.19kB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG APP_VERSION=1.0.0                           0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ARG BUILD_CONFIGURATION=Release                 0B        buildkit.dockerfile.v0
<missing>      3 days ago           RUN /bin/sh -c powershell_version=7.6.10    …   53.1MB    buildkit.dockerfile.v0
...

The rule is simple: instructions that touch the file system, RUN, COPY, ADD and WORKDIR, produce layers; the rest, ENV, ARG, LABEL, USER, EXPOSE, ENTRYPOINT and CMD, are only written to the image config and show up as 0 B. FROM takes all the layers of the base image; the 53.1 MB PowerShell installation at the bottom of the list is a layer of the SDK image itself. All of them in brief:

  • FROM: the starting image. Here the SDK, because we build inside the container.
  • WORKDIR: the working directory for the following instructions, created if missing. Use it instead of RUN cd ..., because every RUN is a new shell.
  • COPY: copies files from the build context into the image; --chown sets the owner while copying.
  • RUN: runs a command during the build, and the result becomes a layer.
  • ARG: a variable valid only during the build, changed with --build-arg.
  • ENV: an environment variable written into the image and valid in the container too.
  • LABEL: metadata; the OCI's org.opencontainers.image.* keys are common names that tools recognise.
  • USER: subsequent RUN steps and the container's process run as this user. The app user that ships with .NET images has uid 1654.
  • ENTRYPOINT and CMD: the command run when the container starts, and its default arguments.

The size of this image is a separate topic: because it carries the SDK, the source code and the intermediate build files, it takes 1.42 GB on disk, measured on an arm64 machine and of the same order on amd64. The image that runs in production should not contain a compiler; we will take the multi-stage structure from post 31 of the ASP.NET Core Web API series further with Bookstore in post 7. In this post we used a single FROM on purpose, to see the instructions in one stage.

Shell Form, Exec Form, ENTRYPOINT and CMD

RUN, CMD and ENTRYPOINT can be written in two forms. The shell form, CMD dotnet Bookstore.Tools.dll, hands the command to /bin/sh -c; the exec form, CMD ["dotnet", "Bookstore.Tools.dll"], runs the program directly. In post 3 we saw that a shell running as PID 1 swallows SIGTERM and that docker stop then waits 10 seconds and ends with 137; that is the reason for the JSONArgsRecommended warning. I also built the draft as dockerseries-bookstore-tools:draft; let's look at the config of both images:

docker image inspect dockerseries-bookstore-tools:draft --format "draft: Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}} User={{.Config.User}}"
docker image inspect dockerseries-bookstore-tools:1.0.0 --format "1.0.0: Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}} User={{.Config.User}}"
draft: Entrypoint=null Cmd=["/bin/sh","-c","dotnet Bookstore.Tools.dll"] User=
1.0.0: Entrypoint=["dotnet","Bookstore.Tools.dll"] Cmd=["--Import:DelayMs=500"] User=app

When both are used, Docker joins them into the command to run as ENTRYPOINT + CMD: ENTRYPOINT is the fixed part, CMD the replaceable default arguments. Everything you type after the image name in docker run replaces CMD. The draft image has no ENTRYPOINT, so the argument replaces the whole command:

docker run --rm dockerseries-bookstore-tools:draft --Import:BatchSize=5
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "--Import:BatchSize=5": executable file not found in $PATH

In the corrected image the same argument replaces CMD, and dotnet Bookstore.Tools.dll --Import:BatchSize=5 runs. Since --Import:DelayMs=500 from CMD is gone as well, the delay falls back to the default 1000 ms:

docker run --rm dockerseries-bookstore-tools:1.0.0
docker run --rm dockerseries-bookstore-tools:1.0.0 --Import:BatchSize=5
10:51:49 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
10:51:49 info: Microsoft.Hosting.Lifetime[0] Hosting environment: Production
10:51:49 info: Microsoft.Hosting.Lifetime[0] Content root path: /app
10:51:49 warn: Bookstore.Tools.ImportWorker[0] Skipping Pride and Prejudice: invalid ISBN-13 9780141439510
10:51:49 info: Bookstore.Tools.ImportWorker[0] Importing 5 books from data/books.csv in 3 batches
10:51:50 info: Bookstore.Tools.ImportWorker[0] Batch 1/3 imported: Dune, Emma
10:51:50 info: Bookstore.Tools.ImportWorker[0] Batch 2/3 imported: The Hobbit, Middlemarch
10:51:51 info: Bookstore.Tools.ImportWorker[0] Batch 3/3 imported: Crime and Punishment
10:51:51 info: Bookstore.Tools.ImportWorker[0] Import finished
10:51:51 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
...
10:52:21 info: Bookstore.Tools.ImportWorker[0] Importing 5 books from data/books.csv in 1 batches
10:52:22 info: Bookstore.Tools.ImportWorker[0] Batch 1/1 imported: Dune, Emma, The Hobbit, Middlemarch, Crime and Punishment

You can override ENTRYPOINT too without changing the image; it is the most practical way to look inside: docker run --rm --entrypoint ls dockerseries-bookstore-tools:1.0.0 -ln data. An environment variable is passed with -e and overrides the default from ENV; when the file is missing, the tool logs the error and exits with 1:

docker run --rm -e Import__File=data/missing.csv dockerseries-bookstore-tools:1.0.0
echo "exit code: $?"
10:52:22 fail: Bookstore.Tools.ImportWorker[0] Cannot read import file: Could not find file '/app/data/missing.csv'.
exit code: 1

A practical rule: the program that does not change goes into ENTRYPOINT, arguments users will often change go into CMD, and settings that vary by environment go into ENV, overridden with -e at run time.

ARG and ENV: Build Time, Run Time and Secrets

ARG is valid during the build; ENV is valid both during the build and in the container. That is why anything written to ENV can be read with docker inspect and seen from inside the container; it is obvious that a secret does not belong there. ARG is often believed to be safer because it is not visible in the container. A small Dockerfile for the first question:

FROM alpine:3
ARG NUGET_TOKEN
RUN echo "restoring with a token of ${#NUGET_TOKEN} characters"
RUN echo "this step does not use the token"
docker build --build-arg NUGET_TOKEN=fake-token-for-demo -t dockerseries-argsecret labs/05-arg-secret
docker history --no-trunc --format "{{.CreatedBy}}" dockerseries-argsecret | head -4
RUN |1 NUGET_TOKEN=fake-token-for-demo /bin/sh -c echo "this step does not use the token" # buildkit
RUN |1 NUGET_TOKEN=fake-token-for-demo /bin/sh -c echo "restoring with a token of ${#NUGET_TOKEN} characters" # buildkit
ARG NUGET_TOKEN=fake-token-for-demo
CMD ["/bin/sh"]

The answer: yes. The token was not written to any file, but every RUN line after the ARG was recorded in the image history together with the build arguments, even the second RUN that does not use the token. Anyone who can pull the image reads it with docker history. The RUN |2 BUILD_CONFIGURATION=Release APP_VERSI… line in our own image's history above is the same mechanism; there it does no harm because the values are not secret. The right way to give a secret to the build is BuildKit's RUN --mount=type=secret: the secret is mounted as a file only for the duration of that RUN and is written to neither a layer nor the history. We set it up with the private NuGet feed example in the next post.

COPY, ADD and .dockerignore

COPY copies files from the build context into the image, and that is all it does. ADD does the same plus two more things: the source can be a URL or a Git repository, and a local tar archive is extracted. The ADD alpine-minirootfs-3.24.4-x86_64.tar.gz / line we saw in post 4 used that second feature. Docker's recommendation is to use COPY unless you really need those extra behaviours, so that a file is not silently extracted or downloaded from outside by surprise.

COPY --chown=app:app sets the owner while copying. Copying and then writing RUN chown -R seems to give the same result, but it produces a second copy of the files in a new layer and the image grows. The result:

docker run --rm --entrypoint ls dockerseries-bookstore-tools:1.0.0 -ln data
total 4
-rw-r--r-- 1 1654 1654 314 Nov 14 10:50 books.csv

The file belongs to the app user, 1654; without --chown it would belong to uid 0, root, and the tool running as app could read it but not write a checkpoint file next to it.

The build context is the directory given as the last argument of docker build, . here; COPY can only copy from there. .dockerignore says what is never sent from that directory. In the history above, the COPY src/ src/ layer was 2.93 MB: there was no .dockerignore at that point, and the bin/ and obj/ folders I had built on the host went into the image too. I added this to the repository root:

**/bin/
**/obj/
out/
.git/
.vs/
.vscode/
*.md
docker history dockerseries-bookstore-tools:1.0.0 | grep "COPY src"
<missing>      6 seconds ago        COPY src/ src/ # buildkit                       45.1kB    buildkit.dockerfile.v0

From 2.93 MB to 45.1 kB. More important than size is correctness: in the ASP.NET Core Web API series post "Containerizing an ASP.NET Core Web API with Docker and Setting Up CI/CD with GitHub Actions" we saw the build break with NETSDK1064 when the host's obj/ folder overwrote the files produced by the restore in the container. The draft's COPY . . is the widest version of this: it can carry everything from the .git folder to a developer's local .env file into the image. Write explicitly what you copy, and make .dockerignore one of the first files in every project.

EXPOSE Does Not Open a Port

The answer to the second question is short too: no. EXPOSE puts a note into the image config saying "this image listens on this port", and does nothing else. The nginx image has EXPOSE 80:

docker run -d --name dockerseries-web nginx:1.30-alpine
docker port dockerseries-web
curl -sS -m 3 localhost:80
curl: (7) Failed to connect to localhost:80 after 0 ms: Could not connect to server

docker port returned nothing, and there is no connection from the host. What publishes a port is -p. The only practical effect of EXPOSE is with the -P flag, capital P: Docker binds every exposed port to a random port on the host:

docker run -d -P --name dockerseries-web nginx:1.30-alpine
docker port dockerseries-web
80/tcp -> 0.0.0.0:32768
80/tcp -> [::]:32768

The draft's EXPOSE 8080 was therefore wrong twice: the tool listens on no port, and even if it did, that line would not open one. Think of EXPOSE as documentation for whoever uses the image; we will use it for HTTP services in post 8.

In Production

The image in this post is single-stage on purpose: with the SDK, the source code and the intermediate build files it is over 1 GB. A production image should contain only the runtime and the publish output; we will do that with a multi-stage build in post 7 and choose the .NET-specific image in post 8.

Choose your base image deliberately and fix its tag. FROM mcr.microsoft.com/dotnet/sdk:10.0 changes with every patch; that is usually what you want, for the security fixes, but if you want reproducible builds use a digest pin with automated updates as in post 4.

Put docker build --check into CI. This step, which takes seconds, catches a shell-form CMD, ARGs named like secrets and undefined variables before an image is produced. Running the container as a non-root user, USER app, should be the default as well; we will look at the reasons in detail in post 13.

Common Pitfalls

Writing apt-get update and apt-get install in separate RUN lines. The build cache stores each line separately. When you add a new package to the install line, the update line has not changed, so it comes from the cache:

FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl jq
#5 [2/3] RUN apt-get update
#5 CACHED
#6 [3/3] RUN apt-get install -y --no-install-recommends curl jq

The package list stays as it was on the day of the first build; weeks later the install can break with a 404 while trying to download a package version that is no longer in the repository, or it installs an old version. The fix is to combine the two in one RUN and delete the list in the same layer:

FROM ubuntu:24.04
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl jq \
    && rm -rf /var/lib/apt/lists/*

Creating a file in one RUN and deleting it in the next. The delete only writes a "deleted" marker into a new layer; the file stays in the layer below and the image does not shrink. Clean up inside the RUN that produces the file.

Passing secrets with ARG or ENV. As we saw above, both stay in the image.

Writing RUN cd /app instead of WORKDIR. Every RUN runs in a new shell; the cd does not carry over to the next line.

A Small Experiment

★☆☆ Fix the five warnings in the draft Dockerfile one by one and run docker build --check after each fix. Which warning came from which line?

★★☆ Prepare a variant of the Dockerfile with COPY data/books.csv data/books.csv followed by RUN chown app:app data/books.csv. Compare the number and size of layers of the two approaches with docker history.

★★★ Instead of baking data/books.csv into the image, try two ways of providing it at run time: mounting a file with -v and changing the path with -e Import__File=.... Write down which is the better fit for which scenario: development, CI or production.

Our first Dockerfile is ready and we know what each instruction adds to the image. In the next post we make the build faster: layer order and cache invalidation, cache mounts for NuGet and npm, restoring from a private feed without leaking the secret into the image, and docker buildx bake to build several images with one command.