Generally speaking, people always want smaller images. It has some way, I have already written about it earlier in Size does really matter? Smaller Containers are better? article.

And now, time has come to go back to my fellow developer friends and explain some other techniques that can also be useful!

Power of COPY statement

Containerfile (or Dockerfile) has a statement, called COPY. Detail from man containerfile manual page:

The COPY instruction copies new files from ‘src’ and adds them to the filesystem of the container at path .

How COPY can cause increased build time?

So, concept of container image, it consist of layers. Layers can be cached. So when we rebuild the image, only those layers are going to be built again, which has changed and every layer after it. And the second half of the previous sentence is usually forgotten. Why does it matter?

Let’s take the next Containerfile as example. During the test, I made two build with measuring its time execution:

  • First build with no cache
  • Second build, I modified build.sh file and make a rebuild
FROM docker.io/library/debian:bookworm-slim@sha256:90522eeb7e5923ee2b871c639059537b30521272f10ca86fdbbbb2b75a8c40cd

RUN mkdir /app
WORKDIR /app

COPY ./build.sh .
RUN chmod +x ./build.sh

ARG GIT_DEB_VERSION=1:2.39.5-0+deb12u2 # suite=bookworm depName=git
ARG CURL_DEB_VERSION=7.88.1-10+deb12u12 # suite=bookworm depName=curl

RUN apt-get update && apt-get install -y git=${GIT_DEB_VERSION} curl=${CURL_DEB_VERSION} && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

LABEL summary="Generic package registry uploaded" \
    usage="Use it based on the README.md file in code repository" \
    name="generic-builder" \
    org.opencontainers.image.authors="Attila Molnar <onlyati@pm.me>" \
    org.opencontainers.image.description="This image is made to create and uplaod generic packages into Gitea generic regsitry" \
    org.opencontainers.image.licenses="MIT" \
    org.opencontainers.image.url="https://git.thinkaboutit.tech/pandora/generic-builder" \
    org.opencontainers.image.vendor="thinkaboutit.tech"

ENTRYPOINT [ "./build.sh" ]
CMD [ "build_and_push" ]

And the result is:

  • 1st build: 6.35s user 5.28s system 89% cpu 12.928 total
  • 2nd build: 6.10s user 5.18s system 105% cpu 10.721 total

In the command output, it was visible, that although the apt install part has not been changed, it has been run, due to COPY statement has been rebuilt before it! It technically mean, that every time we change the script, we also have to wait that apt perform the installation!

On a CI system, it might not matter, but during development, we can reduce some time.

How to use COPY on better way?

The better way is simple, we just have to put the apt install part before the COPY statement.

FROM docker.io/library/debian:bookworm-slim@sha256:90522eeb7e5923ee2b871c639059537b30521272f10ca86fdbbbb2b75a8c40cd

ARG GIT_DEB_VERSION=1:2.39.5-0+deb12u2 # suite=bookworm depName=git
ARG CURL_DEB_VERSION=7.88.1-10+deb12u12 # suite=bookworm depName=curl

RUN apt-get update && apt-get install -y git=${GIT_DEB_VERSION} curl=${CURL_DEB_VERSION} && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN mkdir /app
WORKDIR /app

COPY ./build.sh .
RUN chmod +x ./build.sh

LABEL summary="Generic package registry uploaded" \
    usage="Use it based on the README.md file in code repository" \
    name="generic-builder" \
    org.opencontainers.image.authors="Attila Molnar <onlyati@pm.me>" \
    org.opencontainers.image.description="This image is made to create and uplaod generic packages into Gitea generic regsitry" \
    org.opencontainers.image.licenses="MIT" \
    org.opencontainers.image.url="https://git.thinkaboutit.tech/pandora/generic-builder" \
    org.opencontainers.image.vendor="thinkaboutit.tech"

ENTRYPOINT [ "./build.sh" ]
CMD [ "build_and_push" ]

I made the same test than with the previous file. Here is the result:

  • 1st build: 7.34s user 6.11s system 99% cpu 13.573 total
  • 2nd build: 0.39s user 0.49s system 91% cpu 0.969 total

Measure the difference

The difference is presented on an bar diagram.

What can we say based on the result. We can see that if no cache exists the build time is more or less same. So when a fresh build is done, nothing really change. But we can see an approximately 10 times faster rebuild after change!

Winning this time, can be useful during development. Why? Because, based on my experience, if I can focus on something, then I am much more effective. And waiting for build time, can cause that I loose my focus, so my mind just plug out from my workflow. Some people are tolerant with this, and they are fine, I have no issue with that.

Reduce size and building time with Node.js

Multi-stage build

We like using multi-stage build. What is it? We create more images, like builder and final image. Within the builder image we can install any dev tool that we need to compile the application. Then the final image can be a thin image that only contains those utilities and libraries that are requires to run it.

Why do we do it? Why don’t we just install dev tools on OS? Because if we think about a CI pipeline, then these steps usually running inside a container. It is much more simpler, that developer collect what is needed to compile the application, than talking with pipeline team in case of every change. Simple: involve less people, faster you can take changes through.

Of course, no need to apply multi-stage build to produce an image that is able to run. But it comes with price:

  • Bigger image size
  • More packages in image, means more package to fix vulnerabilities

I have created a default VueJS application with Vite. I will test with this one. First, let’s compare the image size of multi-stage and simper image.

For simple image, we have this image. We just use the vanilla Node.js image.

FROM node:22-bookworm

WORKDIR /app

COPY . .

RUN npm ci && npm run build

RUN npm install -g serve

