docker

Build Smaller, Faster and More Secure Docker Images

Build Smaller, Faster and More Secure Docker Images

By Shubhankar Tripathi • • 5 min read

Container images often become much larger than they need to be. A typical development image such as node:20 or golang:1.22 may contain compilers, package managers, build tools, debugging utilities, and other dependencies that are never required when the application is actually running.

The result is predictable:

  • Slower CI/CD builds

  • Longer image-pull and deployment times

  • Higher storage and network costs

  • Larger container attack surfaces

  • More unnecessary packages to patch and maintain

A better approach is to keep the build environment separate from the runtime environment.

In this guide, we'll use three techniques:

  1. Multi-stage Docker builds

  2. BuildKit cache mounts

  3. Minimal runtime images such as Distroless

For compiled applications such as Go, this can reduce the final image to only a few megabytes. For Node.js and other interpreted runtimes, the goal is to retain only the files and dependencies required in production.


1. Build an Ultra-Lean Go Container

Go is particularly well suited to container optimization because a Go application can be compiled into a single executable.

Instead of shipping the entire Go toolchain with the application, we can compile the application in one stage and copy only the resulting binary into a minimal runtime image.

Production Dockerfile

# syntax=docker/dockerfile:1.7


# --------------------------------------------------

# Stage 1: Build

# --------------------------------------------------

FROM golang:1.22-alpine AS builder


WORKDIR /src


COPY go.mod go.sum ./


RUN --mount=type=cache,target=/go/pkg/mod \

    go mod download


COPY . .


RUN --mount=type=cache,target=/go/pkg/mod \

    --mount=type=cache,target=/root/.cache/go-build \

    CGO_ENABLED=0 \

    GOOS=linux \

    GOARCH=amd64 \

    go build \

    -ldflags="-s -w" \

    -o /bin/server \

    ./cmd/api



# --------------------------------------------------

# Stage 2: Runtime

# --------------------------------------------------

FROM gcr.io/distroless/static-debian12:nonroot


USER nonroot:nonroot


COPY --from=builder \

     --chown=nonroot:nonroot \

     /bin/server \

     /server


EXPOSE 8080


ENTRYPOINT ["/server"]


Build the Go image

From the directory containing your Dockerfile:

docker build \

  --tag go-api:latest \

  .


Because the Dockerfile uses BuildKit cache mounts, make sure BuildKit is enabled. Modern Docker installations enable it by default.

You can explicitly enable it with:

DOCKER_BUILDKIT=1 docker build \

  --tag go-api:latest \

  .


Run the Go container

The application listens on port 8080, so map that port to the host:

docker run \

  --detach \

  --name go-api \

  --publish 8080:8080 \

  go-api:latest


The API will then be available at:

http://localhost:8080


Check the running container:

docker ps


View application logs:

docker logs go-api


Stop and remove the container:

docker stop go-api

docker rm go-api


Check the final image size

docker image ls go-api


You can also inspect the image layers:

docker history go-api:latest



2. Optimize Node.js Container Images

Node.js applications require a slightly different strategy.

Unlike Go, Node.js applications normally need the Node runtime and their production dependencies at runtime.

However, development dependencies such as:

  • TypeScript

  • Webpack

  • Vite

  • ESLint

  • Test frameworks

  • Build plugins

usually don't need to be included in the final container.

This makes Node.js a good candidate for a multi-stage build.

Example Dockerfile

# --------------------------------------------------

# Stage 1: Install dependencies

# --------------------------------------------------

FROM node:20-alpine AS deps


WORKDIR /app


COPY package.json package-lock.json ./


RUN npm ci --omit=optional



# --------------------------------------------------

# Stage 2: Build application

# --------------------------------------------------

FROM node:20-alpine AS builder


WORKDIR /app


COPY --from=deps /app/node_modules ./node_modules

COPY . .


RUN npm run build


RUN npm prune --production



# --------------------------------------------------

# Stage 3: Production runtime

# --------------------------------------------------

FROM node:20-alpine AS runner


WORKDIR /app


ENV NODE_ENV=production


RUN addgroup --system --gid 1001 nodejs && \

    adduser --system --uid 1001 appuser


COPY --from=builder \

     --chown=appuser:nodejs \

     /app/dist \

     ./dist


COPY --from=builder \

     --chown=appuser:nodejs \

     /app/node_modules \

     ./node_modules


COPY --from=builder \

     --chown=appuser:nodejs \

     /app/package.json \

     ./package.json


USER appuser


EXPOSE 3000


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


Build the Node.js image

From the directory containing the Dockerfile:

docker build \

  --tag node-api:latest \

  .


Or explicitly enable BuildKit:

DOCKER_BUILDKIT=1 docker build \

  --tag node-api:latest \

  .


Run the Node.js container

The application listens on port 3000:

