Docker · 45 min

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

Prerequisites

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:

  1. 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.
  2. RUN runs at build time; CMD runs at start time. RUN pip install ... happens once,
    while building the image. CMD ["python", "app.py"] is what launches when you docker run
    the finished image.

Build time vs. run time

Keep these two moments separate in your head:

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

  1. 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-lab anywhere you like, and enter it:

    mkdir docker-lab
    cd docker-lab
    

    Add the app

    Create app.py with 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 to 127.0.0.1 inside the container would only
    answer inside it, and your -p port mapping in Step 4 would reach nothing. Binding to
    0.0.0.0 makes it listen on all interfaces so Docker's port forwarding can hit it.

    Declare the dependency

    Create requirements.txt listing exactly what the app needs:

    flask==3.0.3
    

    Pinning 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 like COPY can pull
    files from it. Right now your context is these two files. Anything not in the context cannot
    be COPY'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 .dockerignore in Step 5.

    Checkpoint

    • What does the trailing . in docker build mean? (The build context — the folder Docker sends to the engine, and the source for COPY.)
    • Why bind the app to 0.0.0.0 instead of 127.0.0.1? (So it's reachable through Docker's published port from outside the container.)

    Deliverable for this step: the docker-lab folder containing app.py and requirements.txt.

  2. 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-lab folder, create a file named exactly Dockerfile (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.12 already has Python installed;
      the -slim variant 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 /app inside the image. Every later
      COPY, RUN and CMD runs 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-dir tells 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 (your app.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 -p at docker run time (Step 4).
    • CMD ["python", "app.py"] — the process that starts when the container runs. The JSON
      "exec form" (a list of strings) is preferred over CMD python app.py because 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

    RUN executes while building the image and its result becomes a permanent layer.
    CMD executes when the container starts and can be overridden at docker run. pip install is 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 Dockerfile saved in docker-lab.

  3. 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 image minha-app with version 1.0. Format
      is name:tag. Without -t the image gets only a random ID and is painful to reference.
    • . → the build context: this folder. Docker sends it to the engine and resolves
      COPY against it. The Dockerfile is found here by default (use -f to 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-slim on the first build (cached on later ones).
    • Each COPY / RUN produces a layer.
    • The RUN pip install step is the slow one — it downloads and installs Flask.
    • At the end you get naming to ... minha-app:1.0 and 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 images
    

    You'll see a row where REPOSITORY is minha-app, TAG is 1.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 -t do, and what would happen without it? (It named+tagged the image minha-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 build output (the success line) and the docker images row for minha-app:1.0.

  4. Run your image and reach it in the browser

    You built an image; now instantiate it as a container — the same docker run you 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.0
    

    The 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, EXPOSE in 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 ps
    

    You'll see minha-app-c, its image minha-app:1.0, status Up ..., 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-c
    

    You 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:5000 on docker run? (EXPOSE only documents the port; -p is what actually publishes it to the host.)

    Deliverable for this step: the docker ps output and confirmation (a note or screenshot) that http://localhost:5000 returned your message.

  5. 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 .dockerignore keeps files out — here, out of the build
    context
    sent to the engine and out of your COPY . .. Create .dockerignore in
    docker-lab:

    __pycache__/
    *.pyc
    .git/
    .gitignore
    .venv/
    venv/
    *.md
    Dockerfile
    .dockerignore
    

    Why 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.txt and ran pip install before
    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 install step reports CACHED and is instant. Because
    requirements.txt didn't change, Docker reused that layer; only the layers from COPY . .
    onward rebuilt. Had you written COPY . . before pip 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 RUN with
    && instead of stacking many RUN lines. Each RUN is 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
    RUN wouldn't shrink the image, because the earlier layer already baked it in. The
    --no-cache-dir you gave pip is the Python equivalent of that cleanup.

    Light base image

    You already chose python:3.12-slim over the full python: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 images
    

    Checkpoint

    • Why did pip install show CACHED on the 1.1 rebuild? (requirements.txt was unchanged, so Docker reused that layer; only layers after the code COPY rebuilt.)
    • Why clean the package cache in the same RUN as the install, not a later one? (A later RUN is a new layer; the earlier layer already contains the cache, so the image wouldn't shrink.)

    Deliverable for this step: the .dockerignore file and the docker build -t minha-app:1.1 . output showing the pip install step CACHED.

  6. 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-c
    

    The images (minha-app:1.0 and minha-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, and docker 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 run docker build themselves. 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 URL
    

    Submit the repository or gist URL as your lab deliverable.

    Submission criteria (self-check, anti-stub)

    • Your Dockerfile is real and complete: FROM a slim/alpine base, WORKDIR, COPY requirements.txt before COPY . ., a RUN pip install, EXPOSE, and a CMD in exec form
    • The docker build output shows a successful build tagged minha-app:1.0 (not just typed — pasted from your terminal)
    • docker images shows your minha-app image with a real size
    • You confirmed http://localhost:5000 returned your message string, not the nginx default page
    • A .dockerignore exists and excludes at least .git/, __pycache__/, and virtualenv folders
    • The rebuild output proves the cache worked: the pip install step 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 in SUBMISSION.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 one docker-compose.yml and orchestrating them with a single
    docker compose up, so multi-container apps start as easily as this one did.