

Sarthak Varshney is a Docker Captain, 5x C# Corner MVP, and 2x Alibaba Cloud MVP, with over six years of hands-on experience in the IT industry, specializing in cloud computing, DevOps, and modern application infrastructure. He is an Author and Associate Consultant, known for working extensively with cloud platforms and container-based technologies in real-world environments.
So, you've been playing around with Docker for a bit. You pulled some images, ran a few containers, maybe even did a docker run hello-world and felt like a genius. And then someone said — "Why don't you write your own Dockerfile?"
And you went... huh?
Don't worry. That's exactly where we're going today. By the end of this article, you'll have your very own Dockerfile written, a real Python app baked into a Docker image, and a solid understanding of what FROM, RUN, COPY, and CMD actually mean — not just what the docs say, but why they exist and what they're doing under the hood.
Let's go.
Think of Docker images like a cake. When someone hands you a slice of cake, you're getting the final product — ready to eat. That's your container. But to make that cake, someone had to follow a recipe. A step-by-step set of instructions: preheat the oven, mix the batter, add eggs, bake for 30 minutes.
A Dockerfile is that recipe.
It's a plain text file — literally just a file named Dockerfile with no extension — that tells Docker exactly how to build your image, layer by layer. Every line in a Dockerfile is an instruction. Docker reads it from top to bottom and executes each step to produce the final image.
No Dockerfile, no custom image. It's as simple as that.
We're going to build a Docker image for a small Python Flask web app. Nothing fancy — a single endpoint that returns "Hello from Docker!" when you hit it in your browser. But trust me, once you understand how to dockerize this, you'll know how to dockerize almost anything.
Here's the app we'll be working with. Create a folder called my-flask-app and add these two files:
app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from Docker! 🐳"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
requirements.txt
flask==3.0.3
That's our app. Simple, clean, and ready to be containerized. Now let's build the Dockerfile around it.
Before we write the full thing, let me walk you through the four instructions we'll use today — because understanding why they exist matters more than just memorizing the syntax.
FROM — You Have to Start SomewhereFROM python:3.12-slim
Every Dockerfile starts with a FROM. Always. No exceptions. It defines the base image — basically, the starting point for your own image.
Think of it this way: if your app were a sandwich, FROM is you picking the bread. You don't grow wheat, grind it into flour, and bake the bread yourself. You grab a loaf from the store and build on top of it. That's exactly what FROM does — it says "start from this existing image and let me add my stuff on top."
In our case, we're using python:3.12-slim. This is an official Python image maintained by the Docker team. The slim part means it's a stripped-down version — only the bare essentials, no extra packages bloating the image. Smaller images = faster builds, faster pushes, fewer security vulnerabilities.
What happens if you skip FROM? Docker will throw an error. It literally can't build without a starting point. Even if you're building something completely from scratch (like building a bare OS), you'd write FROM scratch — which is a special empty image.
RUN — Do Things While BuildingRUN pip install --no-cache-dir -r requirements.txt
The RUN instruction executes a shell command at build time — meaning it runs when you're creating the image, not when you start the container.
Use RUN for anything you'd do to set up an environment: installing packages, creating directories, downloading files, compiling code. In our case, we're using it to install Python dependencies via pip.
The --no-cache-dir flag tells pip not to store cache files. In a Docker image, you usually don't need pip's cache — it just takes up space. So we skip it.
Important mental model: Every RUN instruction creates a new layer in your image. If you do this:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
You get three separate layers. Better practice — chain them:
RUN apt-get update && apt-get install -y curl git
One layer, same result, smaller image. You'll thank yourself later.
COPY — Bring Your Files Into the ImageCOPY . .
The COPY instruction copies files from your local machine (the build context) into the image. The syntax is: COPY <source> <destination>.
So COPY . . means: copy everything from the current directory on my machine into the current working directory inside the image.
But hold on — what is the current working directory inside the image? That's set by another instruction called WORKDIR. We'll add that to our Dockerfile too. More on that in a second.
COPY vs ADD — You might see ADD in some Dockerfiles. They look similar, but COPY is recommended for simple file copying. ADD has extra superpowers (like auto-extracting tar files and downloading from URLs) that can lead to unexpected behavior. Unless you specifically need those features, stick with COPY.
CMD — What Should the Container Do When It Starts?CMD ["python", "app.py"]
CMD is the instruction that runs when someone does docker run on your image. It's the default command — what the container actually does when it starts up.
In our case, we want it to run python app.py to start the Flask server.
The syntax here uses exec form — a JSON array of strings. This is the preferred format because it doesn't invoke a shell, which means signals (like Ctrl+C to stop the container) get handled properly.
You could also write it as CMD python app.py (shell form), but avoid it. Shell form runs your command inside /bin/sh -c, which adds a layer of indirection and can cause weird behavior when you try to stop the container.
CMD vs ENTRYPOINT — This is a classic Docker confusion point. Quick way to remember it:
ENTRYPOINT — The command that always runs, no matter what. Hard-coded.CMD — The default arguments. Can be overridden at runtime.For most apps, CMD is what you want.
Alright, now that we understand each piece, let's put it all together. Inside your my-flask-app folder, create a file named exactly Dockerfile (capital D, no extension):
# Use an official Python base image
FROM python:3.12-slim
# Set the working directory inside the container
WORKDIR /app
# Copy the requirements file first (why? we'll get to it)
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code
COPY . .
# Tell Docker what command to run when the container starts
CMD ["python", "app.py"]
That's it. Six instructions. Let's talk about a couple of things here.
WORKDIR?WORKDIR /app
WORKDIR sets the current working directory inside the image for all the following instructions. If the directory doesn't exist, Docker creates it automatically.
Without WORKDIR, your files would land in the root of the filesystem, which is messy and can cause permission issues. Always set a WORKDIR. /app is a common convention.
requirements.txt Separately Before Everything Else?Look at this order:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
Notice how we copy requirements.txt first, install deps, and then copy the rest of the code? This isn't an accident — it's a caching trick.
Docker caches each layer. If a layer hasn't changed since the last build, Docker reuses the cached version instead of rebuilding it. So:
app.py changes, Docker will use the cached pip install layer — it won't reinstall packages just because your code changed.COPY . . first and then pip install, every code change would invalidate the pip install layer, making Docker reinstall all packages every single build.That's slow and wasteful. The separate COPY requirements.txt pattern is a deliberate optimization to keep builds fast.
Your folder structure should look like this:
my-flask-app/
├── app.py
├── requirements.txt
└── Dockerfile
Navigate into that folder and run:
docker build -t my-flask-app:v1 .
Let's break this command down:
docker build — Trigger a build-t my-flask-app:v1 — Tag the image with a name (my-flask-app) and a version (v1). Without a tag, Docker uses latest by default.. — The build context. This dot tells Docker to look in the current directory for the Dockerfile and the files to copy.You'll see Docker pulling the base image (first time only), then executing each instruction one by one. Something like:
[+] Building 23.4s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> [1/5] FROM python:3.12-slim
=> [2/5] WORKDIR /app
=> [3/5] COPY requirements.txt .
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt
=> [5/5] COPY . .
Build successful? Let's run it.
docker run -d -p 5000:5000 --name flask-container my-flask-app:v1
-d — Run in detached mode (background)-p 5000:5000 — Map port 5000 on your machine to port 5000 inside the container--name flask-container — Give the container a friendly namemy-flask-app:v1 — The image to useNow open your browser and go to http://localhost:5000. You should see:
Hello from Docker! 🐳
You just built and ran your own Docker image from scratch. That's genuinely cool.
1. Forgetting the build context dot
docker build -t myapp # ❌ This will fail
docker build -t myapp . # ✅ The dot is required
2. Not using .dockerignore
Similar to .gitignore, a .dockerignore file tells Docker which files to exclude from the build context. Create a .dockerignore file in your project root:
__pycache__
*.pyc
.git
.env
*.log
Without it, Docker copies everything into the build context — including your .git folder, virtual environments, log files, all of it. That slows down builds and can accidentally bake secrets into your image.
3. Running as root
By default, Docker containers run as the root user. For production images, this is a security risk. Add a non-root user to your Dockerfile:
RUN useradd -m appuser
USER appuser
Add this before your CMD. Not critical for learning, but a good habit to build early.
4. Using latest as your tag in production
latest sounds like it means the newest version, but it's just a default tag — Docker doesn't automatically update it. Always use explicit version tags like v1.0, 2024.03, or a git commit hash in production. This way you always know exactly what's running.
5. Putting secrets in the Dockerfile
Never do this:
ENV DATABASE_PASSWORD=supersecret123 # ❌ This is baked into the image forever
Anyone who pulls your image can see that. Use environment variables at runtime with -e flags or tools like Docker Secrets / .env files.
When Docker builds your image, it creates a stack of layers — each instruction adds a new layer on top. Think of it like a Photoshop file with multiple layers. The final image is all those layers combined.
Layer 5: COPY . . (your app code)
Layer 4: RUN pip install... (installed packages)
Layer 3: COPY requirements.txt . (the requirements file)
Layer 2: WORKDIR /app (directory setup)
Layer 1: FROM python:3.12-slim (the base OS + Python)
When you change your code and rebuild, Docker only rebuilds from the layer that changed onward. Everything above the change gets cached. That's why layer ordering matters — put things that change frequently (your code) at the bottom, and things that change rarely (base image, system packages) at the top.
You've dockerized a Flask app. Now here's your challenge to cement what you've learned:
Level 1 (Easy): Add a new route /health to app.py that returns {"status": "ok"}. Rebuild the image with tag v2 and run it. Verify the new route works.
Level 2 (Medium): Create a Dockerfile for a Node.js app. Start with node:20-slim as your base image. Use npm install instead of pip install. Hint: your COPY pattern will be COPY package.json . then RUN npm install then COPY . . — same caching trick.
Level 3 (Hard): Add a .dockerignore file to your Python project. Add a USER instruction to run the app as a non-root user. Check the final image size with docker image ls and see if you can make it smaller by trying python:3.12-alpine as the base image instead of slim. (Spoiler: alpine is tiny but sometimes needs extra build tools. That's a fun rabbit hole.)
| Instruction | When it runs | What it does |
|---|---|---|
FROM | Build time | Sets the base image |
WORKDIR | Build time | Sets working directory |
COPY | Build time | Copies files from host to image |
RUN | Build time | Executes a shell command |
CMD | Runtime | Default command when container starts |
ENV | Build + Runtime | Sets environment variables |
EXPOSE | Documentation | Documents which port the app uses |
USER | Build time | Sets the user for subsequent commands |
A Dockerfile is just a recipe. That's really all it is. You pick a starting point with FROM, set things up with RUN, bring in your code with COPY, and tell Docker what to run with CMD. Order matters, caching matters, and keeping your image lean matters.
The best way to get comfortable with Dockerfiles is to write them for things you already use — your side project, a script you run regularly, even a simple tool you install on every machine. Pick it, Dockerize it, and suddenly containers stop feeling like magic and start feeling obvious.
Next in this series: we'll look at multi-stage builds — a technique that lets you build your code in a fat image full of build tools, and then copy just the compiled output into a tiny, clean production image. It's one of those things that makes you go "wait, that's brilliant."
Until then — write some Dockerfiles, break some stuff, and don't forget the dot at the end of docker build.
Found this helpful? Share it with a friend who's just getting started with Docker. And if you have questions, drop them in the comments — I read every single one.