Writing a Dockerfile
By the end of this lesson
Describe how to build an image, instruction by instruction.
A Dockerfile is a plain text file listing the instructions that build an image. Docker reads it top to bottom. Each instruction either adds files to the image being built or records a piece of metadata for later.
The first instruction is always FROM, which names the image you are starting from. You are rarely starting from nothing: you pick a base image that already contains an operating system and usually a language runtime, then add your application to it.
The example below builds the employees API in one stage. It is deliberately the simple version — it works, and it ships a compiler to production, which the Multi-Stage Builds lesson fixes. Learn the instructions here first.
# syntax=docker/dockerfile:1
# An image that already contains the .NET SDK, so it can compile
FROM mcr.microsoft.com/dotnet/sdk:9.0
# Where the following instructions run, created if it does not exist
WORKDIR /src
# Copy the project file on its own, then fetch NuGet packages
COPY EmployeesApi.csproj ./
RUN dotnet restore
# Now the source, then a release build written to /app
COPY . ./
RUN dotnet publish -c Release -o /app --no-restore
# A default this image can reasonably carry
ENV ASPNETCORE_HTTP_PORTS=8080
# Documentation for whoever runs the image
EXPOSE 8080
# Stop being root
RUN useradd --system --uid 1001 apiuser
USER apiuser
# What the container runs when it starts
ENTRYPOINT ["dotnet", "/app/EmployeesApi.dll"]- FROM names the base image, including its tag. Pinning to 9.0 rather than leaving it off means you know which SDK compiled the application.
- WORKDIR sets the directory for every instruction that follows, and for the container when it starts. It saves writing absolute paths everywhere.
- COPY takes files from the build context — the folder you point the build at — and puts them in the image. Copying the project file before the source looks fussy; the Layers and Build Caching lesson explains why it saves minutes.
- RUN executes a command while the image is being built, and whatever it changed is kept in the image. It does not run when a container starts. This catches nearly everyone once.
- ENV sets an environment variable that exists for the rest of the build and inside the running container. ASPNETCORE_HTTP_PORTS tells ASP.NET Core which port to listen on.
- EXPOSE records that the application listens on 8080. It publishes nothing and opens nothing — it is a note for humans and tooling.
- useradd creates an account, and USER switches to it for the remaining instructions and for the container's process. Without it, the container runs as root, which the Container Security lesson returns to.
- ENTRYPOINT uses the JSON array form, so dotnet runs directly as process 1 and receives stop signals itself. That matters for clean shutdown.
The instructions you will use most, in one place:
- FROM image:tag
- The base image to build on. Required, and first. A second FROM starts a new stage, which is how multi-stage builds work.
- WORKDIR /path
- Sets the working directory for later instructions and for the running container. Creates the directory if needed.
- COPY source target
- Copies files from the build context into the image. There is also ADD, which additionally unpacks archives and fetches URLs; COPY is preferred because it does exactly one thing.
- RUN command
- Runs a command during the build and keeps the result. Used for installing packages, compiling, and creating users.
- ENV name=value
- Sets an environment variable in the image. Present during the rest of the build and inside every container started from it, unless overridden at run time.
- EXPOSE port
- Declares which port the application listens on. Documentation only — publishing is a run-time decision.
- USER name
- The account the following instructions and the container's process run as. The default is root.
- ENTRYPOINT [...]
- The program the container always runs. Arguments given to docker run are appended to it rather than replacing it.
- CMD [...]
- Default arguments for ENTRYPOINT, or the whole default command when there is no ENTRYPOINT. Replaced by arguments passed to docker run.
ENTRYPOINT and CMD confuse everyone at first, because either one alone can start a container. The difference is what happens when someone passes arguments to docker run:
| ENTRYPOINT | CMD | |
|---|---|---|
| Intended for | The program that defines what this image is | Default arguments to that program |
| Arguments on docker run | Appended after it | Replace it entirely |
| How to override it | The --entrypoint flag | Anything typed after the image name |
| Used alone | Works. The container always runs that program | Works. Any run arguments replace the whole command |
| If several appear | Only the last takes effect | Only the last takes effect |
# The program this image exists to run
ENTRYPOINT ["dotnet", "/app/EmployeesApi.dll"]
# Arguments the caller can replace
CMD ["--environment", "Production"]- With no arguments, the container runs dotnet /app/EmployeesApi.dll --environment Production.
- docker run anvi/employees-api:1.4.0 --environment Staging replaces the CMD arguments. The ENTRYPOINT still runs, so the application still starts.
- docker run --entrypoint sh -it anvi/employees-api:1.4.0 replaces the program instead, which is how you get a shell inside an image to look around.
- Had the whole command been written as a single CMD, then docker run image --environment Staging would try to execute --environment as a program and fail. That failure is the clearest signal you have the two mixed up.
A .dockerignore file sits next to the Dockerfile and lists what the build context should exclude. It matters for three reasons: the context is transferred to the builder before any instruction runs, COPY . ./ copies whatever is in the context into a layer people can read, and excluded files cannot invalidate the build cache. A realistic list for the employees API repository:
- bin/ and obj/ — local build output. Large, constantly changing, and compiled on your machine rather than in the image.
- node_modules/ — for the frontend. Dependencies get installed inside the build, not carried in from your laptop.
- .git/ — the entire history, frequently the biggest item in the folder, and useless to a running application.
- .env, appsettings.Development.json and any local secrets file — these would otherwise land in an image layer that anyone who can pull the image can read.
- docs/, README.md, the Dockerfile and .dockerignore themselves — they play no part in what the image does.
- *.user files, .vs/ and .idea/ — editor and machine-specific clutter.
Summary
- A Dockerfile is an ordered list of instructions that produce an image
- RUN happens at build time; ENTRYPOINT and CMD happen when a container starts
- ENTRYPOINT is the program and survives run arguments; CMD is default arguments and is replaced by them
- EXPOSE documents a port, it does not publish one
- A .dockerignore keeps builds faster and keeps local files and credentials out of image layers
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Write it from scratch
Without looking at the example above, write a Dockerfile for an ASP.NET Core API that listens on port 8080, runs as a non-root user, and starts with dotnet EmployeesApi.dll.
Then compare it with the example. Check specifically: did you pin the base image tag, and did you use the JSON array form for ENTRYPOINT?
Show solution
The order that matters is FROM first, then the working directory, then files, then metadata, then USER, then ENTRYPOINT. USER comes near the end because the instructions that install things generally need root to do it.
Pinning the base image tag is what makes the build repeatable enough to reason about. An unpinned base image means a build next month may compile against different tooling with no change on your side.
The array form of ENTRYPOINT is not cosmetic. It decides whether your application is process 1 and therefore whether it is told to shut down, which the Production Considerations lesson returns to.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /src
COPY EmployeesApi.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app --no-restore
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
RUN useradd --system --uid 1001 apiuser
USER apiuser
ENTRYPOINT ["dotnet", "/app/EmployeesApi.dll"]Think about it
Think about it
You are packaging a command-line tool that exports employee records, and you want docker run anvi/employee-export --format csv to work, with JSON as the default when no arguments are given.
Which part goes in ENTRYPOINT and which in CMD, and what breaks if you put the whole command in CMD?
Show solution
ENTRYPOINT holds the program: the exporter itself. CMD holds the default arguments, --format json, because arguments passed to docker run replace CMD.
Put everything in CMD and docker run anvi/employee-export --format csv replaces the entire command with --format csv, so Docker tries to execute --format as a program and the container exits immediately with a confusing error.
The general rule follows from that: ENTRYPOINT for what the image is, CMD for what can be changed without changing the image.
Saved in this browser only.