DEV Community

Cover image for Building a Docker Image
Sanskriti Harmukh for Vultr

Posted on with Aashish Chaurasiya Originally published at docs.vultr.com

Building a Docker Image

Docker is an open-source containerization platform that enables developers to package applications and all their dependencies into lightweight, portable containers that run consistently across different environments. Docker images are the blueprint for containers — they package your application code, dependencies, environment variables, and runtime configurations into a portable, reproducible format. This guide explains how to build Docker images efficiently and create an optimized Dockerfile. By the end, you'll have a tested, size-optimized Docker image built with docker buildx and ready to push to a registry.


The Short Answer

If you're already familiar with Docker and just need a quick reference, use the following commands:

# Build a Docker image from a Dockerfile in the current directory
$ docker buildx build -t <image-name>:<tag> .

# Run a container from the image and map a port
$ docker container run -d -p <host-port>:<container-port> <image-name>:<tag>

# Tag the image for a container registry
$ docker image tag <image-name>:<tag> <registry>/<image-name>:<tag>

# Push your image to a registry (e.g., Docker Hub or another container registry)
$ docker image push <registry>/<image-name>:<tag>
Enter fullscreen mode Exit fullscreen mode

Continue reading for detailed explanations and examples of each command.

1. Create a Dockerfile

To build a Docker image, start by creating a Dockerfile. This file defines the environment, dependencies, and commands needed to build your application into a container image. Follow the steps below to create a Dockerfile for a Node.js web application.

1. Create a file named Dockerfile in your project's root directory:

$ vim Dockerfile
Enter fullscreen mode Exit fullscreen mode

2. Add the following content to the file, then save and exit:

# Use Node.js as the base image
FROM node:22-alpine

# Set the working directory
WORKDIR /app

# Copy dependency files
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy application source code
COPY . .

# Expose application port
EXPOSE 3000

# Define the startup command
CMD ["node", "app.js"]
Enter fullscreen mode Exit fullscreen mode

Breakdown of the Dockerfile

  • FROM node:22-alpine: Defines node:22-alpine as the base image.
  • WORKDIR /app: Sets /app as the working directory inside the container.
  • COPY package*.json ./: Copies dependency definition files to the working directory.
  • RUN npm install: Installs project dependencies using npm.
  • COPY . .: Copies the entire project directory into the container.
  • EXPOSE 3000: Declares port 3000 as the container's exposed port.
  • CMD ["node", "app.js"]: Defines the startup command to run the application.

The above Dockerfile follows several best practices:

  • Uses a specific version of the base image.
  • Copies dependency files first to take advantage of Docker's build cache.
  • Uses a minimal Alpine-based image to reduce image size.
  • Defines the default command to run the application.

2. Build the Docker Image

With your Dockerfile ready, use the docker buildx build command to create your image. Docker Buildx enables advanced build features such as multi-platform support, build caching, and pushing to registries.

1. Basic build with a tag:

$ docker buildx build -t my-app:latest .
Enter fullscreen mode Exit fullscreen mode

2. Build with multiple tags and build arguments:

$ docker buildx build -t my-app:1.0 -t my-app:latest --build-arg ENV=production .
Enter fullscreen mode Exit fullscreen mode

3. For a multi-platform build and push to a registry, first create and bootstrap a builder (one-time setup):

$ docker buildx create \
  --name container-builder \
  --driver docker-container \
  --bootstrap --use
Enter fullscreen mode Exit fullscreen mode

4. After the container-builder is running, run the multi-platform build:

$ docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t my-app:latest \
  --push .
Enter fullscreen mode Exit fullscreen mode

Command Breakdown

Explanation of key options used with docker buildx build:

  • docker buildx build: Uses Docker BuildKit's extended build command for advanced features like multi-platform builds and better caching.
  • -t my-app:latest: Tags the image with a name (my-app) and version (latest).
  • -t my-app:1.0: Adds another tag (1.0) to the image.
  • --build-arg ENV=production: Passes build-time variable ENV with the value production into the Dockerfile.
  • --platform linux/amd64,linux/arm64: Builds images for multiple CPU architectures (for example, x86_64 and ARM64).
  • --push: Pushes the built image and manifest list to the specified container registry.
  • --no-cache: Forces the build to skip caching and build each layer from scratch.
  • --pull: Always pulls the latest version of the base image instead of using the cached one.
  • -f Dockerfile.prod: Specifies a custom Dockerfile (Dockerfile.prod) instead of the default Dockerfile.
  • .: Sets the build context to the present directory.

Example Scenario

Follow this example to build and tag a Docker image for a Node.js application.

1. Clone an example Docker Node.js application repository:

$ git clone https://github.com/docker/docker-nodejs-sample
Enter fullscreen mode Exit fullscreen mode

2. Navigate to the cloned project directory:

$ cd docker-nodejs-sample
Enter fullscreen mode Exit fullscreen mode

3. Create a Dockerfile inside the project root directory:

$ vim Dockerfile
Enter fullscreen mode Exit fullscreen mode

4. Add the following content to the file:

FROM node:22-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
Enter fullscreen mode Exit fullscreen mode

5. Build the Docker image:

$ docker buildx build -t my-app:latest .
Enter fullscreen mode Exit fullscreen mode

6. View the newly built Docker image:

$ docker image ls
Enter fullscreen mode Exit fullscreen mode

The above command lists all the Docker images available on your system.

