Docker basics · Lesson 4 of 8 · about 30 minutes

Your first Dockerfile

Package a small Python app into your own image, run it, and see why the order of the lines in a Dockerfile changes how fast it builds.

By the end you'll know

  • What a Dockerfile is, and what each common instruction does
  • How to build your own image and run it
  • Why the order of the lines changes how fast your image builds
  • Why an app in a container must listen on 0.0.0.0

From someone else's image to your own

So far you've run images other people built: nginx, BusyBox, Redis. To run your app in a container, you need your own image. You make one with a Dockerfile: a plain text file of instructions that says, step by step, how to build the image. Docker reads it from top to bottom, and each step becomes a layer (Lesson 3).

Think of a recipe. A Dockerfile is the recipe: start from this base, add these ingredients, then this step, and here's how to serve it. docker build is cooking it, once. The image is the finished dish, ready to serve as many times as you like. And if you only change the last step of a recipe, you don't start the whole thing over: you reuse what's already prepared. Docker does the same, as you'll see.

The app

Make a new folder for this lesson and work inside it:

BASH
mkdir hello-app
cd hello-app

You'll create three files. The app itself, app.py, is a tiny web app written with Flask, a popular Python web framework. It replies with its own hostname:

PYTHON
import socket

from flask import Flask

app = Flask(__name__)


@app.get("/")
def hello():
    return f"Hello from a container! My name is {socket.gethostname()}.\n"

requirements.txt lists the Python libraries the app needs, at exact versions, the same rule as image tags in Lesson 3:

TEXT
flask==3.1.3

You don't need Python or Flask installed on your laptop. That's the point: they'll be inside the image.

The Dockerfile

Save this as Dockerfile, with that exact name and no file extension:

DOCKERFILE
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8000
CMD ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000"]

Each line is one instruction:

InstructionWhat it does
FROM python:3.13-slimStart from an existing image: the official Python image, version 3.13, in its smaller slim variant. Every Dockerfile starts with FROM
WORKDIR /appCreate the folder /app inside the image, and work in it from here on
COPY requirements.txt .Copy a file from your folder into the image. . means "the current folder", which is /app
RUN pip install ...Run a command while building. This one installs Flask into the image. --no-cache-dir skips keeping pip's download cache, which would only make the image bigger
COPY app.py .Copy the app's code into the image
EXPOSE 8000A note saying the app listens on port 8000. It doesn't open or publish anything; -p still does that (Lesson 2)
CMD [...]The command to run when a container starts. Here, start Flask on port 8000

The difference between RUN and CMD trips people up, so it's worth saying twice: RUN happens once, while the image is built. CMD happens every time a container starts. Installing software is a RUN; starting your app is the CMD.

Try it: build and run your image

Build it. -t gives the image a name and tag, and the . at the end tells Docker to use the current folder, where your files are:

BASH
docker build -t hello-app:1.0 .

The first build downloads the Python base image and installs Flask, so it takes a little while. You'll see each step go by, numbered like [2/5] WORKDIR /app. When it finishes, your image is on the list:

BASH
docker image ls hello-app

Run it, exactly like the images in Lesson 2:

BASH
docker run -d -p 8000:8000 --name hello hello-app:1.0
curl http://localhost:8000

