Laptop to Production

Writing a Dockerfile

Every line, and why the order matters.

Time
40 minutes
Level
Beginner

Part 1: The problem

QuickCart, the order service moves into a container

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

InstructionWhat it doesQuickCart’s line
FROMThe image to start from: an operating system, often with a language already installedFROM python:3.12-slim
WORKDIRThe folder later lines work in. Created if it doesn’t existWORKDIR /app
COPYCopy files from your folder into the imageCOPY requirements.txt .
RUNRun a command while building, like installing librariesRUN pip install --no-cache-dir -r requirements.txt
ARGA value you can pass in at build time with --build-arg. Gone once the build endsARG APP_VERSION=v1
ENVA setting the app can read when it runsENV APP_VERSION=$APP_VERSION
USERWhich user later lines, and the app, run asUSER 1001
EXPOSEA note saying which port the app listens on. It opens nothingEXPOSE 8080
HEALTHCHECKA command Docker runs every so often to ask “is the app really working?”calls /health
CMDThe command a container runs when it starts. Easy to replaceCMD ["gunicorn", …] in the full file
ENTRYPOINTLike CMD, but fixed. Anything you add on docker run is passed to itnot 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 hasdocker run image runsdocker run image hello runs
CMD ["echo", "default"]echo defaulthello: your words replace CMD
ENTRYPOINT ["echo", "always"]echo alwaysecho 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 asCalledWhat starts
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]exec formgunicorn itself
CMD gunicorn --bind 0.0.0.0:8080 app:appshell forma 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.

Key point: Docker reuses every layer until the first line whose input changed. Put what changes least at the top.

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.

LineWhat it does
RUN useradd --create-home --uid 1001 appuserCreate a normal user called appuser, with user number 1001
USER 1001Run 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 writeYou getVerdict
FROM pythonWhatever is newest today. A different Python next yearNever
FROM python:3The newest Python 3. Can jump from 3.12 to 3.13Avoid
FROM python:3.12-slimPython 3.12, a small Debian, and security patches as they’re releasedA good default
FROM python:3.12-slim@sha256: then the image’s digestExactly one image, forever, byte for byteStrictest. 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.

Dockerfile.multi
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"]
LineWhat it does
FROM python:3.12 AS buildStage one, on the full image with compilers. AS build names the stage
--prefix=/installInstall the libraries into their own folder, so they are easy to copy out
FROM python:3.12-slimStage two, on the small image. This stage becomes the final image
COPY --from=build /install /usr/localTake 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.

Dockerfile order
Edit
DockerfileLast build
  1. 1FROM python:3.12-slimbuilt before
  2. 2WORKDIR /appbuilt before
  3. 3RUN useradd --create-home --uid 1001 appuserbuilt before
  4. 4COPY requirements.txt .reads requirements.txtbuilt before
  5. 5RUN pip install --no-cache-dir -r requirements.txtbuilt before
  6. 6COPY app.py .reads app.pybuilt before
  7. USER 1001setting, no layer
  8. EXPOSE 8080setting, no layer
  9. CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]setting, no layer
Build timenot built yet

ReadyThe image was built once already. Choose what you edit, then Rebuild and watch which steps run again.

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:

