Build Your Own Image with a Dockerfile
Go from consuming images to producing them. Write a Dockerfile for a tiny web app, build it into a tagged image, run it as a container in your browser, then optimize it with a .dockerignore and a layer-cache-friendly instruction order.
Problem
In Lab 1 you ran containers from images other people built. That is half of Docker. The other half — the half that makes Docker worth learning — is packaging **your own** application into an image so it runs identically on your laptop, a teammate's machine, CI, and production. The tool for that is the **Dockerfile**: a plain text recipe of instructions Docker reads top-to-bottom to assemble an image, layer by layer. Get it right and "works on my machine" stops being a sentence anyone says on your team. Get it wrong — a bloated base image, cache you never reuse, secrets copied in by accident — and your builds are slow, huge, and insecure. In this lab you write a real Dockerfile for a minimal Flask web app, build it into a tagged image with `docker build`, run it and open it in your browser, then apply the ebook's best practices — light base images, a `.dockerignore`, grouped `RUN` layers, and cache-friendly ordering — and *watch the build get faster*. You finish able to containerize any app you write.
Objectives
- Explain what a Dockerfile is and how each instruction adds a layer to the resulting image
- Read and write the core instructions:
FROM,WORKDIR,COPY,RUN,EXPOSE,CMD - Build an image from a Dockerfile with
docker build -t name:tag ., understanding the-tflag and the.build context - Run your own image as a container, publish its port, and reach the app in a browser
- Optimize the build with a
.dockerignore, a light base image, groupedRUNlayers with cache cleanup, and cache-friendly instruction ordering - Manage the images you produce with
docker images,docker tag, anddocker rmi
Prerequisites
- Docker installed and running (Docker Desktop on Windows/macOS, or Docker Engine on Linux). Run
docker version— if it prints a Client and a Server section, you are ready. - Completion of Lab 1 ("Run Your First Docker Containers"), or equivalent comfort with
docker run,docker ps,docker imagesand the image-vs-container mental model - A terminal (PowerShell, bash or zsh), a text editor, and a web browser
- Internet access to pull the Python base image on the first build
- Git installed, to submit your deliverable at the end
- No Python experience needed — the app is a ~10-line Flask example you copy as-is
What you will build
A folder called docker-lab containing a tiny Flask web app (app.py + requirements.txt)
and a Dockerfile that packages it. You'll build that Dockerfile into an image named
minha-app:1.0, run it as a container, and open the app at http://localhost:5000. Then you'll
make the image smaller and the build faster by adding a .dockerignore and reordering the
instructions. By the end you own the full producer side of Docker: your code → your image →
your container.
The mental model: a Dockerfile is a recipe, layers are the steps
A Dockerfile is a plain text file with a list of instructions. Docker reads it top-to-bottom
and, for most instructions, adds one layer to the image — a stacked, cached filesystem diff.
| Instruction | What it does | Example |
|---|---|---|
FROM |
The base image you build on top of | FROM python:3.12-slim |
WORKDIR |
Sets the working directory for later instructions | WORKDIR /app |
COPY |
Copies files from the build context into the image | COPY . /app |
RUN |
Executes a command at build time, baking the result into a layer | RUN pip install -r requirements.txt |
EXPOSE |
Documents which port the container listens on | EXPOSE 5000 |
CMD |
The default command run when the container starts | CMD ["python", "app.py"] |
Two things are worth internalizing now, because every optimization later flows from them:
-
Each layer is cached. On a rebuild, Docker reuses a layer unchanged from last time —
unless that layer, or any layer before it, changed. That's why instruction order matters. -
RUNruns at build time;CMDruns at start time.RUN pip install ...happens once,
while building the image.CMD ["python", "app.py"]is what launches when youdocker run
the finished image.
Build time vs. run time
Keep these two moments separate in your head:
-
docker buildreads the Dockerfile and produces an image (runs everyFROM/COPY/RUN). -
docker runtakes that image and starts a container (runs theCMD).
You already know docker run from Lab 1. This lab is mostly about the first moment — docker build
— which is the part you didn't control before.
How to work through this lab
Do the steps in order — each builds the artifact the next one needs. Run every command yourself
and read the real output; the whole point of Step 5 is seeing the cache make a second build
faster, which only lands if you actually ran the first one. You'll collect outputs for the Step 6
submission as you go.
Steps
-
Prepare the project: the app and the build context
Before writing a Dockerfile you need something to containerize. You'll create a minimal Flask
web app — the smallest thing that answers on a port so you can open it in a browser.Create the project folder
Make a folder named
docker-labanywhere you like, and enter it:mkdir docker-lab cd docker-labAdd the app
Create
app.pywith a tiny Flask server that returns one line:# app.py from flask import Flask app = Flask(__name__) @app.route("/") def home(): return "Hello from my own Docker image!" if __name__ == "__main__": # 0.0.0.0 so the app is reachable from outside the container, not just localhost inside it app.run(host="0.0.0.0", port=5000)The
host="0.0.0.0"matters: a server bound to127.0.0.1inside the container would only
answer inside it, and your-pport mapping in Step 4 would reach nothing. Binding to
0.0.0.0makes it listen on all interfaces so Docker's port forwarding can hit it.Declare the dependency
Create
requirements.txtlisting exactly what the app needs:flask==3.0.3Pinning the version (not just
flask) is the same reproducibility habit you learned for
image tags in Lab 1 — a rebuild months from now installs the same Flask, not whatever is
newest.Understand the build context
When you later run
docker build ... ., that trailing.is the build context: the
directory Docker packages up and sends to the engine so instructions likeCOPYcan pull
files from it. Right now your context is these two files. Anything not in the context cannot
beCOPY'd into the image — and, just as important, everything in the context gets sent to
the daemon, which is why you'll trim it with a.dockerignorein Step 5.Checkpoint
- What does the trailing
.indocker buildmean? (The build context — the folder Docker sends to the engine, and the source forCOPY.) - Why bind the app to
0.0.0.0instead of127.0.0.1? (So it's reachable through Docker's published port from outside the container.)
Deliverable for this step: the
docker-labfolder containingapp.pyandrequirements.txt. - What does the trailing
-
Write the Dockerfile, instruction by instruction
Now the heart of the lab: the recipe that turns your two files into an image.
Create the Dockerfile
In the same
docker-labfolder, create a file named exactlyDockerfile(no extension) with
this content:# 1. Base image: a small, official Python FROM python:3.12-slim # 2. Working directory inside the image WORKDIR /app # 3. Copy the dependency list FIRST (see why in Step 5) COPY requirements.txt . # 4. Install dependencies at build time RUN pip install --no-cache-dir -r requirements.txt # 5. Now copy the rest of the app COPY . . # 6. Document the port the app listens on EXPOSE 5000 # 7. Default command when the container starts CMD ["python", "app.py"]What each instruction does
-
FROM python:3.12-slim— the base image.python:3.12already has Python installed;
the-slimvariant strips the docs and extras a full Debian carries, so it's far smaller.
Choosing a light, official, pinned base is decision number one for every Dockerfile. -
WORKDIR /app— creates and switches into/appinside the image. Every later
COPY,RUNandCMDruns relative to here, so you don't repeat absolute paths. -
COPY requirements.txt .— copies just the requirements file into/app. Copying it
before the source is a deliberate cache trick you'll exploit in Step 5. -
RUN pip install --no-cache-dir -r requirements.txt— runs at build time and bakes the
installed packages into a layer.--no-cache-dirtells pip not to keep its download cache,
which would just bloat the image (the ebook's "clean up after installs" practice). -
COPY . .— copies the rest of the build context (yourapp.py) into/app. -
EXPOSE 5000— documentation: it records that the app listens on 5000. It does not
publish the port by itself — you still pass-patdocker runtime (Step 4). -
CMD ["python", "app.py"]— the process that starts when the container runs. The JSON
"exec form" (a list of strings) is preferred overCMD python app.pybecause it runs the
process directly, without wrapping it in a shell, so signals like Ctrl+C reach it cleanly.
RUN vs CMD — the classic confusion
RUNexecutes while building the image and its result becomes a permanent layer.
CMDexecutes when the container starts and can be overridden atdocker run.pip installis a build step (RUN); launching your server is a runtime step (CMD). Mixing
these up is the most common Dockerfile beginner mistake.Deliverable for this step: the
Dockerfilesaved indocker-lab. -
-
Build the image with docker build
Recipe written — now cook it into an image.
Run the build
From inside
docker-lab(the folder with the Dockerfile), run:docker build -t minha-app:1.0 .Decode the command:
-
docker build→ read a Dockerfile and produce an image. -
-t minha-app:1.0→ tag the resulting imageminha-appwith version1.0. Format
isname:tag. Without-tthe image gets only a random ID and is painful to reference. -
.→ the build context: this folder. Docker sends it to the engine and resolves
COPYagainst it. The Dockerfile is found here by default (use-fto point elsewhere).
Read the output: layers and steps
Watch the build log. Docker executes your instructions in order and reports each as a step:
- It pulls
python:3.12-slimon the first build (cached on later ones). - Each
COPY/RUNproduces a layer. - The
RUN pip installstep is the slow one — it downloads and installs Flask. - At the end you get
naming to ... minha-app:1.0and a success line.
Every instruction that changes the filesystem adds a layer, and each layer is stored and
cached independently. Keep this picture in mind — it's exactly what makes the Step 5
optimization pay off.Confirm the image exists
docker imagesYou'll see a row where REPOSITORY is
minha-app, TAG is1.0, plus an image ID, a
creation time and a size. That row is your image — built from your Dockerfile, not pulled
from anyone. Note the size; you'll compare against it after optimizing.Checkpoint
- What did
-tdo, and what would happen without it? (It named+tagged the imageminha-app:1.0; without it you'd have only a hard-to-reference random ID.) - Which build step was slowest, and why? (
RUN pip install— it downloads and installs the dependency.)
Deliverable for this step: the tail of the
docker buildoutput (the success line) and thedocker imagesrow forminha-app:1.0. -
-
Run your image and reach it in the browser
You built an image; now instantiate it as a container — the same
docker runyou know from
Lab 1, but this time the image is yours.Start the container
docker run -d -p 5000:5000 --name minha-app-c minha-app:1.0The flags, exactly as in Lab 1:
-
-d→ detached, run in the background and return your prompt. -
-p 5000:5000→ map host port 5000 to container port 5000 (HOST:CONTAINER). Your app
listens on 5000 inside; you reach it on 5000 outside. This is the line that actually
publishes the port — remember,EXPOSEin the Dockerfile only documented it. -
--name minha-app-c→ a friendly container name. -
minha-app:1.0→ your image, the one you just built.
Open it
Go to http://localhost:5000. You should see:
Hello from my own Docker image!That string came from your
app.py, running inside a container built from your
Dockerfile. This is the whole point of the lab landing.Verify with the CLI
docker psYou'll see
minha-app-c, its imageminha-app:1.0, statusUp ..., and the mapping
0.0.0.0:5000->5000/tcp. Check the logs to see Flask's startup and request lines:docker logs minha-app-cYou should see Flask reporting it's running on
http://0.0.0.0:5000, plus a request log line
each time you refresh the browser.Checkpoint
- The Dockerfile had
EXPOSE 5000. Why did you still need-p 5000:5000ondocker run? (EXPOSEonly documents the port;-pis what actually publishes it to the host.)
Deliverable for this step: the
docker psoutput and confirmation (a note or screenshot) thathttp://localhost:5000returned your message. -
-
Optimize: .dockerignore, light base, layers and cache order
Your image works, but a working image isn't the same as a good image. Apply the ebook's best
practices and measure the difference.Add a .dockerignore
Just like
.gitignore, a.dockerignorekeeps files out — here, out of the build
context sent to the engine and out of yourCOPY . .. Create.dockerignorein
docker-lab:__pycache__/ *.pyc .git/ .gitignore .venv/ venv/ *.md Dockerfile .dockerignoreWhy it matters: without it, a
COPY . .can drag in your.git/history, a local virtualenv,
editor junk, even secrets — bloating the image and risking leaks. Excluding them makes the
context smaller (faster builds) and the image cleaner and safer.The instruction order is already doing the real work
Look back at your Dockerfile. You copied
requirements.txtand ranpip installbefore
COPY . .. That ordering is the single biggest build-speed win, and here's the proof.Change only your app, not your dependencies. Edit
app.py's message:return "Hello from my OPTIMIZED Docker image!"Rebuild:
docker build -t minha-app:1.1 .Watch closely: the
pip installstep reports CACHED and is instant. Because
requirements.txtdidn't change, Docker reused that layer; only the layers fromCOPY . .
onward rebuilt. Had you writtenCOPY . .beforepip install, any code change would
invalidate the install layer and reinstall Flask every single time. This is the cache-order
practice from the ebook, made concrete.One RUN layer instead of many
The ebook's "minimize layers" practice: group related shell work into a single
RUNwith
&&instead of stacking manyRUNlines. EachRUNis its own layer, so fewer, grouped
RUNs mean fewer layers and a smaller image. Illustration (you don't need extra packages for
this app, so this is a pattern to recognize, not to paste):# Many layers, cache left behind — avoid: RUN apt-get update RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/* # One layer, cleaned up in the same step — prefer: RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*Grouping also lets you clean up in the same layer — deleting the apt cache in a later
RUNwouldn't shrink the image, because the earlier layer already baked it in. The
--no-cache-diryou gave pip is the Python equivalent of that cleanup.Light base image
You already chose
python:3.12-slimover the fullpython:3.12. That's the ebook's "prefer
light base images (alpine/slim)" practice: slim/alpine bases mean smaller images, faster
pulls, and a smaller attack surface. Confirm both your images and compare sizes:docker imagesCheckpoint
- Why did
pip installshow CACHED on the1.1rebuild? (requirements.txtwas unchanged, so Docker reused that layer; only layers after the codeCOPYrebuilt.) - Why clean the package cache in the same
RUNas the install, not a later one? (A laterRUNis a new layer; the earlier layer already contains the cache, so the image wouldn't shrink.)
Deliverable for this step: the
.dockerignorefile and thedocker build -t minha-app:1.1 .output showing thepip installstep CACHED. - Why did
-
Submit: your image, documented
Prove you built and ran your own image by capturing the real evidence in a repository.
Clean up (optional but tidy)
Before submitting, stop the running container so nothing is left holding port 5000:
docker stop minha-app-c docker rm minha-app-cThe images (
minha-app:1.0andminha-app:1.1) stay — they're your deliverable. Practice the
management commands from the ebook if you like:docker tag minha-app:1.1 minha-app:latest
creates another tag for the same image, anddocker rmi <name:tag>removes one you no longer
want.Build your submission
Create a repository containing your actual project files plus a
SUBMISSION.md. Include
Dockerfile,app.py,requirements.txt, and.dockerignore— the graders should be able
to rundocker buildthemselves. Paste your real outputs, not invented text.# Docker Lab 2 — Build Your Own Image with a Dockerfile ## 1. My Dockerfile <paste the contents of your Dockerfile> ## 2. docker build output <paste the tail of `docker build -t minha-app:1.0 .` showing the success line> ## 3. docker images (my image present) <paste the `docker images` row(s) for minha-app, showing tag and size> ## 4. App running in the browser I ran `docker run -d -p 5000:5000 --name minha-app-c minha-app:1.0` and opened http://localhost:5000. It returned: "Hello from my own Docker image!" (yes / no) <optionally paste a screenshot or the `docker ps` line> ## 5. Cache proof (optimization) After changing only app.py and rebuilding as minha-app:1.1, the `pip install` step showed: <paste the CACHED line from the second build> ## 6. Reflection (2-3 sentences) In my own words: the difference between RUN and CMD, and why copying requirements.txt before the rest of the code makes rebuilds faster.Submit
cd docker-lab git init git add Dockerfile app.py requirements.txt .dockerignore SUBMISSION.md git commit -m "Docker lab 2 — build your own image" # push to a public repo or create a gist, then submit that URLSubmit the repository or gist URL as your lab deliverable.
Submission criteria (self-check, anti-stub)
- Your
Dockerfileis real and complete:FROMa slim/alpine base,WORKDIR,COPY requirements.txtbeforeCOPY . ., aRUN pip install,EXPOSE, and aCMDin exec form - The
docker buildoutput shows a successful build taggedminha-app:1.0(not just typed — pasted from your terminal) -
docker imagesshows yourminha-appimage with a real size - You confirmed
http://localhost:5000returned your message string, not the nginx default page - A
.dockerignoreexists and excludes at least.git/,__pycache__/, and virtualenv folders - The rebuild output proves the cache worked: the
pip installstep shows CACHED after a code-only change - Your reflection correctly states, in your words, RUN vs CMD and why the requirements-first ordering speeds rebuilds
- No placeholder text (
<paste ...>,TODO, "lorem ipsum") is left inSUBMISSION.md
What's next
You can now turn a single app into a single image and run it as a container. Real systems are
rarely one process, though — a web app usually needs a database, maybe a cache, maybe a
background worker, all wired together. In the next Docker lab you'll learn Docker Compose:
describing several services in onedocker-compose.ymland orchestrating them with a single
docker compose up, so multi-container apps start as easily as this one did. - Your