Writing a Dockerfile
Every line, and why the order matters.
Part 1: The problem
Leo wrote QuickCart’s first Dockerfile in five minutes. It copied the whole project folder into the image, installed the libraries, and started the app. It worked.
Then every change got slow. Leo fixed a typo in one line of code, rebuilt, and waited four minutes while every library downloaded and installed again. A two-line fix meant eight minutes of waiting.
Then Priya noticed something worse. Copying the whole folder had also copied Leo’s .env file into the image, with the test database password in it. The image had already been pushed to the company’s registry.
Both problems come from how a Dockerfile works: every line builds a layer, and layers are both reused and kept. This lesson is about writing each line on purpose.
Part 2: The idea
A Dockerfile is a text file of instructions for building an image. Docker runs them from top to bottom. Most lines change the files in the image, and each of those saves its result as a layer: a snapshot of what that one line changed.
Every instruction, in one table
| Instruction | What it does | QuickCart’s line |
|---|---|---|
FROM | The image to start from: an operating system, often with a language already installed | FROM python:3.12-slim |
WORKDIR | The folder later lines work in. Created if it doesn’t exist | WORKDIR /app |
COPY | Copy files from your folder into the image | COPY requirements.txt . |
RUN | Run a command while building, like installing libraries | RUN pip install --no-cache-dir -r requirements.txt |
ARG | A value you can pass in at build time with --build-arg. Gone once the build ends | ARG APP_VERSION=v1 |
ENV | A setting the app can read when it runs | ENV APP_VERSION=$APP_VERSION |
USER | Which user later lines, and the app, run as | USER 1001 |
EXPOSE | A note saying which port the app listens on. It opens nothing | EXPOSE 8080 |
HEALTHCHECK | A command Docker runs every so often to ask “is the app really working?” | calls /health |
CMD | The command a container runs when it starts. Easy to replace | CMD ["gunicorn", …] in the full file |
ENTRYPOINT | Like CMD, but fixed. Anything you add on docker run is passed to it | not used here |
CMD or ENTRYPOINT
Both say what runs when a container starts. The difference shows when you add words after the image name on docker run:
| Dockerfile has | docker run image runs | docker run image hello runs |
|---|---|---|
CMD ["echo", "default"] | echo default | hello: your words replace CMD |
ENTRYPOINT ["echo", "always"] | echo always | echo always hello: your words are added |
Use CMD for an app like QuickCart’s. It’s easy to swap for something else when you’re debugging. ENTRYPOINT suits images that are really one tool, like a command-line program, where every run calls the same thing.
Brackets or no brackets
CMD and ENTRYPOINT can be written two ways:
| Written as | Called | What starts |
|---|---|---|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] | exec form | gunicorn itself |
CMD gunicorn --bind 0.0.0.0:8080 app:app | shell form | a shell, /bin/sh -c, which then starts gunicorn |
When Docker stops a container, it sends the main process a polite “please stop” signal, called SIGTERM, and waits 10 seconds. Then it forces it. With the shell form, the signal goes to the shell, and many shells never pass it on. Gunicorn doesn’t hear it, so it’s killed in the middle of answering people. Always use the bracket form. Releases without downtime shows what this costs during a deploy.
The cache, and why the order matters
On a rebuild, Docker looks at each line in turn. If the line hasn’t changed, and the files it reads haven’t changed, and nothing above it rebuilt, Docker reuses the saved layer. The build output says CACHED.
The moment one line has to run again, every line below it runs again too, because each layer is built on top of the one before.
So the rule is: put what changes rarely at the top, and what changes often at the bottom. QuickCart’s libraries change once a month. The code changes many times a day. So the Dockerfile copies requirements.txt and installs the libraries first, and copies the code last.
What goes into the build
docker build . sends the whole folder, called the build context, to the builder. A COPY . . line then copies all of it into the image, including .git, local virtual environments and any .env file.
A file called .dockerignore, next to the Dockerfile, lists what to leave out. It works like .gitignore. It keeps builds fast and secrets out.
Don’t run as root
Inside a container, the app runs as root unless you say otherwise. If someone finds a hole in the app, they get root’s power inside the container, and containers share the host’s kernel, so a root break-in is much more dangerous. Two lines fix it: create a normal user, then switch to it.
| Line | What it does |
|---|---|
RUN useradd --create-home --uid 1001 appuser | Create a normal user called appuser, with user number 1001 |
USER 1001 | Run everything after this, including the app, as user 1001 |
USER is given the number, not the name. The name only exists inside the image’s own user list. Tools outside the image, like Kubernetes checking that a container isn’t root, can only trust a number. The Dockerfile linter hadolint warns about a name for exactly that reason.
This is also why the app listens on port 8080 rather than 80. On Linux, ports below 1024 have traditionally needed root, and not every platform relaxes that rule. A high port always works for a normal user, and the load balancer in front doesn’t mind which port it forwards to.
Pin the base image
FROM decides most of what’s in your image. How precisely you name it decides whether next month’s build gets the same thing:
| You write | You get | Verdict |
|---|---|---|
FROM python | Whatever is newest today. A different Python next year | Never |
FROM python:3 | The newest Python 3. Can jump from 3.12 to 3.13 | Avoid |
FROM python:3.12-slim | Python 3.12, a small Debian, and security patches as they’re released | A good default |
FROM python:3.12-slim@sha256: then the image’s digest | Exactly one image, forever, byte for byte | Strictest. You must update it by hand to get patches |
slim means a small Debian with only what Python needs. The full python:3.12 image is several times larger, because it also carries compilers and build tools. Images and containers explains tags and digests.
Multi-stage builds
Some apps need big tools to build and none of them to run. A multi-stage build has more than one FROM. The early stages build things; only the last stage becomes the image, and it copies in just the finished result.
FROM python:3.12 AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home --uid 1001 appuser
COPY --from=build /install /usr/local
COPY app.py .
USER 1001
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]| Line | What it does |
|---|---|
FROM python:3.12 AS build | Stage one, on the full image with compilers. AS build names the stage |
--prefix=/install | Install the libraries into their own folder, so they are easy to copy out |
FROM python:3.12-slim | Stage two, on the small image. This stage becomes the final image |
COPY --from=build /install /usr/local | Take only the installed libraries from stage one. Its compilers are thrown away |
For QuickCart’s two small Python libraries this saves little, because nothing needs compiling. It matters a lot for Go, Java or a front-end build, where the build tools are hundreds of megabytes and the finished program is small.
Part 3: See it
Below is QuickCart’s Dockerfile, already built once. Pick what you edit and choose Rebuild. Then switch to the Dockerfile that copies everything first and do the same edit again.
- 1
FROM python:3.12-slimbuilt before - 2
WORKDIR /appbuilt before - 3
RUN useradd --create-home --uid 1001 appuserbuilt before - 4
COPY requirements.txt .reads requirements.txtbuilt before - 5
RUN pip install --no-cache-dir -r requirements.txtbuilt before - 6
COPY app.py .reads app.pybuilt before
What to notice: both Dockerfiles install exactly the same libraries. The only difference is the order of two lines, and it turns a one-second rebuild into a forty-second one, on every single code change.
Part 4: Do it
Free — everything runs on your own computer.
You’ll write QuickCart’s order service and its Dockerfile, build it, run it, and watch the cache work and fail. About 30 minutes.
You need Docker Desktop installed and running. Set up your computer covers it. Check with:
docker version| Part | What it means |
|---|---|
docker version | print the version of the Docker client and of the engine it talks to |
Two sections, Client and Server. If you only see Client and an error like Cannot connect to the Docker daemon, Docker isn’t running. On macOS and Windows, open Docker Desktop and wait for it to say it’s running; on Linux, run sudo systemctl start docker.
Write the app
Make a folder called
order-serviceand open it in your editor. Create these two files in it.app.pyimport os import socket from flask import Flask, jsonify app = Flask(__name__) VERSION = os.environ.get("APP_VERSION", "v1") ORDERS = [ {"id": 1, "item": "margherita pizza", "qty": 2}, {"id": 2, "item": "pad thai", "qty": 1}, {"id": 3, "item": "falafel wrap", "qty": 3}, ] @app.get("/health") def health(): return jsonify(status="ok", version=VERSION) @app.get("/") def home(): return jsonify(service="order-service", version=VERSION, served_by=socket.gethostname()) @app.get("/orders") def orders(): return jsonify(version=VERSION, count=len(ORDERS), orders=ORDERS)Page What it returns /healthokand the version. Health checks call this to ask “are you working?”/The version, and served_by: the name of the machine, or container, that answered/ordersThree sample orders requirements.txtflask==3.0.3 gunicorn==23.0.0Line What it means flask==3.0.3Flask, the web framework the app is written in, exactly version 3.0.3 gunicorn==23.0.0Gunicorn, the server that runs a Flask app properly, with several workers Exact versions (
==) mean every build installs the same libraries: no surprise upgrades.Write the Dockerfile and .dockerignore
In the same folder, create
Dockerfile, with no extension:DockerfileFROM python:3.12-slim WORKDIR /app RUN useradd --create-home --uid 1001 appuser COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . USER 1001 ARG APP_VERSION=v1 ENV APP_VERSION=$APP_VERSION EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)"] CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "20", "app:app"]Line What it does Why it’s here FROM python:3.12-slimStart from a small Debian with Python 3.12 Pinned to 3.12, so it can’t jump versions WORKDIR /appWork in /appfrom now onKeeps the app’s files together RUN useradd …Create appuser, number 1001Never changes, so it sits near the top and stays cached COPY requirements.txt .Copy only the library list So the next line depends on this file alone RUN pip install --no-cache-dir -r requirements.txtInstall the libraries. --no-cache-dirstops pip keeping its download cache, which would only make the image biggerThe slow step. Reused until requirements.txtchangesCOPY app.py .Copy the code Last, because it changes most USER 1001Run the app as appuserNot root ARG APP_VERSION=v1A build-time value, v1unless you pass--build-arg APP_VERSION=v2Lets later lessons build a v2 from the same code ENV APP_VERSION=$APP_VERSIONCopy that value into a setting the running app reads ARGdisappears after the build;ENVstaysEXPOSE 8080Note the port Documentation for people and tools HEALTHCHECK …Every 30 seconds, fetch /healthwith Python. Wait up to 3 seconds; after 3 failures in a row, mark the container unhealthy. Failures in the first 10 seconds don’t countThe slim image has no curl, so the check uses Python, which is already thereCMD [ … ]Start gunicorn on port 8080 with 2 workers. On a stop signal, give requests in progress up to 20 seconds to finish Bracket form, so gunicorn hears the stop signal Now create
.dockerignore, to keep anything else in the folder out of the build:.dockerignore.git .venv __pycache__ *.pyc .env *.pem Dockerfile* .dockerignoreLine Leaves out .gitYour git history, which can be large and may hold old secrets .venv,__pycache__,*.pycPython’s local files, which belong to your computer, not the image .env,*.pemSettings files and keys: secrets Dockerfile*,.dockerignoreThe build files themselves. Docker still reads them; they just aren’t copied into the image This Dockerfile copies files by name, so
.dockerignoreis a second line of defence. It matters most the day someone writesCOPY . ..Build the image
In the terminal, go into the folder:
Terminalcd order-servicePart What it means cd order-servicemove into the folder, so the next commands use its files Terminaldocker build -t order-service:v1 .Part What it means docker buildbuild an image from a Dockerfile -t order-service:v1name it order-service, with the tagv1.the build context: send this folder to the builder. Docker looks for Dockerfilein itThe lines that matter look like this=> [1/6] FROM docker.io/library/python:3.12-slim => [2/6] WORKDIR /app => [3/6] RUN useradd --create-home --uid 1001 appuser => [4/6] COPY requirements.txt . => [5/6] RUN pip install --no-cache-dir -r requirements.txt => [6/6] COPY app.py . => exporting to image => => naming to docker.io/library/order-service:v1There are more lines than this, and each has a time on the right. The first build downloads the base image, so it takes longest. Only six lines are numbered:
USER,ARG,ENV,EXPOSE,HEALTHCHECKandCMDare settings, not layers.Run it and check it
Terminaldocker run -d -p 8080:8080 --name orders order-service:v1Part What it means docker runstart a container from an image -ddetached: run in the background and give you the terminal back -p 8080:8080connect port 8080 on your computer to port 8080 in the container --name orderscall the container orders, so later commands can use that nameorder-service:v1the image to start You should seeA long line of letters and numbers: the new container’s ID. It started.
Terminalcurl localhost:8080/healthPart What it means curlfetch a web address and print the answer localhost:8080your own computer, port 8080, which -pconnects to the container/healththe page to fetch You should see{"status":"ok","version":"v1"}Terminalcurl localhost:8080/Part What it means /the home page, which says who answered You should see{"served_by":"4d29ffd981e4","service":"order-service","version":"v1"}served_byis the container’s own name for itself: the first 12 characters of its ID. Yours will be different.Terminaldocker psPart What it means docker pslist running containers You should seeOne row, named
orders. Its STATUS saysUpwith(health: starting), and about 30 seconds later, once the first check has passed,(healthy). That’s theHEALTHCHECKline at work.Terminaldocker exec orders whoamiPart What it means docker execrun a command inside a running container orderswhich container whoamithe command: print the current user You should seeappuserNot
root. User 1001 isappuser, because that’s the name theuseraddline gave it.Change the code and watch the cache
Open
app.pyand add one line at the very end, a comment:app.pyimport os import socket from flask import Flask, jsonify app = Flask(__name__) VERSION = os.environ.get("APP_VERSION", "v1") ORDERS = [ {"id": 1, "item": "margherita pizza", "qty": 2}, {"id": 2, "item": "pad thai", "qty": 1}, {"id": 3, "item": "falafel wrap", "qty": 3}, ] @app.get("/health") def health(): return jsonify(status="ok", version=VERSION) @app.get("/") def home(): return jsonify(service="order-service", version=VERSION, served_by=socket.gethostname()) @app.get("/orders") def orders(): return jsonify(version=VERSION, count=len(ORDERS), orders=ORDERS) # a small change, to test the cacheSave it and build again, as a new tag:
Terminaldocker build -t order-service:v2 .Part What it means -t order-service:v2the same name, a new tag, so you keep v1 as it was Look for CACHED=> CACHED [2/6] WORKDIR /app => CACHED [3/6] RUN useradd --create-home --uid 1001 appuser => CACHED [4/6] COPY requirements.txt . => CACHED [5/6] RUN pip install --no-cache-dir -r requirements.txt => [6/6] COPY app.py .Only the step that reads
app.pyran. The whole build takes a second or two.Build it in the wrong order
Create a second Dockerfile called
Dockerfile.bad. It copies the whole folder before installing:Dockerfile.badFROM python:3.12-slim WORKDIR /app RUN useradd --create-home --uid 1001 appuser COPY . . RUN pip install --no-cache-dir -r requirements.txt USER 1001 EXPOSE 8080 CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]Build it once, so there’s a cache to reuse:
Terminaldocker build -f Dockerfile.bad -t order-service:bad .Part What it means -f Dockerfile.baduse this file instead of the one called Dockerfile-t order-service:badtag the result bad.the same folder as before Now change the comment at the end of
app.py, so it reads# a second small change, save, and run the same build command again.This time=> CACHED [2/5] WORKDIR /app => CACHED [3/5] RUN useradd --create-home --uid 1001 appuser => [4/5] COPY . . => [5/5] RUN pip install --no-cache-dir -r requirements.txtpip installran again, for a comment.COPY . .readsapp.py, so its input changed, and every step below it had to rebuild. With two small libraries it’s a few seconds; with a real app’s libraries it’s minutes, on every change.See CMD and ENTRYPOINT side by side
Create two tiny Dockerfiles. First,
Dockerfile.cmd:Dockerfile.cmdFROM python:3.12-slim CMD ["echo", "this is the default"]Then
Dockerfile.ep:Dockerfile.epFROM python:3.12-slim ENTRYPOINT ["echo", "always starts with this:"]Build both:
Terminaldocker build -f Dockerfile.cmd -t cmd-demo .Part What it means -f Dockerfile.cmdbuild from the CMD example -t cmd-demoname it cmd-demo. With no tag, Docker addslatestTerminaldocker build -f Dockerfile.ep -t ep-demo .Part What it means -f Dockerfile.ep -t ep-demothe same, for the ENTRYPOINT example Run the CMD one twice, once with extra words:
Terminaldocker run --rm cmd-demoPart What it means --rmdelete the container as soon as it finishes cmd-demothe image; nothing after it, so CMD runs You should seethis is the defaultTerminaldocker run --rm cmd-demo echo "I replaced it"Part What it means echo "I replaced it"words after the image name. They replace CMD You should seeI replaced itNow the ENTRYPOINT one:
Terminaldocker run --rm ep-demo and this was addedPart What it means ep-demothe image with the ENTRYPOINT and this was addedwords after the image name. They’re added to the ENTRYPOINT You should seealways starts with this: and this was addedStop it cleanly
Terminaldocker stop ordersPart What it means docker stopsend the container’s main process SIGTERM, wait up to 10 seconds, then force itorderswhich container You should seeordersIt returns in about a second, not ten. Look at what gunicorn did:
Terminaldocker logs ordersPart What it means docker logsprint everything the container wrote to its output, even after it stopped The last lines[INFO] Handling signal: term [INFO] Worker exiting (pid: 7) [INFO] Worker exiting (pid: 8) [INFO] Shutting down: MasterEach line also starts with a date and a number; yours will differ. Gunicorn heard the signal, let its workers finish, and exited. That’s the bracket form of
CMDdoing its job.Clean-up step: Clean up
Remove the container:
Terminaldocker rm ordersPart What it means docker rmdelete a stopped container You should seeordersThen remove the images you built:
Terminaldocker image rm order-service:v1 order-service:v2 order-service:bad cmd-demo ep-demoPart What it means docker image rmdelete images from your computer order-service:v1and the restevery image this lab built, by name and tag You should seeAn
Untagged:line for each name, and someDeleted:lines. Nothing from this lab is left except the files in your folder.Keep the
order-servicefolder. Data and networking and Storing images: ECR build from it again.
Part 5: Check yourself
Leo changes one line in app.py. In which Dockerfile does the rebuild skip pip install?
When does a RUN line run?
A Dockerfile has COPY .env . and, two lines later, RUN rm .env. Is the password in .env safe?
Why write CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] with square brackets?
What does EXPOSE 8080 do?
In a multi-stage build, what ends up in the final image?
Part 6: Cheat sheet
| Line or command | What it does |
|---|---|
FROM python:3.12-slim | Start from a pinned, small base image |
COPY requirements.txt . then RUN pip install then COPY app.py . | Libraries first, code last: fast rebuilds |
RUN | Runs once, at build time |
CMD ["a", "b"] | Runs at container start. Brackets, so the app hears stop signals |
ENTRYPOINT | Fixed start command; docker run words are added to it |
USER 1001 | Don’t run as root. Use the number |
.dockerignore | Keep .git, .env and keys out of the build |
COPY --from=build | Multi-stage: ship the result, not the build tools |
docker build -t name:tag . | Build from the Dockerfile here |
docker build -f File -t name:tag . | Build from another Dockerfile |
CACHED in the output | That step was reused |
Secret copied in, then rm | Still in the image. Never copy it in |