In the previous tutorial, we ran containers from pre-built images like ubuntu and nginx. That is useful, but in real projects you need to package your own application into an image.

That is what a Dockerfile does. A Dockerfile is a text file with instructions that tell Docker how to build an image. Think of it as a recipe — each line is a step.

Your First Dockerfile

Let’s create a simple Node.js application and package it with Docker.

Create a new folder:

mkdir my-app
cd my-app

Create a file called server.js:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from Docker!\n");
});

server.listen(3000, () => {
  console.log("Server running on port 3000");
});

Now create a file called Dockerfile (no file extension):

FROM node:20-alpine

WORKDIR /app

COPY server.js .

EXPOSE 3000

CMD ["node", "server.js"]

That is it. Five lines. Let’s understand each one.

Dockerfile Instructions

FROM — The Base Image

Every Dockerfile starts with FROM. It tells Docker which base image to use.

FROM node:20-alpine

This uses Node.js version 20 on Alpine Linux. Alpine is a tiny Linux distribution (about 5 MB). It makes your images much smaller than using the default Debian-based images.

Always use a specific version tag like node:20-alpine. Do not use node:latest — the latest version changes over time and can break your build.

WORKDIR — Set the Working Directory

WORKDIR /app

This sets the working directory inside the container to /app. All following commands run from this directory. If the directory does not exist, Docker creates it.

COPY — Copy Files into the Image

COPY server.js .

This copies server.js from your computer into the /app directory inside the image. The . means the current working directory (which we set to /app).

You can copy multiple files:

COPY package.json package-lock.json ./
COPY src/ ./src/

RUN — Execute Commands During Build

RUN npm install

RUN executes a command while building the image. Common uses:

  • Installing dependencies: RUN npm install or RUN pip install -r requirements.txt
  • Installing system packages: RUN apt-get update && apt-get install -y curl
  • Creating directories: RUN mkdir -p /app/data

Each RUN instruction creates a new layer in the image. We will talk about layers soon.

EXPOSE — Document the Port

EXPOSE 3000

EXPOSE tells Docker (and other developers) that the container listens on port 3000. It is documentation — it does not actually open the port. You still need -p when running the container.

CMD — The Default Command

CMD ["node", "server.js"]

CMD sets the default command that runs when a container starts. There can only be one CMD in a Dockerfile. If you add multiple, only the last one takes effect.

The square bracket syntax ["node", "server.js"] is called the “exec form”. It is preferred over the shell form CMD node server.js because it handles signals correctly.

ENTRYPOINT — The Fixed Command

ENTRYPOINT is similar to CMD, but with a key difference:

  • CMD can be overridden when running the container
  • ENTRYPOINT is fixed — it always runs
# With CMD
CMD ["python", "app.py"]
# docker run my-app           → runs: python app.py
# docker run my-app bash      → runs: bash (CMD is overridden)

# With ENTRYPOINT
ENTRYPOINT ["python"]
CMD ["app.py"]
# docker run my-app           → runs: python app.py
# docker run my-app test.py   → runs: python test.py (CMD is overridden, ENTRYPOINT stays)

Use ENTRYPOINT when your container should always run the same executable. Use CMD when you want flexibility.

ENV — Environment Variables

ENV NODE_ENV=production
ENV PORT=3000

ENV sets environment variables that are available at both build time and runtime. Your application can read them with process.env.NODE_ENV (Node.js) or os.environ["NODE_ENV"] (Python).

Building the Image

Go back to the my-app folder and build the image:

docker build -t my-app .
  • -t my-app — gives the image the name “my-app”
  • . — the build context (the current directory)

Docker reads the Dockerfile and executes each instruction:

Step 1/5 : FROM node:20-alpine
 ---> 1a2b3c4d5e6f
Step 2/5 : WORKDIR /app
 ---> Running in 7g8h9i0j1k2l
Step 3/5 : COPY server.js .
 ---> 3m4n5o6p7q8r
Step 4/5 : EXPOSE 3000
 ---> 9s0t1u2v3w4x
Step 5/5 : CMD ["node", "server.js"]
 ---> 5y6z7a8b9c0d
Successfully built 5y6z7a8b9c0d
Successfully tagged my-app:latest

Now run it:

docker run -d -p 3000:3000 --name my-app my-app

Open http://localhost:3000 in your browser. You should see “Hello from Docker!”

Stop and remove the container when you are done:

docker stop my-app && docker rm my-app

The Build Context and .dockerignore

When you run docker build ., Docker sends the entire directory to the Docker engine. This is the build context. If the directory contains large files (like node_modules, .git, or log files), the build is slow.

Create a .dockerignore file to exclude files from the build context:

node_modules
.git
.env
*.md
Dockerfile
docker-compose.yml
.DS_Store

This works exactly like .gitignore. Always add a .dockerignore to your projects. It makes builds faster and prevents secrets (like .env files) from ending up in your image.

