Building Container Images

Building Custom Docker Images

Pulling images from Docker Hub is convenient. But sooner or later, you’ll want to build your own.

Maybe you need to bundle your own application into a container. Maybe you want to customise an existing image. Maybe you just want to understand what’s actually inside the images you’re running.

Building images isn’t as complicated as it looks. You just need a Dockerfile to describe how the image should be built, and a single build command. Let’s walk through how it works.

How Images Are Built: Layers

Every Docker image is made up of layers. Each instruction in a Dockerfile creates a new layer. Those layers are stacked together to form the final image.

When you use an existing image as a base, such as Ubuntu or Alpine, you’re inheriting all of its layers as your starting point. Your instructions then add new layers on top.

This layering system has a practical advantage: layers are shared and reused. If two images both use the Alpine base layer, Docker only stores those layers once. Pulling a new image that shares layers you already have is faster, because only the new layers need to be downloaded.

Layers are immutable when running as a container. Any changes the container makes go into a temporary writable layer on top, leaving the image unchanged.

The Dockerfile

A Dockerfile is a text file, usually named Dockerfile, that contains instructions for building an image. Each instruction becomes a layer.

Here’s an example of a simple NGINX web server:

FROM alpine:latest
RUN apk add --no-cache nginx
RUN adduser --system www-data
RUN mkdir -p /var/www/html
RUN chown -R www-data:www-data /var/www/html
COPY . /var/www/html
COPY default.conf /etc/nginx/http.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Let’s go through each instruction.

FROM

FROM alpine:latest

Every Dockerfile starts with FROM. This sets the base image, which is the foundation everything else is built on.

Alpine Linux is a popular choice because it’s minimal and security-focused. The current latest version of the image on Docker Hub is less than 4MB.

RUN

RUN apk add --no-cache nginx

RUN executes a command during the build. Here we’re using Alpine’s package manager (apk) to install NGINX.

Multiple RUN commands create multiple layers.

COPY

COPY . /var/www/html

COPY copies files from the build context (usually your local directory) into the image. The dot on the left means “everything in the current directory.” The path on the right is where those files land inside the image.

EXPOSE

EXPOSE 80

EXPOSE is informational. It tells users of the image that the container will listen on port 80.

It doesn’t actually open or map any ports. That still happens at runtime with -p. Think of it as documentation embedded in the image.

CMD

CMD ["nginx", "-g", "daemon off;"]

CMD sets the default command that runs when the container starts. This is called the entrypoint. In this case, when the container starts, NGINX will run.

The format shown here (a JSON array) is called exec form. This is the preferred way to write it, as it avoids some edge cases with how shells handle signals.

ENTRYPOINT vs CMD

Both CMD and ENTRYPOINT define what runs when a container starts. However, they are intended to be used for slightly different purposes.

CMD provides a default command that’s easy to override. If you add arguments after the image name in docker run, they replace CMD entirely.

ENTRYPOINT is used for a fixed entrypoint. Arguments passed at runtime are appended to it rather than replacing it. Use ENTRYPOINT when the container has one specific job and you want that job to always run. It can be overridden at runtime, but requires special arguments to do so.

You can use both together. ENTRYPOINT defines the executable; CMD provides the default arguments. At runtime, you can override the arguments (replacing CMD) while ENTRYPOINT stays fixed.

Building the Image

Once your Dockerfile is ready, building is a single command:

docker build . -t mywebserver:latest

The `.` tells Docker to use the current directory as the build context. This is the source for any files being copied. The -t flag sets the image name and tag.

Docker processes each instruction in the Dockerfile from top to bottom, creating a layer for each one.

Layer Caching

This is one of the most important things to understand about building images.

Docker caches each layer after it’s built. If you rebuild an image and a layer hasn’t changed, Docker reuses the cached version instead of rebuilding it. This makes subsequent builds much faster.

The catch: if a layer changes, all layers below it must also be rebuilt. Docker can’t assume that subsequent steps are unaffected by the change.

This means the order of your instructions matters. Put instructions that change frequently (like `COPY` for your source files) near the bottom of the Dockerfile. Put stable instructions (like installing base packages) near the top. That way, a code change only invalidates the last few layers, not the entire build.

Order Dockerfile instructions from least-likely-to-change to most-likely-to-change.

Multistage Builds

Building an image sometimes requires tools you don’t actually need at runtime. A compiler is the classic example. You need it to build your code, but once the binary exists, the compiler is just wasted space, as well as an increased attack surface.

Multistage builds let you split the build into stages. Each stage starts fresh with FROM. You can then copy specific files from one stage into the next.

# Stage 1: Build
FROM alpine:latest AS build
RUN apk add --no-cache build-base
WORKDIR /src
COPY app.c .
RUN gcc -O2 -o myapp app.c

# Stage 2: Runtime
FROM alpine:latest
WORKDIR /app
COPY --from=build /src/myapp .
CMD ["./myapp"]

The first stage installs the compiler and compiles the code. The second stage starts fresh from Alpine, copies only the compiled binary, and discards everything else. The compiler, build libraries, and temporary files are left behind.

The result can be dramatically smaller. An image that was 300MB with a single stage might drop to under 10MB with multistage.

Security When Building Images

A few things can go wrong when building images, and they’re worth knowing about before they bite you.

Never Put Secrets in a Dockerfile

Every instruction in your Dockerfile ends up in the final image. That image can then be inspected. Anyone with access to your image can run docker history and read the layers.

That means passwords, API keys, and tokens should never appear in a Dockerfile. Not even temporarily.

If a container needs sensitive information, pass it at runtime using environment variables. Not at build time.

Watch What You COPY

COPY . /var/www/html copies everything in the current directory into the image. That might include your Dockerfile, your .git folder, a .env file with credentials, or other things you didn’t intend to share.

Always use a .dockerignore file. It works just like .gitignore. It lists files and patterns to exclude from the build:

.git/
.env
*.key
*.pem
node_modules/

This prevents accidents. Make it a habit.

Don’t Run as Root

By default, containers run as root. That’s more privilege than most applications need.

Use the USER instruction to switch to a non-root user before your entrypoint:

RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser

Any instructions after USER run as that user. If an attacker compromises the container, they’re working with a restricted account rather than root.

Where to Go Next

The full course builds several real images from scratch, covering each of these concepts with working examples:

Watch the free YouTube course → COMING SOON

Enrol in the full Udemy course → COMING SOON