(Or open http://localhost:8000 in your browser.) You'll see Hello from a container! My name is followed by a string of letters and numbers. Now run docker ps: the same string is the container's ID. Inside a container, the hostname is the container's ID. Your app is running in your own image.

Flask's logs mention This is a development server. That's fine for learning. For real apps you'd use a production server, like CivicGrid does below.

Why line order matters

Change the message in app.py to Hello again from a container!, and build a new version:

BASH
docker build -t hello-app:1.1 .

This time it's done in a second or two. Look at the output: the steps up to installing Flask say CACHED, and only COPY app.py and the steps after it ran again.

That's the build cache. Docker remembers each layer it built. For each step, if the step and everything it depends on are exactly the same as last time, Docker reuses the old layer instead of running it again. As soon as one step's inputs change, that step and every step after it are rebuilt.

That's why the Dockerfile copies requirements.txt and installs Flask before copying app.py. You change your code all the time, and your libraries rarely. With the slow install above the code, a code change only rebuilds the fast steps at the bottom.

How line order changes what gets rebuiltAfter changing only app.py. On the left, the good order: FROM, WORKDIR, copy requirements, and install Flask are all reused from the cache, and only copying app.py is rebuilt, so the build takes seconds. On the right, the bad order copies everything, including app.py, before installing Flask. The copy step changed, so it and the slow install step after it are both rebuilt, every time the code changes.Good order: code copied lastFROM python:3.13-slimcachedWORKDIR /appcachedCOPY requirements.txt .cachedRUN pip install (slow)cachedCOPY app.py .rebuiltSecondsBad order: code copied firstFROM python:3.13-slimcachedWORKDIR /appcachedCOPY . . (includes app.py)rebuiltRUN pip install (slow)rebuiltThe slow step, every timeDocker reuses every step until the first one whose inputs changed. Put what changes most at the bottom.
After changing only app.py. In the good order, only the last, fast step is rebuilt. In the bad order, the code is copied before the install, so every code change reinstalls the libraries too.

Here that saves a few seconds. In a real app with dozens of libraries, it's the difference between a 5-second build and a 5-minute one, every time you change a line of code.

In my real setup

CivicGrid's API is built from a Dockerfile that starts the same way as yours: FROM python:3.13-slim, then COPY requirements.txt . and the install, and only then the code. The comment I left above that step says exactly what you just saw: copy only the requirements first, so the install step stays cached until the requirements actually change, and code changes don't touch it.

Its last line is a CMD too, starting the API with a production server called uvicorn, on 0.0.0.0 and port 8000. There's a comment on that line as well, because it's the mistake you're about to make on purpose.

The real file has more in it: it's built in two stages, and it runs as a user without admin rights. Lesson 7 builds up to that.

Break it on purpose

Listen on the wrong address. In the Dockerfile, change "--host", "0.0.0.0" to "--host", "127.0.0.1". Rebuild, and run a new container:

BASH
docker build -t hello-app:broken .
docker rm -f hello
docker run -d -p 8000:8000 --name hello hello-app:broken
curl http://localhost:8000

curl fails, even though docker ps says the container is up and docker logs hello shows Flask happily running. Here's why. 127.0.0.1 means "this machine only", and inside a container, "this machine" is the container itself. Flask is now only accepting connections that start inside the container, and the traffic Docker forwards from your laptop's port 8000 arrives from outside it. 0.0.0.0 means "accept connections from anywhere", which is what a server in a container needs.

This is one of the most common container problems: the container is running, the logs look fine, and nothing can connect. Check what address the app listens on. Change it back to 0.0.0.0 when you're done.

Make the cache useless. Change the middle of the Dockerfile to the bad order, copying everything first:

DOCKERFILE
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

Build once, change the message in app.py again, and build again. Watch the install step run every single time. Put the good order back when you've seen it.

Clean up

BASH
docker rm -f hello
docker rmi hello-app:1.0 hello-app:1.1 hello-app:broken

Keep the hello-app folder. Lessons 5 and 7 build on it.

Check yourself

  1. What's the difference between RUN and CMD in a Dockerfile?

  2. What does the . at the end of docker build -t hello-app:1.0 . mean?

  3. You change one line in app.py and rebuild. Which steps run again?

  4. Why copy requirements.txt and install libraries before copying the app's code?

  5. Your container is running and its logs look fine, but curl to the published port fails. What should you check?

  6. Does EXPOSE 8000 make the app reachable from your laptop?

Key terms

  • Dockerfile: a text file of instructions for building an image.
  • FROM: the image to start from. Every Dockerfile begins with it.
  • WORKDIR: the folder inside the image that later steps work in.
  • COPY: copies files from your folder into the image.
  • RUN: runs a command while the image is built.
  • CMD: the command a container runs when it starts.
  • Build context: the folder you give docker build, which COPY can read from.
  • Build cache: Docker's memory of steps it has already built, reused until a step's inputs change.
  • 0.0.0.0: "accept connections from anywhere". Servers in containers should listen on it.

Go deeper