Day 8: Dockerfile Basics - Building Custom Images
Learning Objectives: By the end of this lesson, you will be able to:
- Understand what a Dockerfile is and why it’s essential
- Write basic Dockerfile instructions to create custom images
- Build Docker images from Dockerfiles using the docker build command
- Follow best practices for writing maintainable Dockerfiles
1. Welcome and Introduction (5 minutes)
Icebreaker Activity
Think about the last time you had to set up a development environment on a new machine. How long did it take? What challenges did you face? Today, we’ll learn how Dockerfiles can eliminate these setup headaches by creating reproducible, automated builds.
Lesson Overview
- Duration: 45-60 minutes
- Prerequisites: Completion of Days 1-7, basic understanding of Docker commands
- Materials Needed: Docker installed, text editor, terminal access
2. Learning Objectives and Session Outline (5 minutes)
What We’ll Cover Today:
- Understanding Dockerfiles - What they are and why they matter
- Essential Dockerfile Instructions - FROM, RUN, COPY, WORKDIR, and more
- Building Images - Using docker build command effectively
- Hands-on practice - Creating your first custom Docker image
- Best practices - Writing maintainable and efficient Dockerfiles
Success Criteria:
By the end of this session, you should be able to write a basic Dockerfile and build a custom image that runs a simple application.
3. Foundation Knowledge (15-20 minutes)
What is a Dockerfile?
A Dockerfile is a text file that contains a series of instructions used to build a Docker image automatically. Think of it as a recipe that tells Docker exactly how to create your custom image, step by step.
Real-World Analogy: A Dockerfile is like a detailed recipe for baking a cake. Just as a recipe lists ingredients and step-by-step instructions, a Dockerfile lists the base image and commands needed to create your application environment.
Why Dockerfiles Matter:
- Reproducibility: Same image builds consistently across different machines
- Version Control: Track changes to your environment setup
- Automation: No manual installation steps required
- Documentation: Serves as living documentation of your environment
Docker Image Layers
Every instruction in a Dockerfile creates a new layer in the resulting image. These layers are cached and reused, making subsequent builds faster when only some instructions change.
Key Concepts:
- Base Layer: The foundation image (specified by FROM)
- Intermediate Layers: Created by each instruction
- Final Layer: The complete image ready to run as a container
Key Terminology
- Dockerfile: A text file with instructions to build a Docker image
- Build Context: The directory containing files sent to Docker daemon during build
- Image Layer: A read-only filesystem layer created by Dockerfile instructions
- Build Cache: Docker’s mechanism to reuse layers from previous builds
4. Detailed Explanation and Examples (25-30 minutes)
Essential Dockerfile Instructions
FROM - Setting the Base Image
Every Dockerfile must start with a FROM instruction that specifies the base image.
# Use official Node.js runtime as base image
FROM node:18-alpine
# You can also use specific versions for reproducibility
FROM ubuntu:20.04
# Multi-stage builds can have multiple FROM instructions
FROM node:18-alpine AS builder
WORKDIR - Setting the Working Directory
Sets the working directory for subsequent instructions.
FROM node:18-alpine
# Set working directory inside container
WORKDIR /app
COPY and ADD - Adding Files
COPY transfers files from build context to the image. ADD has additional features but COPY is preferred for simple file transfers.
# Copy package.json to working directory
COPY package.json .
# Copy entire current directory to container
COPY . .
# Copy with specific permissions
COPY --chown=node:node . .
RUN - Executing Commands
RUN executes commands during the build process and commits the results to the image.
# Install dependencies
RUN npm install
# Run multiple commands (combine to reduce layers)
RUN apt-get update && \
apt-get install -y curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
EXPOSE - Documenting Ports
EXPOSE documents which ports the container will listen on (doesn’t actually publish ports).
# Document that app listens on port 3000
EXPOSE 3000
CMD and ENTRYPOINT - Defining Default Commands
CMD provides default commands for container execution. ENTRYPOINT sets the main command that always runs.
# CMD - can be overridden
CMD ["npm", "start"]
# ENTRYPOINT - always runs
ENTRYPOINT ["node", "server.js"]
# Combination - ENTRYPOINT + CMD
ENTRYPOINT ["node"]
CMD ["server.js"]
Common Misconceptions
-
Misconception: “Dockerfile and docker-compose.yml are the same thing” Reality: Dockerfile builds images; docker-compose.yml orchestrates containers
-
Misconception: “More RUN instructions make builds faster” Reality: Fewer RUN instructions (combined commands) typically result in smaller, more efficient images
5. Live Coding Demonstration (20-25 minutes)
Example 1: Simple Node.js Application Dockerfile
# Use official Node.js runtime as base image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json (if available)
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application source code
COPY . .
# Expose port 3000
EXPOSE 3000
# Define the command to run the application
CMD ["npm", "start"]
Explanation:
- Line 2: We start with a lightweight Node.js base image
- Line 5: Set /app as our working directory inside container
- Line 8: Copy package files first (for better caching)
- Line 11: Install dependencies in a separate layer
- Line 14: Copy the rest of our application code
- Line 17: Document that our app uses port 3000
- Line 20: Define the default command to start our application
Example 2: Python Flask Application
# Use official Python runtime
FROM python:3.9-slim
# Set working directory
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create non-root user for security
RUN adduser --disabled-password --gecos '' appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "app.py"]
Key Points:
- Uses Python slim image for smaller size
- Installs requirements in separate layer for caching
- Creates non-root user for security
- Follows principle of least privilege
Example 3: Multi-Stage Build for Production
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --chown=nextjs:nodejs . .
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]
Debugging Demonstration
Show common errors and how to fix them:
# Common mistake - wrong base image
FROM node:latest # ❌ Avoid 'latest' tag
# Better approach
FROM node:18-alpine # ✅ Use specific versions
# Common mistake - inefficient layer ordering
COPY . . # ❌ Copying everything first
RUN npm install
# Better approach - copy dependencies first
COPY package*.json ./ # ✅ Copy package files first
RUN npm install
COPY . . # Then copy source code
6. Hands-On Practice (30-40 minutes)
Exercise 1: Basic Dockerfile (15 minutes)
Objective: Create a Dockerfile for a simple “Hello World” web server
Instructions:
- Create a new directory called
hello-docker - Create a simple HTML file (
index.html) - Write a Dockerfile to serve this HTML using nginx
- Build and run the image
Starter Code:
Create index.html:
<!DOCTYPE html>
<html>
<head>
<title>Hello Docker</title>
</head>
<body>
<h1>Hello from Docker!</h1>
<p>This is my first custom Docker image.</p>
</body>
</html>
Create Dockerfile:
# Your Dockerfile here - use nginx:alpine as base
# Copy index.html to /usr/share/nginx/html/
# Expose port 80
Solution:
# Use nginx alpine as base image
FROM nginx:alpine
# Copy HTML file to nginx default directory
COPY index.html /usr/share/nginx/html/
# Expose port 80
EXPOSE 80
# nginx starts automatically, no CMD needed
Build and run:
# Build the image
docker build -t hello-docker .
# Run the container
docker run -p 8080:80 hello-docker
# Visit http://localhost:8080 in your browser
Exercise 2: Node.js Application Dockerfile (20 minutes)
Objective: Create a Dockerfile for a simple Express.js application
Requirements:
- Use Node.js 18 alpine as base image
- Install dependencies efficiently (leverage Docker layer caching)
- Run as non-root user for security
- Expose appropriate port
Create package.json:
{
"name": "docker-express-app",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.0"
}
}
Create server.js:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'Hello from Dockerized Express!',
timestamp: new Date().toISOString()
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Hints:
- Copy package.json first, then run npm install
- Copy source code after installing dependencies
- Use npm ci for production builds
Exercise 3: Multi-Stage Build Challenge (15 minutes)
Objective: Create a multi-stage Dockerfile that builds and runs a Go application
Challenge: Create a Dockerfile that:
- Uses golang:alpine to build a Go application
- Uses a minimal base image (alpine) for the final runtime
- Results in a very small final image
Create main.go:
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from Go in Docker!")
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
Extension Ideas:
- Add health check endpoints
- Include environment-specific configurations
- Implement graceful shutdown
7. Common Pitfalls and Troubleshooting (10 minutes)
Frequent Mistakes
- Problem: Build context is too large, causing slow builds Cause: Including unnecessary files in build context Solution: Use .dockerignore file to exclude unwanted files
node_modules
npm-debug.log
.git
.DS_Store
*.md
- Problem: Docker builds are slow and don’t leverage cache Cause: Poor instruction ordering in Dockerfile Solution: Order instructions from least to most frequently changing
# ❌ Poor caching - copies everything first
COPY . .
RUN npm install
# ✅ Better caching - install dependencies first
COPY package*.json ./
RUN npm install
COPY . .
- Problem: Images are unnecessarily large Cause: Using wrong base images or not cleaning up Solution: Use alpine variants and clean up in same RUN instruction
# ❌ Large image
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y curl
# ✅ Smaller image
FROM alpine:latest
RUN apk add --no-cache curl
Debugging Tips
- Use
docker build --progress=plainto see detailed build output - Use
docker history <image>to inspect image layers - Build incrementally and test each major step
- Use
docker run -it <image> /bin/shto explore built images
8. Best Practices and Professional Tips (10 minutes)
Industry Standards
- Use official base images when possible for security and reliability
- Pin specific versions instead of using ’latest’ tag
- Use multi-stage builds to reduce final image size
- Run as non-root user for security
- Use .dockerignore to exclude unnecessary files
Dockerfile Optimization Tips
# ✅ Good practices example
FROM node:18-alpine AS builder
# Create app directory and user
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Install dependencies first (better caching)
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy source code
COPY --chown=nextjs:nodejs . .
# Switch to non-root user
USER nextjs
# Document exposed port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Default command
CMD ["npm", "start"]
Next Steps in Your Learning Journey
- Learn about multi-stage builds in depth (Day 9)
- Explore Docker layer caching strategies
- Study security scanning tools for images
- Practice with different types of applications (Python, Java, Go)
9. Summary and Review (5 minutes)
Key Takeaways
- Dockerfiles automate image creation - They provide reproducible, version-controlled environment setup
- Layer order matters - Proper instruction ordering leverages Docker’s caching for faster builds
- Security is important - Always run as non-root user and use specific image versions
Quick Review Questions
- What’s the difference between COPY and ADD instructions?
- Why should you copy package.json before copying your entire source code?
- What’s the purpose of the EXPOSE instruction?
Answers:
- COPY is simpler and preferred for basic file copying; ADD has additional features like URL downloading and archive extraction
- To leverage Docker’s layer caching - dependencies don’t change as often as source code
- EXPOSE documents which ports the container listens on (it’s documentation, not actual port publishing)
For Instructors
Teaching Tips
- Start with very simple Dockerfiles and gradually add complexity
- Emphasize the caching concept early - it’s crucial for efficient Docker usage
- Use real-world examples that students can relate to their own projects
- Demonstrate both working and broken Dockerfiles to show common mistakes
Assessment Ideas
- Have students create Dockerfiles for their own projects
- Code review exercise: analyze and improve existing Dockerfiles
- Troubleshooting exercise: fix intentionally broken Dockerfiles
Adaptation Notes
- For beginners: Focus more on basic instructions and simple examples
- For advanced students: Introduce multi-stage builds and optimization techniques earlier
- Online delivery: Use screen sharing for live coding, provide starter repositories
- Time management: Core concepts can be covered in 30 minutes, exercises can be extended based on available time