Running Docker images is useful. Building your own images is where you start becoming a real Docker practitioner.
How do you package your own application into a Docker image?
That's what Dockerfiles are for.
A Dockerfile turns your application's code, dependencies, runtime, and startup instructions into a reproducible Docker image.
In this article, you'll learn:
- What a Dockerfile is
- The most important Dockerfile instructions
- How
FROM,WORKDIR,COPY,RUN,EXPOSE, andCMDwork - Why Dockerfile instruction order matters
- How Docker build caching works
- How to build and run your own image
- Why
.dockerignoreis important
From Docker User to Docker Author
Until now, you've mostly worked with images created by other people.
For example:
docker run nginx
docker run redis
docker run mongo
These commands use images that already exist.
Today, you'll create an image for an application yourself.
That changes the way you think about Docker.
Instead of asking:
"How do I run this image?"
You'll start asking:
"How should I package this application into an image?"
This is an important step because Dockerfiles appear everywhere in modern software delivery.
They are used by:
- Development teams
- CI/CD pipelines
- Cloud platforms
- Kubernetes deployments
- Container registries
- Production environments
Once you understand Dockerfiles, you have a skill that carries directly into the rest of your DevOps journey.
What Is a Dockerfile?
A Dockerfile is a plain-text file containing instructions that Docker uses to build an image.
Think of it as a recipe.
Dockerfile
|
v
+-----------------------+
| Base image |
| Dependencies |
| Application code |
| Configuration |
| Startup command |
+-----------------------+
|
v
Docker Image
A Dockerfile describes:
- Which base image to use
- Where the application should live
- Which dependencies need to be installed
- Which files should be copied
- Which port the application uses
- Which command should run when the container starts
Because the Dockerfile is just a text file, you can commit it to Git alongside your application source code.
That gives you a reproducible way to build the same image again and again.
FROM — Choose the Base Image
Every Dockerfile normally starts with FROM.
Example:
FROM python:3.12-slim
FROM tells Docker which existing image should be used as the starting point.
In this example, we're starting with Python 3.12 on a slim Linux base.
The choice of base image matters.
A smaller image generally means:
- Faster downloads
- Smaller storage requirements
- Smaller deployment artifacts
- Fewer unnecessary packages
Common Python base images include:
| Base Image | Characteristics |
|---|---|
python:3.12 |
Full Python image with more system packages |
python:3.12-slim |
Smaller image with fewer unnecessary packages |
python:3.12-alpine |
Very small Alpine-based image |
For many applications, a slim image is a good starting point.
Why Avoid latest?
You could write:
FROM python:latest
But the meaning of latest can change over time.
A future build could use a different Python version than today's build.
For reproducibility, explicit versions are usually better:
FROM python:3.12-slim
The more controlled your base image is, the more predictable your builds become.
WORKDIR — Set the Working Directory
Next, define where your application will live inside the image.
WORKDIR /app
This is similar to using cd on Linux.
After this instruction, subsequent commands operate relative to /app.
For example:
WORKDIR /app
COPY requirements.txt .
COPY app.py .
The files will be copied into:
/app/requirements.txt
/app/app.py
Docker also creates the directory if it doesn't already exist.
Using WORKDIR is cleaner than repeatedly writing:
RUN mkdir /app
RUN cd /app
The second approach is also misleading because each RUN instruction executes in its own build step.
WORKDIR is the standard and predictable way to define the working directory.
COPY — Bring Application Files Into the Image
The COPY instruction transfers files from your build context into the image.
Example:
COPY requirements.txt .
COPY . .
The first argument is the source.
The second argument is the destination.
Because we previously defined:
WORKDIR /app
this:
COPY requirements.txt .
means:
Host:
requirements.txt
|
v
Container:
/app/requirements.txt
You can also copy the entire application:
COPY . .
But there is an important reason why you should not always do this immediately.
We'll come back to that when we discuss build caching.
RUN — Execute Commands During the Build
RUN executes a command while Docker is building the image.
For a Python application:
RUN pip install --no-cache-dir -r requirements.txt
This installs the application's dependencies into the image.
Other examples include:
RUN apt-get update
RUN npm install
RUN pip install flask
The important thing to remember is:
RUN happens during image building.
For example:
docker build -t my-app .
During this process, Docker executes the RUN instructions.
The resulting changes become part of the image.
RUN vs CMD
This distinction is extremely important.
RUN happens when the image is built.
CMD happens when the container is started.
Think about it this way:
docker build
|
+--> RUN instructions
|
v
Docker Image
|
| docker run
v
Container
|
+--> CMD
Example:
RUN pip install -r requirements.txt
This installs dependencies during the build.
Then:
CMD ["python", "app.py"]
starts the application when the container runs.
EXPOSE — Document the Application Port
Suppose your application listens on port 5000.
You can document that in the Dockerfile:
EXPOSE 5000
But there's an important detail:
EXPOSE does not publish the port.
It is primarily metadata/documentation that tells readers and tooling which port the application expects to use.
To actually make the application reachable from your host, use -p:
docker run -p 5000:5000 my-flask-app
The mapping means:
Host Container
5000 -------------> 5000
So:
http://localhost:5000
can reach the application inside the container.
CMD — Define the Default Startup Command
CMD defines what should run when a container starts.
For a Python application:
CMD ["python", "app.py"]
This is different from RUN.
RUN happens during the build.
CMD happens when the container starts.
For example:
docker build -t my-flask-app .
creates the image.
Then:
docker run my-flask-app
starts a container and executes:
python app.py
Prefer Exec Form
The recommended form is:
CMD ["python", "app.py"]
rather than:
CMD python app.py
The JSON-array form is called the exec form.
It allows the application process to receive operating system signals more directly, which is important for graceful shutdown.
For example, when you run:
docker stop my-app
Docker can send the termination signal to the application process.
A Complete Dockerfile
Imagine this project:
my-flask-app/
├── app.py
├── requirements.txt
└── Dockerfile
The Dockerfile could look like this:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Read it from top to bottom:
FROM
↓
Choose Python
WORKDIR
↓
Move into /app
COPY requirements.txt
↓
Bring in dependencies
RUN pip install
↓
Install dependencies
COPY .
↓
Bring in application code
EXPOSE
↓
Document application port
CMD
↓
Start the application
This simple file describes the complete environment required to run the application.
Why Dockerfile Instruction Order Matters
This is one of the most important Docker concepts to understand.
Docker images are built in layers.
For example:
Layer 5: CMD ["python", "app.py"]
Layer 4: COPY . .
Layer 3: RUN pip install ...
Layer 2: COPY requirements.txt .
Layer 1: FROM python:3.12-slim
Docker can cache these layers.
That means if something hasn't changed, Docker can reuse the previous result instead of doing the work again.
This makes builds much faster.
Understanding Docker Build Cache
Imagine your application has 20 Python dependencies.
Installing them could take some time.
Now imagine you change one line of app.py.
You rebuild the image.
Do you really want Docker to reinstall all 20 dependencies?
No.
That's why we structure the Dockerfile like this:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
The dependency file is copied first.
The application code is copied later.
Now consider what happens when you change only:
app.py
Docker sees that:
requirements.txt
has not changed.
So it can reuse the cached dependency installation layer.
Only the later application layer needs to be rebuilt.
A Common Dockerfile Mistake
A beginner might write:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]
This works.
But it can be inefficient.
Why?
Because COPY . . includes your application code.
Every time any source file changes, Docker may invalidate that layer and everything after it.
That means:
Change app.py
|
v
COPY . . changes
|
v
pip install runs again
That is unnecessary.
The Better Dockerfile
Instead, separate dependencies from application code:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Now:
requirements.txt changes
|
v
Reinstall dependencies
app.py changes
|
v
Reuse dependency cache
This simple ordering decision can make a huge difference during development and CI/CD builds.
The General Rule
A useful principle is:
Put instructions that change rarely before instructions that change frequently.
For example:
Base image
↓
System dependencies
↓
Application dependencies
↓
Application source code
The less frequently changing layers are placed earlier so Docker can reuse them as often as possible.
Building Your Docker Image
Once you have a Dockerfile, build the image:
docker build -t my-flask-app .
Let's break this down.
docker build
Tells Docker to build an image.
-t my-flask-app
Assigns a name to the image.
The -t option means tag.
You can also specify a version:
docker build -t my-flask-app:1.0 .
This is useful because explicit image versions make deployments easier to reproduce.
.
The dot specifies the build context.
It tells Docker:
"Use the current directory as the build context."
Docker can access files from this context when processing instructions such as COPY.
Check Your New Image
After the build completes:
docker images
You should see something similar to:
REPOSITORY TAG IMAGE ID CREATED SIZE
my-flask-app latest abc123def456 10 seconds ago 150MB
You can also use an explicit version:
docker build -t my-flask-app:1.0 .
Then:
docker images
might show:
REPOSITORY TAG IMAGE ID
my-flask-app 1.0 abc123def456
Versioning your images becomes especially important once you start using registries and CI/CD pipelines.
Run Your Application
Now start a container:
docker run -d --name my-app -p 5000:5000 my-flask-app:1.0
Check that it's running:
docker ps
Check the application logs:
docker logs my-app
Then test it:
curl http://localhost:5000
Or open this in your browser:
http://localhost:5000
You've now taken your own application and turned it into a Docker container.
.dockerignore — Your Docker Build's .gitignore
If you've worked with Git, you already know about:
.gitignore
It prevents unwanted files from being tracked by Git.
Docker has a similar concept:
.dockerignore
It prevents unwanted files from being included in the Docker build context.
For example:
__pycache__/
*.pyc
.git/
.gitignore
.env
node_modules/
*.log
This is important for several reasons.
Smaller Build Context
You don't need to send unnecessary files to Docker.
For example:
.git/
node_modules/
logs/
can be very large.
Ignoring them makes builds more efficient.
Smaller Images
If unnecessary files aren't copied into the image, the resulting image can be smaller.
Security
Most importantly, don't accidentally copy secrets.
For example:
.env
might contain:
DATABASE_PASSWORD=secret
API_KEY=abc123
You do not want those credentials baked into your Docker image.
Just as you should never commit secrets to Git, you should also avoid putting secrets into container images.
A Practical .dockerignore
A basic Python project might use:
__pycache__/
*.pyc
.git/
.gitignore
.env
.venv/
venv/
*.log
For a Node.js application:
node_modules/
.git/
.env
*.log
The exact contents depend on your project.
The principle is simple:
Only send Docker the files it actually needs.
COPY vs ADD
You may also see another Dockerfile instruction:
ADD
ADD provides some additional behavior, such as handling local archives.
However, for normal file copying, COPY is generally preferred because its behavior is simpler and more predictable.
Use:
COPY
for ordinary application files.
Use ADD only when you specifically need one of its additional features.
Common Dockerfile Mistakes
Using latest Everywhere
Avoid:
FROM python:latest
when reproducibility matters.
Prefer:
FROM python:3.12-slim
Copying Everything Too Early
Avoid:
COPY . .
RUN pip install -r requirements.txt
when you can separate dependencies first.
Prefer:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Confusing EXPOSE With Port Publishing
This:
EXPOSE 5000
does not make the application available on your host.
You still need:
docker run -p 5000:5000 my-app
Putting Secrets in the Image
Never do this:
ENV API_KEY="my-secret-key"
or:
COPY .env .
Secrets should be provided at runtime through appropriate secret-management mechanisms rather than baked into an image.
Forgetting .dockerignore
Without .dockerignore, you may accidentally send:
- Git history
- Dependencies
- Logs
- Local virtual environments
- Environment files
- Secrets
to the Docker build context.
The Dockerfile Mental Model
At this point, you can think about a Dockerfile as a pipeline:
Base Image
|
v
Working Directory
|
v
Application Dependencies
|
v
Application Code
|
v
Runtime Configuration
|
v
Startup Command
|
v
Docker Image
|
v
Docker Container
Each stage has a specific purpose.
Once this mental model becomes familiar, Dockerfiles stop looking like mysterious configuration files.
They become simple instructions describing how your application should be packaged.
From Dockerfile to CI/CD
Dockerfiles become even more powerful when combined with CI/CD.
Imagine a Git push:
Developer
|
v
Git Push
|
v
CI Pipeline
|
v
docker build
|
v
Docker Image
|
v
Container Registry
|
v
Deployment
This is one of the most common patterns in modern software delivery.
Later in your DevOps journey, you'll build CI/CD pipelines that automatically:
- Build Docker images
- Run tests
- Tag images
- Push images to a registry
- Deploy them to an environment
The Dockerfile you write today becomes one of the core building blocks of that pipeline.
Hands-On Challenge
Create a simple Python application.
Your project should look like this:
docker-demo/
├── app.py
├── requirements.txt
├── Dockerfile
└── .dockerignore
Create app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from Docker!"
app.run(host="0.0.0.0", port=5000)
Create requirements.txt:
flask
Create the Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Create .dockerignore:
__pycache__/
*.pyc
.git/
.env
venv/
.venv/
Build the image:
docker build -t docker-demo:1.0 .
Run it:
docker run -d --name docker-demo -p 5000:5000 docker-demo:1.0
Check the container:
docker ps
Check the logs:
docker logs docker-demo
Test the application:
curl http://localhost:5000
You should get:
Hello from Docker!
Final Thoughts
Docker becomes much more useful once you stop relying only on images created by others.
A Dockerfile gives you a reproducible way to package your own application with its runtime and dependencies.
The most important concepts from today are:
-
FROMdefines the base image -
WORKDIRdefines the working directory -
COPYbrings files into the image -
RUNexecutes commands during the build -
EXPOSEdocuments the application's port -
CMDdefines the default startup command - Docker layers make build caching possible
- Instruction order can dramatically improve build performance
-
.dockerignorekeeps unnecessary and sensitive files out of the build context
The most important habit to take away is this:
Build images to be reproducible, small, secure, and easy to rebuild.
Once you can confidently write a Dockerfile for your own application, you're no longer just running containers.
You're building the containers that power the rest of your DevOps workflow.
Top comments (0)