CMD ["serve", "-s", "dist"]

The second image is the following. Before run it, I have added a .dockerignore file to the project with the following content. This file prevents to copy the dist and node_modules directories, so our temporary images would be smaller and, due to less copy, quicker.

# Node stuff
node_modules
dist
.vite

# Build artifacts
*.log
*.tmp
*.cache

# System and editor files
.DS_Store
Thumbs.db
.idea
.vscode

# Optional: OS-specific
.env.local
.env.*
!.env.production # keep this if needed in production
FROM docker.io/library/node:22-bookworm AS builder

WORKDIR /app

COPY . .
RUN npm ci \
    && npm run build

FROM docker.io/library/nginx:stable-alpine AS production

COPY --from=builder /app/dist /usr/share/nginx/html

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

Size difference between image are really-really huge.

localhost/test_vue_single   latest         8c207acf503d  4 minutes ago   1.24 GB
localhost/test_vue_multi    latest         b69a68da0ee5  26 minutes ago  49.7 MB

Basically, the multi-stage build produce ~25 times smaller image than the simple one! This is a huge difference, and represent how bloated Node.js is.

Can we do something else?

Of course, we can always improve something. The current build is fine from the view of size. But as soon, we change something in the project, the COPY . . would cause a full rebuild (including installing the dependencies).

Sometimes we say that “keep layers in images minimal”, but it is not true for every situation. If we would split the copy, install and build steps, we could prevent that dependencies would be installed after each change.

# Stage 1: Builder
FROM docker.io/library/node:22-bookworm AS builder

WORKDIR /app

# Just copy package json files and doing install
# So install would be done next time if we change
# content of package json files.
COPY package*.json ./
RUN npm ci

# Copy everything then rebuild
COPY . .

RUN npm run build

# Stage 2: Final image with Nginx
FROM docker.io/library/nginx:stable-alpine AS production

COPY --from=builder /app/dist /usr/share/nginx/html

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

Effect of this change is similar like in case of previous example. It does not change the size of the final image, just enable quicker rebuilds.

Fine tune with Go

Let’s assume you are working on backend and you are using a normal backend language like Go, instead of Node.js. What options do we have in this case? I will start again, from the worst option and going toward better options.

The sample Go application is a simple “Hello world” REST API based on net/http.

package main

import "net/http"

func main() {
    // Create a new request multiplexer
    // Take incoming requests and dispatch them to the matching handlers
    mux := http.NewServeMux()

    // Register the routes and handlers
    mux.Handle("/", &homeHandler{})

    // Run the server
    http.ListenAndServe(":8080", mux)
}

type homeHandler struct{}

func (h *homeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, my friend."))
}

Simple and big build

The first option is to use a single image. This image a pretty simple, but it comes with price. Same disadvantages, that I have already wrote with the big Node.js image.

FROM docker.io/library/golang:1.24-bookworm

WORKDIR /app
COPY . .

RUN go build -o app

EXPOSE 8080
CMD ["./app"]

The build is done, and it has been a fat image.

REPOSITORY                TAG            IMAGE ID      CREATED        SIZE
localhost/http_go_big     latest         e072348e63b7  7 minutes ago  978 MB

Use multi-stage build

The second round, we use a multi-stage build. First stage is the golang image where we build the application, then we copy it to a general Debian slim image.

FROM docker.io/library/golang:1.24-bookworm AS builder

WORKDIR /app
COPY . .

RUN go build -o app

FROM docker.io/library/debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/app .

EXPOSE 8080
CMD ["./app"]

The image size has been reduced significantly, as we expected. It is ~10 times smaller than the big image.

REPOSITORY                TAG            IMAGE ID      CREATED        SIZE
localhost/http_go_med     latest         686e60aaf9a7  6 minutes ago  86.1 MB

Use scratch

The image called SCRATCH is a very minimal image, without almost anything. This build is also a multi-stage but instead of Debian slim, we use scratch.

FROM docker.io/library/golang:1.24-bookworm AS builder

WORKDIR /app
COPY . .

# Must disable CGO and build statically
RUN CGO_ENABLED=0 GOARCH=amd64 GOOS=linux  go build  -ldflags="-s -w" -o app

# Final image: empty base
FROM scratch

COPY --from=builder /app/app /app
EXPOSE 8080

ENTRYPOINT ["/app"]

The image size has been really reduced! This is because of scratch image and because build command strip the debug symbols.

REPOSITORY                TAG            IMAGE ID      CREATED         SIZE
localhost/http_go_tiny    latest         f30c49705846  13 seconds ago  5.53 MB

Using CGO_NEABLED=0 (means false) tells the go compiler that do not use any dynamic linking, but make static linking. It means that we do not need to install any shared library altogether with the final binary, because it already contains it.

Some thoughts to be consider

To see the comparison, chart can be found below.

Build application from scratch with Go sounds good, but sometimes it is not possible. Like if we use Apache Kafka library that is build on top of shared library, it would fail. But multi-stage build is always a good option!

And for those who are yelling “Why did you use Debian slim image instead of Alpine??!”, I want to answer. My experience that some standard library things can be tricky with Alpine. To me, the default image I use is Debian slim. If Alpine would be enough in the situation, then scratch also could be fine.

Final words

In this post, I showed how to build smaller and faster container images using some simple yet powerful tricks. Whether you’re working with Node.js, Go, or just writing generic build scripts, small decisions can make a big difference in your build time and image size. There’s no silver bullet – real world setups always involve trade-offs. But understanding these techniques helps you make better decisions, faster iterations, and leaner containers.