How the Docker Build Works:

  • Docker processes the Dockerfile in a layered fashion.
  • Starts with the base image (node:22-alpine in this case).
  • Creates a temporary container for each instruction (for example, COPY, RUN, CMD).
  • Executes the instruction inside the container and commits the result as a new image layer.
  • Removes the temporary container.
  • Repeats the process for each instruction in the Dockerfile.
  • Tags the final image with the names and versions you specified.

3. Test the Docker Image

After building your Docker image, test it by running a container and verifying that your application behaves as intended.

1. Run a container from your image:

$ docker container run -d -p 3000:3000 --name check-list-app my-app:latest
Enter fullscreen mode Exit fullscreen mode

In the above command:

  • docker container run: Starts a container from an image.
  • -d: Runs the container in detached mode (in the background).
  • -p 3000:3000: Maps port 3000 of the host to port 3000 of the container.
  • --name check-list-app: Assigns a custom name to the container.
  • my-app:latest: Specifies the image and tag to use.

2. Verify the container is running:

$ docker container ls
Enter fullscreen mode Exit fullscreen mode

This command lists all running containers. After confirming your container is listed and running, you can access your application to ensure it behaves as expected.

3. Check the application logs for debugging purposes:

$ docker container logs check-list-app
Enter fullscreen mode Exit fullscreen mode

This command displays the container's logs, allowing you to verify application startup messages or troubleshoot any issues.

4. Stop and remove the container:

$ docker container stop check-list-app
$ docker container rm check-list-app
Enter fullscreen mode Exit fullscreen mode

The above commands stop the running container and then remove it entirely from your system.

4. Optimize Docker Image Size

Optimizing Docker image size leads to faster deployments, lower storage costs, and improved security. Follow these best practices to create smaller, more efficient Docker images.

Use Multi-Stage Builds

Multi-stage builds let you create lean final images by separating build and runtime environments. Each FROM starts a new build stage, allowing you to copy only necessary artifacts into the final image.

# Build stage
FROM golang:1.17 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
CMD ["./app"]
Enter fullscreen mode Exit fullscreen mode

The final image includes only the compiled binary and runtime dependencies, excluding source code and build tools.

Choose Smaller Base Images

Using smaller base images helps reduce Docker image size, improve security, and speed up build and deployment times. Choose a base image that includes only the essential components needed to run your application.

# Builder stage: install dependencies
FROM python:3.9-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt --target /app/packages

# Final image: minimal runtime using distroless
FROM gcr.io/distroless/python3
COPY --from=builder /app/packages /packages
ENV PYTHONPATH=/packages
COPY app.py /
CMD ["app.py"]
Enter fullscreen mode Exit fullscreen mode

Best practices:

  • Use alpine or slim based images as a minimal base for building dependencies.
  • Use distroless for a secure, production-ready runtime image.
  • Consider scratch images for statically compiled binaries in languages like Go or Rust.
  • Apply multi-stage builds to separate build and runtime environments.
  • Exclude package managers and shells from the final image.
  • Set environment variables like PYTHONPATH to locate dependencies at runtime.

Reduce Layer Count and Image Size

Each instruction in a Dockerfile creates a new layer. Fewer, optimized layers reduce image size and speed up builds.

Example of an inefficient Dockerfile (creates many layers):

# Bad practice - many layers
FROM ubuntu:20.04
RUN apt-get update
RUN apt-get install -y python3
RUN apt-get install -y python3-pip
RUN pip install flask
Enter fullscreen mode Exit fullscreen mode

In the above Dockerfile:

  • Each RUN instruction creates a new layer.
  • No cleanup of APT cache or temporary files.
  • Slower builds and larger images.

Example of an optimized Dockerfile (fewer layers and smaller image):

# Better practice - fewer layers
FROM ubuntu:20.04
RUN apt-get update && \
 apt-get install -y python3 python3-pip && \
 pip install flask && \
 apt-get clean && \
 rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

In the above Dockerfile:

  • Related commands are combined using && to reduce layers.
  • Package caches and temporary files are cleaned up during installation.

Use a .dockerignore File

Exclude unnecessary files and directories from the Docker build context to reduce image size and speed up build times.

node_modules
npm-debug.log
.git
.gitignore
Dockerfile
README.md
.env
*.pyc
__pycache__
Enter fullscreen mode Exit fullscreen mode

In the above .dockerignore file:

  • Prevents unneeded files from being copied into the image.
  • Keeps the image clean and lightweight.
  • Speeds up build time by minimizing the context sent to the Docker daemon.

Leverage Docker Build Cache

Docker caches image layers to avoid rebuilding unchanged steps. Structure your Dockerfile to take full advantage of this.

Best practices:

  • Place less frequently changing instructions at the top of your Dockerfile.
  • Copy dependency files first, install packages, then copy the application source last.

Example of an efficient caching structure:

FROM node:16-alpine

WORKDIR /app

# Copy dependency files first
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the source code last
COPY . .

CMD ["node", "app.js"]
Enter fullscreen mode Exit fullscreen mode

In the above Dockerfile:

  • Dependency layers are reused unless package*.json changes.
  • Source code changes won't invalidate the cached dependency layers.
  • Results in faster builds and smaller diffs in CI/CD pipelines.

Next Steps

  • Wire docker buildx build --push into a CI/CD pipeline so images rebuild and publish automatically on every commit.
  • Scan your images for vulnerabilities with a tool like Trivy or Docker Scout before pushing to production.
  • Add a multi-stage build to any existing Dockerfile that still ships build tools in the final image.
  • Set up docker buildx multi-platform builds if you need to support both x86_64 and ARM64 deployments.

For the full guide with additional tips, visit the original article on Vultr Docs.

Top comments (0)