Layers and Caching

Docker images are made of layers. Each instruction in a Dockerfile creates a new layer. Docker caches layers to speed up rebuilds.

Here is the important part: if a layer changes, all layers after it are rebuilt.

Look at this Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Every time you change server.js, Docker copies all files (COPY . .), sees that the layer changed, and runs npm install again — even if your dependencies did not change.

Now look at this improved version:

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]

Here we copy the dependency files first and install dependencies. Then we copy the rest of the code. When you change server.js, Docker reuses the cached npm install layer because package.json did not change.

Rule: copy things that change less often first.

Layer 1: FROM node:20-alpine          ← cached (rarely changes)
Layer 2: WORKDIR /app                 ← cached
Layer 3: COPY package.json ./         ← cached (dependencies rarely change)
Layer 4: RUN npm install              ← cached
Layer 5: COPY . .                     ← rebuilt (code changed)
Layer 6: CMD ["node", "server.js"]    ← rebuilt

A Practical Example: Python App

Let’s containerize a Python application. Create a folder called my-python-app with these files.

app.py:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello from Docker!"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

requirements.txt:

flask==3.1.0

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"]

Build and run:

docker build -t my-python-app .
docker run -d -p 5000:5000 --name my-python-app my-python-app

Open http://localhost:5000 to see the result.

Multi-Stage Builds

A typical image contains both build tools and the final application. Build tools take space but are not needed at runtime. Multi-stage builds solve this.

Here is an example with a React app:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

What happens here:

  1. Stage 1 uses Node.js to install dependencies and build the app. This stage has all the build tools.
  2. Stage 2 uses a tiny Nginx image. It copies only the built files from stage 1.

The final image only contains Nginx and the static files. It does not contain Node.js, node_modules, or source code. The result is a much smaller image.

ApproachImage Size
Single stage (Node + app + build tools)~400 MB
Multi-stage (Nginx + built files only)~25 MB

Use multi-stage builds whenever your application has a build step (TypeScript, Go, Rust, Java, React, etc.).

Best Practices

Use Specific Base Image Tags

# Bad — version changes unexpectedly
FROM python:latest

# Good — pinned version
FROM python:3.12-slim

Run as Non-Root

By default, containers run as root. This is a security risk. If an attacker exploits your application, they have root access inside the container.

FROM node:20-alpine

WORKDIR /app
COPY . .
RUN npm ci --production

# Create and switch to a non-root user
USER node

CMD ["node", "server.js"]

The node user already exists in the Node.js Alpine image. For other images, you can create a user:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Minimize Layers

Combine related RUN commands with &&:

# Bad — creates 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# Good — creates 1 layer
RUN apt-get update && \
    apt-get install -y curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Use .dockerignore

Always add a .dockerignore file. At minimum, exclude:

node_modules
.git
.env
*.md

Use COPY Instead of ADD

Docker has two instructions for copying files: COPY and ADD. They look similar, but ADD has extra features — it can extract tar archives and download files from URLs. This makes ADD unpredictable. Use COPY unless you specifically need tar extraction.

# Good — simple and predictable
COPY requirements.txt .

# Only use ADD for tar extraction
ADD archive.tar.gz /app/

Add a Version Tag When Building

# Tag with a version
docker build -t my-app:1.0 .

# Tag for a registry
docker build -t kemalcodes/my-app:1.0 .

Inspecting an Image

After building, you can check the size and layers of your image:

# Check image size
docker images my-app

# See all layers and their sizes
docker history my-app

The docker history command shows each layer, the instruction that created it, and its size. This helps you find which layers are taking the most space and where to optimize.

Common Mistakes

1. Putting COPY . . before RUN npm install. This breaks layer caching. Every code change triggers a full dependency reinstall. Copy dependency files first, install them, then copy the rest of the code.

2. Using ADD when COPY is enough. The ADD instruction can extract tar files and download URLs. Most of the time you just need to copy files. Use COPY — it is simpler and more predictable. Use ADD only when you specifically need its extra features.

3. Forgetting .dockerignore. Without it, node_modules (hundreds of MB) and .git (potentially huge) get sent to the Docker engine on every build. Create a .dockerignore in every project.

What We Learned

In this tutorial, you learned:

  • A Dockerfile is a recipe for building a Docker image
  • Key instructions: FROM, WORKDIR, COPY, RUN, EXPOSE, CMD, ENTRYPOINT
  • docker build -t name . builds an image from a Dockerfile
  • .dockerignore excludes files from the build context
  • Layer order matters for caching — copy things that change less often first
  • CMD is the default command (can be overridden); ENTRYPOINT is fixed
  • Multi-stage builds produce smaller images by separating build and runtime
  • Best practices: specific tags, non-root user, minimal layers

What’s Next?

In the next tutorial, we will learn about Docker Compose — a tool for running multiple containers together. Most real applications need more than one container (for example, an app server and a database). Docker Compose makes this easy.