Writing a Dockerfile
A Dockerfile is a recipe for building a Docker image. It lists the exact steps to create an environment for your application.
Why Dockerfiles?
Instead of saying "install Node 20, then copy files, then run npm install..." you write it once:
FROM node:20
COPY . .
RUN npm install
CMD ["node", "server.js"]
Anyone with this Dockerfile can build the exact same image. No setup docs needed.
Anatomy of a Dockerfile
Let's build a Dockerfile for a simple e-commerce product API:
# Start from a Node.js base image
FROM node:20-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
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Command to run when container starts
CMD ["node", "server.js"]
Instruction Breakdown
FROM - Choose Your Base
Every Dockerfile starts with FROM. It's what you're building on top of.
# Official Node.js image
FROM node:20
# Alpine variant (smaller, ~50MB vs ~350MB)
FROM node:20-alpine
# Slim variant (smaller than full, larger than alpine)
FROM node:20-slim
# Specific version for stability
FROM node:20.10.0-alpine
For e-commerce production, alpine is popular - smaller images mean faster deploys.
WORKDIR - Set Your Location
WORKDIR /app
Creates the directory if it doesn't exist and sets it as the current directory. All following commands run here.
COPY - Bring In Files
# Copy specific files
COPY package.json ./
COPY package-lock.json ./
# Copy with wildcard
COPY package*.json ./
# Copy everything
COPY . .
# Copy to a specific location
COPY src/ ./src/
RUN - Execute Build Commands
# Install dependencies
RUN npm install
# Run multiple commands
RUN npm install && npm run build
# Install system packages (in alpine)
RUN apk add --no-cache python3 make g++
Each RUN creates a new layer in the image.
EXPOSE - Document Ports
EXPOSE 3000
This is documentation - it doesn't actually publish the port. You still need -p when running.
CMD - Default Command
# Exec form (preferred)
CMD ["node", "server.js"]
# Shell form
CMD node server.js
This runs when the container starts. Can be overridden at runtime.
Building Your First Image
Create a project structure:
product-api/
├── Dockerfile
├── package.json
└── server.js
package.json:
{
"name": "product-api",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
server.js:
const express = require('express');
const app = express();
const products = [
{ id: 1, name: 'Wireless Headphones', price: 79.99, stock: 45 },
{ id: 2, name: 'USB-C Cable', price: 12.99, stock: 200 },
{ id: 3, name: 'Laptop Stand', price: 49.99, stock: 30 }
];
app.get('/products', (req, res) => {
res.json(products);
});
app.get('/products/:id', (req, res) => {
const product = products.find(p => p.id === parseInt(req.params.id));
if (product) {
res.json(product);
} else {
res.status(404).json({ error: 'Product not found' });
}
});
app.listen(3000, () => {
console.log('Product API running on port 3000');
});
Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Build it:
docker build -t product-api .
-t product-apitags the image with a name.is the build context (current directory)
[+] Building 15.2s (10/10) FINISHED
=> [1/5] FROM node:20-alpine
=> [2/5] WORKDIR /app
=> [3/5] COPY package*.json ./
=> [4/5] RUN npm install
=> [5/5] COPY . .
=> exporting to image
Run it:
docker run -d -p 3000:3000 --name my-product-api product-api
Test it:
curl http://localhost:3000/products
Understanding Layer Caching
Docker caches each instruction as a layer. This is why order matters:
# GOOD: Dependencies cached separately
COPY package*.json ./
RUN npm install
COPY . .
# BAD: npm install runs on every code change
COPY . .
RUN npm install
With the good approach:
- Change
server.js - Rebuild - Docker reuses cached
npm installlayer - Build takes seconds instead of minutes
.dockerignore
Create .dockerignore to exclude files from the build:
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
.env
This is crucial:
- node_modules - Rebuilt inside container anyway
- .env - Secrets shouldn't be baked into images
- .git - Not needed, wastes space
Multi-Stage Builds (Preview)
For production, you can use multi-stage builds to create smaller images:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]
The final image only contains what's needed to run, not build tools.
Common Dockerfile Patterns
Adding a health check:HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:3000/health || exit 1
Running as non-root user:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Setting environment defaults:
ENV NODE_ENV=production
ENV PORT=3000
Key Takeaways
- FROM sets the base image
- WORKDIR sets the working directory
- COPY brings files into the image
- RUN executes commands during build
- CMD defines the default runtime command
- Layer order matters for caching
- Use .dockerignore to exclude files
Next, let's build a complete Product API image for our e-commerce app!