Writing Your First Dockerfile
Build your own image from a text file: FROM, WORKDIR, COPY, RUN and CMD, each introduced by the problem it solves, ending with an application you built and ran yourself.
Build your own image from a text file: FROM, WORKDIR, COPY, RUN and CMD, each introduced by the problem it solves, ending with an application you built and ran yourself.
Everything you have run so far belonged to somebody else. That is fine for a web server or a database, but the reason you are learning this is your own code — and your code is not on Docker Hub.
This is the lesson that closes the gap. By the end of it you will have written a Dockerfile, built an image from it, and run your own application in a container you made. It is the moment Docker stops being a way to borrow software and becomes a way to ship yours.
You could build an image the manual way. Start a plain Ubuntu
container, install a runtime, copy files in, and freeze the result
with docker commit. It works, and nobody does it, for the same
reason nobody documents deployment as a list of chat messages: a
sequence of actions somebody performed once cannot be reviewed,
diffed, repeated or fixed.
A Dockerfile is those steps written down as a text file that lives next to your code, in version control, where a change to the environment shows up in a pull request like any other change.
That is the real shift. Your environment stops being something a person did to a machine and becomes something the repository describes.
We need something real to containerise. Here is a small Python web service — three files, no framework magic, and it does something you can see in a browser.
Create a directory and put this in app.py:
from flask import Flask
import os
app = Flask(__name__)
@app.get("/")
def index():
name = os.environ.get("GREET_NAME", "world")
return f"Hello, {name}!\n"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)And this in requirements.txt:
flask==3.0.3Two files. On your own machine you would create a virtual
environment, install Flask into it, and run python app.py.
Instead, we are going to describe that whole process to Docker.
Create a file named exactly Dockerfile, with no extension, next
to app.py:
Nine lines, and every one of them answers a question you would otherwise have to answer by hand. Read it as a recipe: start from this, work here, bring these files, run this to prepare, and this is what to do when you start.
FROM python:3.12-slim — every image starts from another
image, and this one says which. You are not building an operating
system from nothing; you are starting from a filesystem that
already has Python 3.12 installed and adding to it. The -slim
variant drops documentation, build tools and extras you rarely
need, which takes the base from around 1 GB to about 130 MB.
WORKDIR /app — sets the working directory inside the image,
creating it if needed. Every later COPY, RUN and CMD runs
relative to it, so you write COPY app.py . instead of repeating
the path everywhere. It is also where you land when you docker exec into the container, which makes debugging pleasanter.
COPY requirements.txt . — copies a file from your machine
into the image. The first path is on your machine, relative to
where you run the build; the second is inside the image. Files do
not arrive in an image by accident — if you did not copy it, it is
not there.
RUN pip install --no-cache-dir -r requirements.txt — runs a
command while building, and whatever it changes becomes part of
the image. This is the line that installs Flask. --no-cache-dir
stops pip keeping its download cache, which would otherwise be
baked into the image as dead weight you can never use.
COPY app.py . — brings in the application code, after the
dependencies. That ordering is deliberate and pays for itself
enormously; the next lesson explains exactly why.
EXPOSE 8000 — documents which port the program inside
listens on. It publishes nothing on its own; it is a note for
humans and for tools. You still need -p when you run.
CMD ["python", "app.py"] — what to run when a container
starts. Nothing in the image executes at build time except your
RUN lines; CMD is the default command for the finished image.
From the directory containing the Dockerfile:
Two parts matter. -t hello-docker tags the resulting image
with a name you can use later. The . at the end is the build
context: the directory Docker sends to the daemon so that COPY
has something to copy from. It is easy to read as punctuation, but
it is an argument, and leaving it off is an error.
The output narrates each instruction:
Confirm it exists:
It is an image like any other, so you already know this command:
Visit http://localhost:8000 and your application answers. Then
prove the environment travelled with it:
Visit http://localhost:8001 and the greeting has changed. One
image, two different behaviours, no rebuild — which is the whole
point of reading configuration from the environment, and the
subject of a later lesson.
Nothing about Flask or Python 3.12 was installed on your machine. Delete the image and there is no trace left.
docker build -t hello-docker . gave you hello-docker:latest,
because a tag with no version defaults to latest. Once an image
matters to anyone but you, say which version it is:
The second form applies both tags to the same image in one build —
a specific version to depend on, and a moving latest for
convenience.
Both say what runs when the container starts, and the difference is
what happens when someone passes arguments to docker run.
A default that gets replaced.
docker run hello-docker runs python app.py.
docker run hello-docker python -V runs python -V — your
CMD is gone entirely.
Fixed. Arguments are appended.
docker run hello-docker --debug runs
python app.py --debug.
The image behaves like the tool it wraps, rather than like a box you can run anything inside.
Used together, ENTRYPOINT is the command and CMD supplies its
default arguments:
For a single-purpose application image, CMD alone is the right
default. Reach for ENTRYPOINT when your image is a wrapper around
one tool and you want it to behave like that tool.
Bad — the container exits immediately with no error at all:
Good — the same image, with the last line as the container's command:
The whole of the difference is when each one runs.
FROM, WORKDIR, COPY, RUN — during the build
Each one produces a layer. RUN pip install belongs here,
because installing should happen once and be frozen into the
image.
The image now exists and nothing is running
It is a snapshot on disk. docker build has finished.
CMD — when a container starts
Once per container, every time. This is where your server belongs.
Put the server in RUN and it starts while building, hangs
until the build times out or you interrupt it, and leaves you an
image whose default command is whatever the base image had — so
the container starts, finds nothing to do, and exits with status
0 and an empty log.
Build the same image a second time and notice how much faster it
is. That speed is the build cache, and understanding it is the
difference between a five-second rebuild and a five-minute one —
which is the next lesson, along with why COPY requirements.txt
came before COPY app.py.
After that come volumes, so the data your container writes can survive it, and networking, so two containers can talk. For now, containerise something of your own: any script or small service you already have. The first Dockerfile you write for real code teaches more than reading three more lessons about it.
[+] Building 12.4s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/pyt… 1.2s
=> [1/5] FROM docker.io/library/python:3.12-slim 4.1s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY requirements.txt . 0.0s
=> [4/5] RUN pip install --no-cache-dir -r requireme… 6.3s
=> [5/5] COPY app.py . 0.0s
=> exporting to image 0.4s
=> => naming to docker.io/library/hello-docker:latest 0.0sREPOSITORY TAG IMAGE ID CREATED SIZE
hello-docker latest 7f2a91c4e8b3 9 seconds ago 143MBdocker build -t hello-docker .docker images hello-dockerdocker run -d -p 8000:8000 --name hello hello-dockerdocker run --rm -e GREET_NAME=Antonii -p 8001:8000 hello-dockerdocker build -t hello-docker:0.1.0 .
docker build -t hello-docker:0.1.0 -t hello-docker:latest .# Building and running your own image
docker build -t hello-docker . # build from ./Dockerfile
docker build -t hello-docker:0.1.0 . # ...with a version tag
docker build -f api.Dockerfile -t api . # a differently named file
docker build --no-cache -t hello-docker . # ignore cached layers
docker images hello-docker # confirm it was built
docker run -d -p 8000:8000 hello-docker # run it
docker run --rm -e GREET_NAME=you hello-docker # override config
docker exec -it hello sh # look inside it
docker logs hello # read its outputFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN python app.pyFROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]# Dockerfile instructions used in this lesson
FROM python:3.12-slim # the image to build on top of
WORKDIR /app # working dir for later instructions
COPY requirements.txt . # host path -> image path
RUN pip install -r req.txt # run at BUILD time, result is a layer
EXPOSE 8000 # document the port (publishes nothing)
ENV GREET_NAME=world # a default environment variable
CMD ["python", "app.py"] # default command, replaceable
ENTRYPOINT ["python", "app.py"] # fixed cmd, args appended