docker run \

  --detach \

  --name node-api \

  --publish 3000:3000 \

  node-api:latest


The API will then be available at:

http://localhost:3000


Check the container:

docker ps


View logs:

docker logs node-api


Stop and remove it:

docker stop node-api

docker rm node-api


Check the final image size

docker image ls node-api


And inspect its layers:

docker history node-api:latest



3. Why BuildKit Cache Mounts Matter

Multi-stage builds reduce the final image size, but BuildKit can also make the build process itself faster.

For example:

RUN --mount=type=cache,target=/go/pkg/mod \

    go mod download


creates a persistent build cache for downloaded Go modules.

Similarly:

RUN --mount=type=cache,target=/root/.cache/go-build \

    go build ...


allows Go's compilation cache to be reused between builds.

This becomes particularly useful in CI/CD environments where the same dependencies are downloaded and compiled repeatedly.

The important distinction is:

Cache mounts improve build performance; multi-stage builds reduce the final runtime image.

They solve different problems and work well together.


4. Distroless Images and Minimal Runtimes

A traditional application image often contains far more than the application actually needs.

For example, a general-purpose Linux image may include:

  • Shell utilities

  • Package managers

  • Networking tools

  • Editors

  • Compilers

  • Debugging utilities

A Distroless image takes a different approach: provide a minimal runtime environment without the usual collection of operating-system utilities.

For example:

FROM gcr.io/distroless/static-debian12:nonroot


For a statically compiled Go binary, this can be an excellent production runtime.

There is also an important security benefit: fewer packages and utilities generally mean a smaller potential attack surface.

However, a minimal image is not a substitute for security practices such as vulnerability scanning, dependency management, least-privilege execution, and regular updates.


5. Run Containers as a Non-Root User

Another useful production practice is avoiding the root user whenever possible.

For example:

USER nonroot:nonroot


or:

USER appuser


Running the application as an unprivileged user limits what the process can do if the application is compromised.

For production containers, combine this with:

  • Read-only filesystems where practical

  • Dropped Linux capabilities

  • Resource limits

  • Network restrictions

  • Image vulnerability scanning

  • Regular dependency updates


6. Example Image Size Comparison

The exact size depends on the application, dependencies, architecture, and image versions, so benchmark your own images rather than treating fixed numbers as universal.

A typical comparison might look like this:

Runtime

Development / Builder Image

Optimized Runtime

Main Optimization

Go API

~1 GB+

Often tens of MB or less

Static binary + minimal runtime

Node.js API

~1 GB+

Often significantly smaller

Production dependencies only

Python FastAPI

~1 GB+

Application-dependent

Slim runtime + production dependencies

You can inspect the actual image size with:

docker images


For a more detailed view of where the space is being used:

docker history <image-name>


For even deeper analysis, tools such as dive can help identify which layers are consuming the most space.


7. The Bigger Picture

The objective isn't simply to create the smallest possible Docker image.

A production container should balance:

Size + Build Speed + Security + Maintainability

For example:

                Application Source

                         |

                         v

              +---------------------+

              |   Builder Stage      |

              |---------------------|

              | Compiler             |

              | Build tools          |

              | Dev dependencies     |

              | Source code          |

              +----------+----------+

                         |

                    Build Artifact

                         |

                         v

              +---------------------+

              |   Runtime Stage     |

              |---------------------|

              | Application          |

              | Runtime dependencies |

              | Minimal OS/runtime   |

              +----------+----------+

                         |

                         v

                 Production Image


This separation gives you a cleaner deployment artifact and prevents development tooling from unnecessarily reaching production.


8. Practical Docker Optimization Checklist

Before pushing an image to a production registry, check the following:

  • Use multi-stage builds

  • Copy dependency manifests before application source

  • Use BuildKit cache mounts where they improve build performance

  • Remove development dependencies from the runtime image

  • Prefer minimal runtime images

  • Use Distroless where the application supports it

  • Run the application as a non-root user

  • Add a suitable .dockerignore

  • Scan images for vulnerabilities

  • Pin or otherwise control important base-image versions

  • Regularly rebuild images to receive security updates

  • Measure the final image instead of assuming its size


Conclusion

A production Docker image doesn't need to contain the entire development environment.

The key idea is simple:

Build with everything you need. Run with only what you need.

Multi-stage builds keep compilers and development tools outside the final image. BuildKit cache mounts can accelerate repeated builds, while minimal or Distroless runtime images help reduce unnecessary packages and the associated attack surface.

The result is a cleaner container architecture with potentially:

  • Faster CI/CD pipelines

  • Smaller image transfers

  • Lower registry and network usage

  • Fewer production dependencies

  • A reduced container attack surface

  • Easier operational maintenance

The most important step is to measure your actual application, because image sizes vary considerably depending on dependencies and build configuration.