DEV Community

Cover image for Dockerfiles: Build Your Own Images
Md Mohiuddin
Md Mohiuddin

Posted on

Dockerfiles: Build Your Own Images

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, and CMD work
  • Why Dockerfile instruction order matters
  • How Docker build caching works
  • How to build and run your own image
  • Why .dockerignore is 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A Dockerfile describes:

  1. Which base image to use
  2. Where the application should live
  3. Which dependencies need to be installed
  4. Which files should be copied
  5. Which port the application uses
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

The files will be copied into:

/app/requirements.txt
/app/app.py
Enter fullscreen mode Exit fullscreen mode

Docker also creates the directory if it doesn't already exist.

Using WORKDIR is cleaner than repeatedly writing:

RUN mkdir /app
RUN cd /app
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

The first argument is the source.

The second argument is the destination.

Because we previously defined:

WORKDIR /app
Enter fullscreen mode Exit fullscreen mode

this:

COPY requirements.txt .
Enter fullscreen mode Exit fullscreen mode

means:

Host:
requirements.txt

        |
        v

Container:
/app/requirements.txt
Enter fullscreen mode Exit fullscreen mode

You can also copy the entire application:

COPY . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This installs the application's dependencies into the image.

Other examples include:

RUN apt-get update
Enter fullscreen mode Exit fullscreen mode
RUN npm install
Enter fullscreen mode Exit fullscreen mode
RUN pip install flask
Enter fullscreen mode Exit fullscreen mode

The important thing to remember is:

RUN happens during image building.

For example:

docker build -t my-app .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

RUN pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

This installs dependencies during the build.

Then:

CMD ["python", "app.py"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The mapping means:

Host                  Container

5000  ------------->  5000
Enter fullscreen mode Exit fullscreen mode

So:

http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

This is different from RUN.

RUN happens during the build.

CMD happens when the container starts.

For example:

docker build -t my-flask-app .
Enter fullscreen mode Exit fullscreen mode

creates the image.

Then:

docker run my-flask-app
Enter fullscreen mode Exit fullscreen mode

starts a container and executes:

python app.py
Enter fullscreen mode Exit fullscreen mode

Prefer Exec Form

The recommended form is:

CMD ["python", "app.py"]
Enter fullscreen mode Exit fullscreen mode

rather than:

CMD python app.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Docker can send the termination signal to the application process.


A Complete Dockerfile

Imagine this project:

my-flask-app/
├── app.py
├── requirements.txt
└── Dockerfile
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

The dependency file is copied first.

The application code is copied later.

Now consider what happens when you change only:

app.py
Enter fullscreen mode Exit fullscreen mode

Docker sees that:

requirements.txt
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Now:

requirements.txt changes
        |
        v
Reinstall dependencies


app.py changes
        |
        v
Reuse dependency cache
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You should see something similar to:

REPOSITORY      TAG       IMAGE ID       CREATED        SIZE
my-flask-app    latest    abc123def456   10 seconds ago  150MB
Enter fullscreen mode Exit fullscreen mode

You can also use an explicit version:

docker build -t my-flask-app:1.0 .
Enter fullscreen mode Exit fullscreen mode

Then:

docker images
Enter fullscreen mode Exit fullscreen mode

might show:

REPOSITORY      TAG       IMAGE ID
my-flask-app    1.0       abc123def456
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check that it's running:

docker ps
Enter fullscreen mode Exit fullscreen mode

Check the application logs:

docker logs my-app
Enter fullscreen mode Exit fullscreen mode

Then test it:

curl http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

Or open this in your browser:

http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It prevents unwanted files from being tracked by Git.

Docker has a similar concept:

.dockerignore
Enter fullscreen mode Exit fullscreen mode

It prevents unwanted files from being included in the Docker build context.

For example:

__pycache__/
*.pyc
.git/
.gitignore
.env
node_modules/
*.log
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

might contain:

DATABASE_PASSWORD=secret
API_KEY=abc123
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For a Node.js application:

node_modules/
.git/
.env
*.log
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

when reproducibility matters.

Prefer:

FROM python:3.12-slim
Enter fullscreen mode Exit fullscreen mode

Copying Everything Too Early

Avoid:

COPY . .
RUN pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

when you can separate dependencies first.

Prefer:

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Enter fullscreen mode Exit fullscreen mode

Confusing EXPOSE With Port Publishing

This:

EXPOSE 5000
Enter fullscreen mode Exit fullscreen mode

does not make the application available on your host.

You still need:

docker run -p 5000:5000 my-app
Enter fullscreen mode Exit fullscreen mode

Putting Secrets in the Image

Never do this:

ENV API_KEY="my-secret-key"
Enter fullscreen mode Exit fullscreen mode

or:

COPY .env .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Build Docker images
  2. Run tests
  3. Tag images
  4. Push images to a registry
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Create requirements.txt:

flask
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Create .dockerignore:

__pycache__/
*.pyc
.git/
.env
venv/
.venv/
Enter fullscreen mode Exit fullscreen mode

Build the image:

docker build -t docker-demo:1.0 .
Enter fullscreen mode Exit fullscreen mode

Run it:

docker run -d   --name docker-demo   -p 5000:5000   docker-demo:1.0
Enter fullscreen mode Exit fullscreen mode

Check the container:

docker ps
Enter fullscreen mode Exit fullscreen mode

Check the logs:

docker logs docker-demo
Enter fullscreen mode Exit fullscreen mode

Test the application:

curl http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

You should get:

Hello from Docker!
Enter fullscreen mode Exit fullscreen mode

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:

  • FROM defines the base image
  • WORKDIR defines the working directory
  • COPY brings files into the image
  • RUN executes commands during the build
  • EXPOSE documents the application's port
  • CMD defines the default startup command
  • Docker layers make build caching possible
  • Instruction order can dramatically improve build performance
  • .dockerignore keeps 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)