ServerlessBase Blog
  • Writing Your First Dockerfile: A Step-by-Step Guide

    Learn how to create a Dockerfile for your application with this practical, step-by-step guide to containerizing your code.

    Writing Your First Dockerfile: A Step-by-Step Guide

    You've heard about containers. You know they're supposed to make deployment easier. But when you sit down to write your first Dockerfile, you stare at a blank file and wonder where to start. This guide walks you through creating a production-ready Dockerfile from scratch, with concrete examples you can adapt to your own projects.

    A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image. Think of it as a recipe for building your application container. Every line in the file is an instruction that tells Docker what to do, in what order, and with what parameters.

    Understanding Dockerfile Instructions

    Before writing your first Dockerfile, you need to understand the core instructions. These commands form the building blocks of any container image.

    FROM - The Foundation

    The FROM instruction specifies the base image for your container. This is the starting point. All other instructions build upon this base.

    FROM node:18-alpine

    This line pulls the official Node.js 18 image with Alpine Linux as the base. Alpine is lightweight, making it ideal for production containers. You can use any official image from Docker Hub or your own private registry.

    WORKDIR - Setting the Stage

    WORKDIR sets the working directory for any subsequent RUN, COPY, ADD, CMD, ENTRYPOINT, VOLUME, and EXPOSE instructions in the Dockerfile.

    WORKDIR /app

    This creates the directory if it doesn't exist and changes the working directory. Always use WORKDIR instead of RUN mkdir followed by WORKDIR — it's cleaner and more reliable.

    COPY and ADD - Bringing Files In

    COPY copies new files or directories from your host system into the container filesystem. ADD does the same but has some extra features like automatic extraction of archives.

    COPY package*.json ./
    COPY . .

    The first line copies your package.json and package-lock.json files to the container's current directory. The second line copies all files from your local directory to the container's current directory. Use COPY for most cases — it's more predictable and transparent.

    RUN - Executing Commands

    RUN executes any command in the container and commits the changes. This is where you install dependencies, build your application, or perform any setup tasks.

    RUN npm ci --only=production

    This command installs your production dependencies. The --only=production flag ensures only production dependencies are installed, keeping the image smaller.

    CMD and ENTRYPOINT - Defining the Container's Main Process

    CMD and ENTRYPOINT specify the default command to run when the container starts. They're similar but have key differences in how they interact with other instructions.

    CMD ["node", "server.js"]

    This runs the Node.js server when the container starts. The JSON array format is preferred because it's more reliable and allows for proper argument passing.

    Building a Complete Dockerfile

    Let's build a complete Dockerfile for a Node.js application. This example demonstrates best practices and common patterns.

    # 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 files to the container
    COPY package*.json ./
     
    # Install dependencies
    RUN npm ci --only=production
     
    # Copy the rest of the application code
    COPY . .
     
    # Expose the port the app runs on
    EXPOSE 3000
     
    # Define the command to run the application
    CMD ["node", "server.js"]

    This Dockerfile follows a logical flow: start with a base image, set up the working directory, copy and install dependencies, copy your application code, expose the port, and define the startup command.

    Multi-Stage Builds for Smaller Images

    One of the biggest advantages of Docker is the ability to use multi-stage builds. This technique allows you to use different stages in your build process, discarding intermediate artifacts to create a smaller final image.

    # Stage 1: Build the application
    FROM node:18-alpine AS builder
     
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci
     
    COPY . .
    RUN npm run build
     
    # Stage 2: Production image
    FROM node:18-alpine
     
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --only=production
     
    COPY --from=builder /app/dist ./dist
    COPY --from=builder /app/node_modules ./node_modules
    COPY --from=builder /app/package.json ./
     
    EXPOSE 3000
    CMD ["node", "dist/server.js"]

    This Dockerfile uses two stages. The first stage builds your application, and the second stage creates the production image. The COPY --from=builder instruction copies only the necessary files from the build stage to the production stage, keeping the final image small.

    Best Practices for Production Dockerfiles

    Writing a Dockerfile is easy. Writing a production-ready Dockerfile requires attention to detail. Here are the key practices to follow.

    Use Specific Image Tags

    Always use specific image tags instead of latest. The latest tag can change, breaking your builds unexpectedly.

    # Good - specific version
    FROM node:18.17.0-alpine
     
    # Bad - can change
    FROM node:18-alpine

    Minimize the Number of Layers

    Each RUN, COPY, and ADD instruction creates a new layer. Combine commands to reduce the number of layers.

    # Good - single layer
    RUN npm ci --only=production && npm cache clean --force
     
    # Bad - multiple layers
    RUN npm ci --only=production
    RUN npm cache clean --force

    Use .dockerignore

    Create a .dockerignore file to exclude unnecessary files from the build context. This speeds up builds and reduces image size.

    node_modules
    npm-debug.log
    .git
    .gitignore
    .env

    Leverage Build Cache

    Docker caches layers by default. Structure your Dockerfile to maximize cache hits. Put instructions that change frequently at the end.

    # Good - changes to this file invalidate cache
    COPY package*.json ./
    RUN npm ci
     
    # Bad - changes to this file don't invalidate cache
    COPY . .
    RUN npm run build

    Use Non-Root Users

    Run containers as non-root users to improve security. This prevents privilege escalation attacks.

    # Create a non-root user
    RUN addgroup -g 1001 -S nodejs && \
        adduser -S nodejs -u 1001
     
    # Switch to the non-root user
    USER nodejs

    Health Checks

    Add health checks to monitor your container's health. This helps Docker and orchestration systems understand when to restart unhealthy containers.

    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
      CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1

    Common Dockerfile Patterns

    Different applications require different Dockerfile patterns. Here are some common examples.

    Node.js Applications

    FROM node:18-alpine
     
    WORKDIR /app
     
    COPY package*.json ./
    RUN npm ci --only=production
     
    COPY . .
     
    EXPOSE 3000
    CMD ["node", "server.js"]

    Python Applications

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

    Go Applications

    FROM golang:1.21-alpine AS builder
     
    WORKDIR /app
    COPY . .
     
    RUN go build -o main .
     
    FROM alpine:latest
     
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY --from=builder /app/main .
    EXPOSE 8080
    CMD ["./main"]

    Static Site (Nginx)

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

    Debugging Dockerfiles

    Writing a Dockerfile is only half the battle. Debugging issues is where most developers struggle. Here are some common problems and solutions.

    Issue: Container exits immediately

    Check your CMD or ENTRYPOINT instruction. Ensure the command is correct and the application starts successfully.

    docker run --rm my-app

    Issue: Permission denied errors

    Run as a non-root user or fix file permissions.

    RUN chown -R nodejs:nodejs /app
    USER nodejs

    Issue: Large image size

    Use multi-stage builds, Alpine-based images, and .dockerignore to reduce image size.

    Issue: Build is slow

    Optimize layer caching, use .dockerignore, and consider using BuildKit.

    DOCKER_BUILDKIT=1 docker build -t my-app .

    Testing Your Dockerfile

    Before deploying your Dockerfile to production, test it thoroughly. Here's how.

    Build the Image

    docker build -t my-app .

    Run the Container

    docker run -p 3000:3000 my-app

    Verify the Container

    docker ps
    docker logs my-app

    Check the Image Size

    docker images my-app

    Test Health Checks

    docker inspect --format='{{.State.Health.Status}}' my-app

    Conclusion

    Writing a Dockerfile is a fundamental skill for modern development. You've learned the core instructions, best practices, and common patterns. Remember these key points:

    • Start with a specific base image tag
    • Use WORKDIR to set the working directory
    • Copy dependencies separately to leverage build cache
    • Use multi-stage builds for smaller production images
    • Run as a non-root user for security
    • Add health checks for monitoring
    • Use .dockerignore to exclude unnecessary files

    The best way to learn is by doing. Take an existing project, write a Dockerfile for it, and iterate based on your needs. Platforms like ServerlessBase can help you deploy and manage your containers, but understanding how to write effective Dockerfiles gives you full control over your application's environment.

    Ready to containerize your first application? Start with a simple Node.js or Python project, follow the patterns in this guide, and build from there.

    Leave comment