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:

  1. Understanding Dockerfiles - What they are and why they matter
  2. Essential Dockerfile Instructions - FROM, RUN, COPY, WORKDIR, and more
  3. Building Images - Using docker build command effectively
  4. Hands-on practice - Creating your first custom Docker image
  5. 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

  1. Misconception: “Dockerfile and docker-compose.yml are the same thing” Reality: Dockerfile builds images; docker-compose.yml orchestrates containers

  2. 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:

  1. Create a new directory called hello-docker
  2. Create a simple HTML file (index.html)
  3. Write a Dockerfile to serve this HTML using nginx
  4. 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:

  1. Uses golang:alpine to build a Go application
  2. Uses a minimal base image (alpine) for the final runtime
  3. 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

  1. 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
  1. 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 . .
  1. 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=plain to 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/sh to 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

  1. Dockerfiles automate image creation - They provide reproducible, version-controlled environment setup
  2. Layer order matters - Proper instruction ordering leverages Docker’s caching for faster builds
  3. Security is important - Always run as non-root user and use specific image versions

Quick Review Questions

  1. What’s the difference between COPY and ADD instructions?
  2. Why should you copy package.json before copying your entire source code?
  3. What’s the purpose of the EXPOSE instruction?

Answers:

  1. COPY is simpler and preferred for basic file copying; ADD has additional features like URL downloading and archive extraction
  2. To leverage Docker’s layer caching - dependencies don’t change as often as source code
  3. 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