Introduction

In the previous chapters, we learned how to run containers from existing Docker images. While readily available images from Docker Hub or private registries are incredibly useful, real-world applications often require specific configurations, custom code, or unique dependencies that aren’t met by generic images. This is where building your own custom Docker images becomes essential.

Custom Docker images allow you to package your application and its entire environment into a portable, reproducible unit. The blueprint for creating these images is a Dockerfile. A Dockerfile is a simple text file that contains a series of instructions that Docker Engine reads to build an image automatically. By mastering Dockerfiles, you gain precise control over your application’s deployment environment, ensuring consistency from development to production.

This chapter will dive deep into Dockerfiles, exploring their syntax, common instructions, best practices, and how to build efficient and secure custom images.

Main Explanation

What is a Dockerfile?

A Dockerfile is a script that Docker Engine uses to automate the process of creating a Docker image. It’s a plain text file (typically named Dockerfile without any file extension) located in the root directory of your project. Each line in a Dockerfile represents an instruction, executed in sequence, to construct the final image.

Key Dockerfile Instructions

Understanding the core instructions is crucial for writing effective Dockerfiles.

FROM

The FROM instruction initializes a new build stage and sets the base image for subsequent instructions. Every Dockerfile must start with FROM.

  • Syntax: FROM <image>[:<tag>] [AS <name>]
  • Example: FROM ubuntu:22.04 or FROM node:18-alpine AS builder

RUN

The RUN instruction executes any commands in a new layer on top of the current image and commits the results. This is used for installing packages, creating directories, or performing any setup tasks required for your application.

  • Syntax:
    • RUN <command> (shell form, runs in a shell like /bin/sh -c)
    • RUN ["executable", "param1", "param2"] (exec form, preferred for clarity and avoiding shell string processing)
  • Example: RUN apt-get update && apt-get install -y nginx

COPY

The COPY instruction copies new files or directories from <src> (the build context) and adds them to the filesystem of the container at the path <dest>.

  • Syntax: COPY <src>... <dest>
  • Example: COPY . /app (copies everything from the current directory to /app in the image)

ADD

The ADD instruction is similar to COPY but has additional features: it can handle URL sources and automatically extract compressed archives (tar, gzip, bzip2, etc.) if the source is a local tar file. For most use cases, COPY is preferred due to its predictability.

  • Syntax: ADD <src>... <dest>
  • Example: ADD http://example.com/latest.tar.gz /app/

WORKDIR

The WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY, and ADD instructions that follow it in the Dockerfile. If the WORKDIR does not exist, it will be created.

  • Syntax: WORKDIR /path/to/workdir
  • Example: WORKDIR /app

EXPOSE

The EXPOSE instruction informs Docker that the container listens on the specified network ports at runtime. It’s purely documentation and doesn’t actually publish the port. To publish a port, you use the -p flag with docker run.

  • Syntax: EXPOSE <port> [<port>/<protocol>...]
  • Example: EXPOSE 80 or EXPOSE 8080/tcp

ENV

The ENV instruction sets environment variables. These variables are available to subsequent instructions in the build stage and to the container at runtime.

  • Syntax: ENV <key>=<value> ...
  • Example: ENV NODE_ENV=production

CMD

The CMD instruction provides defaults for an executing container. These defaults can include an executable, or they can omit the executable, in which case you must specify an ENTRYPOINT instruction. There can only be one CMD instruction in a Dockerfile. If you list more than one CMD, only the last CMD will take effect.

  • Syntax:
    • CMD ["executable","param1","param2"] (exec form, preferred)
    • CMD ["param1","param2"] (as default parameters to ENTRYPOINT)
    • CMD command param1 param2 (shell form)
  • Example: CMD ["node", "app.js"]

ENTRYPOINT

The ENTRYPOINT instruction configures a container that will run as an executable. When combined with CMD, ENTRYPOINT sets the command that will always be executed, and CMD provides default arguments to that command.

  • Syntax: ENTRYPOINT ["executable", "param1", "param2"] (exec form, preferred)
  • Example: ENTRYPOINT ["nginx", "-g", "daemon off;"]

ARG

The ARG instruction defines a variable that users can pass at build-time to the builder with the docker build --build-arg <varname>=<value> command.

  • Syntax: ARG <name>[=<default value>]
  • Example: ARG BUILD_VERSION=1.0

LABEL

The LABEL instruction adds metadata to an image. A LABEL is a key-value pair.

  • Syntax: LABEL <key>="<value>" [<key>="<value>" ...]
  • Example: LABEL maintainer="AI Expert <[email protected]>"

Dockerfile Best Practices

