Skip to content

Join the Seedly owners community →

Docker

Writing Dockerfiles

Learn to write Dockerfiles with FROM, RUN, COPY, WORKDIR, EXPOSE, and CMD instructions

Written by 13 min read1 activity
Buzz, your presenter

Buzz presents

a Dockerfile is a little recipe for your image, and putting the steps in a smart order makes builds sooo much faster!a Dockerfile is a little recipe for your image, and putting the steps in a smart order makes builds sooo much faster!

Buzz builds a layered cake on a stand, following a picture card of each step
A Dockerfile is a recipe where each step adds a layer

A Dockerfile is just a text file full of step-by-step instructions for building a Docker image. That's it. Think of it like a recipe... each instruction adds a layer to your image, and once Docker works through every step, you've got an app image that's ready to run.

I grew up working construction with my dad, and his whole thing was "I can fix anything." A Dockerfile gives you that same feeling with software. When you wrote the recipe yourself, you know exactly what's in the build, so when something breaks you can actually fix it.

Your First Dockerfile#

Let's kick things off with a simple Dockerfile for a Node.js app.

# Start with a base image that includes Node.js
FROM node:24-alpine
 
# Set the working directory inside the container
WORKDIR /app
 
# Copy package files first (for better caching)
COPY package*.json ./
 
# Install dependencies
RUN npm install
 
# Copy the rest of the application code
COPY . .
 
# Document which port the app uses
EXPOSE 3000
 
# Define the command to run when the container starts
CMD ["node", "server.js"]

Every line is an instruction. Let's walk through them one at a time.

FROM: Choose Your Base Image#

Every Dockerfile starts with FROM. It picks the base image, which is the starting point you build on top of. You're not starting from scratch here, you're standing on stuff that already exists.

# Start with Node.js on Alpine Linux (small and fast)
FROM node:24-alpine
 
# Or start with Python
FROM python:3.12-slim
 
# Or start with a bare-minimum Linux
FROM alpine:3.24

WORKDIR: Set the Working Directory#

WORKDIR sets the folder where every command after it runs. If that folder doesn't exist yet, Docker makes it for you.

# All subsequent commands run inside /app
WORKDIR /app
 
# You can use WORKDIR multiple times
WORKDIR /app/src

It's basically like running cd /app before every command. Leave it out and you're working in the container's root directory, which gets messy FAST.

COPY: Add Files to the Image#

COPY moves files from your computer into the image.

# Copy a single file
COPY package.json ./
 
# Copy multiple files using a wildcard
COPY package*.json ./
 
# Copy everything in the current directory
COPY . .
 
# Copy to a specific location
COPY config/default.json /app/config/

The first path is the source (on your machine) and the second one is the destination (inside the image).

RUN: Execute Commands During Build#

RUN runs a command while the image is being built. You'll use it to install dependencies, make folders or set up the environment.

# Install Node.js dependencies
RUN npm install
 
# Install system packages
RUN apk add --no-cache curl
 
# Create a directory
RUN mkdir -p /app/logs
 
# Run multiple commands (combine for fewer layers)
RUN npm install && npm run build && rm -rf /tmp/*

EXPOSE: Document the Port#

EXPOSE tells Docker (and any other developer reading the file) which port your app listens on. It's documentation only and doesn't actually open anything, so you still need -p when you run the container.

# Web application on port 3000
EXPOSE 3000
 
# API on port 8080
EXPOSE 8080

CMD: Define the Startup Command#

CMD sets the command that runs when a container starts. You only want one CMD per Dockerfile (if you put in more, the last one wins).

# Preferred format (exec form) - use JSON array syntax
CMD ["node", "server.js"]
 
# Shell form (works but less efficient)
CMD node server.js
 
# For Python applications
CMD ["python", "app.py"]

The Correct Order Matters#

Buzz swaps only the berry topping while the lower cake layers stay under a glass dome
Put rarely changing steps first so Docker can reuse them

The order of your instructions changes how fast builds go, all because of layer caching. Docker caches every layer, and if a layer hasn't changed, it reuses the cached copy. But the moment one layer changes, every layer after it gets rebuilt.

Here's the smart order for a Node.js app.

# Rarely changes
FROM node:24-alpine
# Rarely changes
WORKDIR /app
# Changes when deps change
COPY package*.json ./
# Rebuilds only if package.json changed
RUN npm install
# Changes often (your source code)
COPY . .
# Never changes
EXPOSE 3000
# Rarely changes
CMD ["node", "server.js"]

So here's the big trick. Copy package.json and install dependencies BEFORE you copy your source code. That way, when you only touch your source code, Docker reuses the cached npm install layer and you save minutes on every single build.

A Complete Example: Express.js App#

Here's a realistic Dockerfile for a production Express.js app.

# Use a specific Node.js version on Alpine for small size
FROM node:24-alpine
 
# Set working directory
WORKDIR /app
 
# Copy package files
COPY package.json package-lock.json ./
 
# Install only production dependencies
RUN npm ci --omit=dev
 
# Copy application source code
COPY src/ ./src/
 
# Document the port
EXPOSE 3000
 
# Set environment to production
ENV NODE_ENV=production
 
# Start the application
CMD ["node", "src/index.js"]

Building and Running Your Image#

Got a Dockerfile? Build it and run it.

# Build the image (run from the same directory as the Dockerfile)
docker build -t my-express-app .
 
# Run it
docker run -d -p 3000:3000 my-express-app
 
# Visit http://localhost:3000 in your browser!

ENV: Set Environment Variables#

ENV sets environment variables that are there both during the build and when the container runs.

# Set Node environment
ENV NODE_ENV=production
 
# Set multiple variables
ENV PORT=3000
ENV DB_HOST=localhost

Common Dockerfile Patterns#

Python Application#

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]

Static Website with Nginx#

FROM nginx:alpine
COPY dist/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

TL;DR#

  • A Dockerfile is a recipe with step-by-step instructions for building an image
  • FROM picks the base image, and WORKDIR sets the working directory
  • COPY brings files in, and RUN runs commands at build time
  • EXPOSE documents the port, and CMD sets the startup command
  • Order matters, so put the stuff that rarely changes first for better caching
  • Copy dependency files before source code to get the most out of layer caching
  • Use the exec form for CMD, like CMD ["node", "server.js"]

What's Next?#

Your containers can run apps now... but what about your data? When a container gets removed, everything inside it vanishes. Next lesson covers how volumes keep data around after a container is gone and how networks let containers talk to each other.

This lesson ends with a short activity.