Terminal
docker version
PartWhat it means
docker versionprint the version of the Docker client and of the engine it talks to
You should see

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.

  1. Write the app

    Make a folder called order-service and open it in your editor. Create these two files in it.

    app.py
    import 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)
    PageWhat it returns
    /healthok and 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.txt
    flask==3.0.3
    gunicorn==23.0.0
    LineWhat 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.

  2. Write the Dockerfile and .dockerignore

    In the same folder, create Dockerfile, with no extension:

    Dockerfile
    FROM 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"]
    LineWhat it doesWhy it’s here
    FROM python:3.12-slimStart from a small Debian with Python 3.12Pinned to 3.12, so it can’t jump versions
    WORKDIR /appWork in /app from 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 listSo the next line depends on this file alone
    RUN pip install --no-cache-dir -r requirements.txtInstall the libraries. --no-cache-dir stops pip keeping its download cache, which would only make the image biggerThe slow step. Reused until requirements.txt changes
    COPY app.py .Copy the codeLast, because it changes most
    USER 1001Run the app as appuserNot root
    ARG APP_VERSION=v1A build-time value, v1 unless 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 readsARG disappears after the build; ENV stays
    EXPOSE 8080Note the portDocumentation for people and tools
    HEALTHCHECK …Every 30 seconds, fetch /health with 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 there
    CMD [ … ]Start gunicorn on port 8080 with 2 workers. On a stop signal, give requests in progress up to 20 seconds to finishBracket 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*
    .dockerignore
    LineLeaves 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 .dockerignore is a second line of defence. It matters most the day someone writes COPY . ..

  3. Build the image

    In the terminal, go into the folder:

    Terminal
    cd order-service
    PartWhat it means
    cd order-servicemove into the folder, so the next commands use its files
    Terminal
    docker build -t order-service:v1 .
    PartWhat it means
    docker buildbuild an image from a Dockerfile
    -t order-service:v1name it order-service, with the tag v1
    .the build context: send this folder to the builder. Docker looks for Dockerfile in it
    The 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:v1

    There 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, HEALTHCHECK and CMD are settings, not layers.

  4. Run it and check it

    Terminal
    docker run -d -p 8080:8080 --name orders order-service:v1
    PartWhat 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 name
    order-service:v1the image to start
    You should see

    A long line of letters and numbers: the new container’s ID. It started.

    Terminal
    curl localhost:8080/health
    PartWhat it means
    curlfetch a web address and print the answer
    localhost:8080your own computer, port 8080, which -p connects to the container
    /healththe page to fetch
    You should see
    {"status":"ok","version":"v1"}
    Terminal
    curl localhost:8080/
    PartWhat it means
    /the home page, which says who answered
    You should see
    {"served_by":"4d29ffd981e4","service":"order-service","version":"v1"}

    served_by is the container’s own name for itself: the first 12 characters of its ID. Yours will be different.

    Terminal
    docker ps
    PartWhat it means
    docker pslist running containers
    You should see

    One row, named orders. Its STATUS says Up with (health: starting), and about 30 seconds later, once the first check has passed, (healthy). That’s the HEALTHCHECK line at work.

    Terminal
    docker exec orders whoami
    PartWhat it means
    docker execrun a command inside a running container
    orderswhich container
    whoamithe command: print the current user
    You should see
    appuser

    Not root. User 1001 is appuser, because that’s the name the useradd line gave it.

  5. Change the code and watch the cache

    Open app.py and add one line at the very end, a comment:

    app.py
    import 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 cache

    Save it and build again, as a new tag:

    Terminal
    docker build -t order-service:v2 .
    PartWhat 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.py ran. The whole build takes a second or two.

  6. Build it in the wrong order

    Create a second Dockerfile called Dockerfile.bad. It copies the whole folder before installing:

    Dockerfile.bad
    FROM 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:

    Terminal
    docker build -f Dockerfile.bad -t order-service:bad .
    PartWhat 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.txt

    pip install ran again, for a comment. COPY . . reads app.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.

  7. See CMD and ENTRYPOINT side by side

    Create two tiny Dockerfiles. First, Dockerfile.cmd:

    Dockerfile.cmd
    FROM python:3.12-slim
    CMD ["echo", "this is the default"]

    Then Dockerfile.ep:

    Dockerfile.ep
    FROM python:3.12-slim
    ENTRYPOINT ["echo", "always starts with this:"]

    Build both:

    Terminal
    docker build -f Dockerfile.cmd -t cmd-demo .
    PartWhat it means
    -f Dockerfile.cmdbuild from the CMD example
    -t cmd-demoname it cmd-demo. With no tag, Docker adds latest
    Terminal
    docker build -f Dockerfile.ep -t ep-demo .
    PartWhat it means
    -f Dockerfile.ep -t ep-demothe same, for the ENTRYPOINT example

    Run the CMD one twice, once with extra words:

    Terminal
    docker run --rm cmd-demo
    PartWhat it means
    --rmdelete the container as soon as it finishes
    cmd-demothe image; nothing after it, so CMD runs
    You should see
    this is the default
    Terminal
    docker run --rm cmd-demo echo "I replaced it"
    PartWhat it means
    echo "I replaced it"words after the image name. They replace CMD
    You should see
    I replaced it

    Now the ENTRYPOINT one:

    Terminal
    docker run --rm ep-demo and this was added
    PartWhat it means
    ep-demothe image with the ENTRYPOINT
    and this was addedwords after the image name. They’re added to the ENTRYPOINT
    You should see
    always starts with this: and this was added
  8. Stop it cleanly

    Terminal
    docker stop orders
    PartWhat it means
    docker stopsend the container’s main process SIGTERM, wait up to 10 seconds, then force it
    orderswhich container
    You should see
    orders

    It returns in about a second, not ten. Look at what gunicorn did:

    Terminal
    docker logs orders
    PartWhat 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: Master

    Each 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 CMD doing its job.

  9. Clean-up step: Clean up

    Remove the container:

    Terminal
    docker rm orders
    PartWhat it means
    docker rmdelete a stopped container
    You should see
    orders

    Then remove the images you built:

    Terminal
    docker image rm order-service:v1 order-service:v2 order-service:bad cmd-demo ep-demo
    PartWhat it means
    docker image rmdelete images from your computer
    order-service:v1 and the restevery image this lab built, by name and tag
    You should see

    An Untagged: line for each name, and some Deleted: lines. Nothing from this lab is left except the files in your folder.

    Keep the order-service folder. 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

On one card
Line or commandWhat it does
FROM python:3.12-slimStart from a pinned, small base image
COPY requirements.txt . then RUN pip install then COPY app.py .Libraries first, code last: fast rebuilds
RUNRuns once, at build time
CMD ["a", "b"]Runs at container start. Brackets, so the app hears stop signals
ENTRYPOINTFixed start command; docker run words are added to it
USER 1001Don’t run as root. Use the number
.dockerignoreKeep .git, .env and keys out of the build
COPY --from=buildMulti-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 outputThat step was reused
Secret copied in, then rmStill in the image. Never copy it in

Finished the build and the quiz? Mark it done to fill in its stop on the route map.