Build Smaller, Faster and More Secure Docker Images
Build Smaller, Faster and More Secure Docker Images
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:
Multi-stage Docker builds
BuildKit cache mounts
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:
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.