To build efficient, secure, and maintainable Docker images, consider these best practices:

  • Minimize Layers: Each RUN, COPY, ADD instruction creates a new layer. Combine multiple RUN commands using && and \ to reduce the number of layers and improve caching.
  • Use .dockerignore: Similar to .gitignore, a .dockerignore file specifies files and directories to exclude from the build context. This prevents unnecessary files from being sent to the Docker daemon and reduces image size.
  • Leverage Build Cache: Docker caches layers during the build process. If an instruction and its context haven’t changed, Docker reuses the cached layer. Place frequently changing instructions (like COPY . .) later in the Dockerfile.
  • Multi-Stage Builds: Use multiple FROM instructions to create separate build stages. This allows you to copy only the necessary artifacts from a “builder” stage to a final, smaller production image, discarding build tools and intermediate files.
  • Use Specific Tags: Always use specific image tags (e.g., node:18-alpine) instead of latest in your FROM instructions to ensure reproducible builds.
  • Run as Non-Root User: By default, Docker containers run as root. For security, create a dedicated non-root user and switch to it using USER instruction.
  • Clean Up: After installing packages with RUN, clean up any temporary files, caches, or lists to reduce image size (e.g., rm -rf /var/lib/apt/lists/*).

Building an Image

Once you have a Dockerfile, you use the docker build command to create an image.

  • Syntax: docker build [OPTIONS] PATH | URL | -
  • PATH: The path to the build context, which is the directory containing your Dockerfile and any files it needs to copy.
  • -t (tag): Names and optionally tags the image in the name:tag format.

Example Command: docker build -t my-custom-app:1.0 . Here, . specifies that the current directory is the build context and contains the Dockerfile.

Examples

Let’s look at a couple of common scenarios for building custom images.

Example 1: Simple Node.js Application

Suppose you have a simple Node.js application with app.js and package.json:

app.js:

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.send('Hello from Dockerized Node.js App!');
});

app.listen(port, () => {
  console.log(`App listening at http://localhost:${port}`);
});

package.json:

{
  "name": "my-node-app",
  "version": "1.0.0",
  "description": "A simple Node.js app",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

Dockerfile:

# Use an official Node.js runtime as a parent image
FROM node:18-alpine

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
# This step is done separately to leverage Docker's layer caching
COPY package*.json ./

# Install application dependencies
RUN npm install

# Copy the rest of the application code to the working directory
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Define the command to run your app
CMD ["npm", "start"]

.dockerignore:

node_modules
npm-debug.log
.git
.gitignore

To build the image:

docker build -t my-node-app:1.0 .

To run the container:

docker run -p 3000:3000 my-node-app:1.0

Example 2: Simple Python Flask Application (Multi-Stage Build)

Let’s create a Flask application that requires some build dependencies, but we want the final image to be lean.

app.py:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello from Dockerized Flask App!"

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

requirements.txt:

Flask==2.2.2

Dockerfile:

# Stage 1: Build stage
FROM python:3.9-slim-buster AS builder

# Set the working directory
WORKDIR /app

# Install build dependencies (if any, for now just pip install)
# In a real app, this might include compilers or specific libraries
RUN pip install --no-cache-dir --upgrade pip

# Copy only requirements.txt first to leverage cache
COPY requirements.txt .

# Install application dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application code
COPY . .

# Stage 2: Final image
FROM python:3.9-slim-buster

# Set the working directory
WORKDIR /app

# Copy only the installed dependencies and application code from the builder stage
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
COPY --from=builder /app /app

# Expose the port the app runs on
EXPOSE 5000

# Define the command to run your app
CMD ["python", "app.py"]

To build the image:

docker build -t my-flask-app:1.0 .

To run the container:

docker run -p 5000:5000 my-flask-app:1.0

Mini Challenge

Create a Dockerfile for a simple Nginx web server that serves a static HTML file.

  1. Create a directory named nginx-static.
  2. Inside nginx-static, create an index.html file with content like <h1>Hello from Nginx in Docker!</h1>.
  3. Create a Dockerfile that:
    • Uses the official Nginx base image (nginx:alpine).
    • Copies your index.html into the appropriate Nginx web root directory (hint: /usr/share/nginx/html/).
    • Exposes port 80.
  4. Build the image, tagging it as my-static-nginx:1.0.
  5. Run a container from your new image, mapping port 8080 on your host to port 80 in the container.
  6. Verify by navigating to http://localhost:8080 in your web browser.

Summary

Custom Docker images, built using Dockerfiles, are fundamental to packaging and deploying modern applications. Dockerfiles provide a clear, reproducible, and version-controlled way to define your application’s environment. We’ve covered the essential Dockerfile instructions like FROM, RUN, COPY, WORKDIR, EXPOSE, CMD, and ENTRYPOINT, along with critical best practices such as minimizing layers, using .dockerignore, leveraging build cache, and implementing multi-stage builds. By mastering these concepts, you are now equipped to create robust and efficient custom images for your own projects, paving the way for consistent and reliable deployments.