Skip to Content
Multi-Stage Node or Go Image Under 50 MB
Advanced
0 likes
Tutorials

Multi-Stage Node or Go Image Under 50 MB

Build a Go service in a multi-stage Dockerfile and ship it in an image well under 50 MB, with a non-root user and a health check. After this, you know why Go gets that small and Node does not.

A Go web service does not need Go in its image, and it does not need Debian either. With a multi-stage build, the compiler stays in the build stage and the image you ship holds one binary. This page builds a small Go service into an image well under 50 MB and checks the size. I ran it on Docker Desktop 4.85.0 (Engine 29.6.2) with golang:1.26-bookworm (Go 1.26.9) and alpine:3.24.

Skip ahead: If you are working with Node, read the "What about Node?" section at the end first. A Node image cannot get this small, and the reason matters.

The service

package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	if len(os.Args) > 1 && os.Args[1] == "health" {
		resp, err := http.Get("http://127.0.0.1:8080/")
		if err != nil || resp.StatusCode != 200 {
			os.Exit(1)
		}
		return
	}
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	http.ListenAndServe(":8080", nil)
}
module example.com/small

go 1.26

The health argument lets the same binary act as its own health check, so the final image needs no wget or curl.

The Dockerfile

FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server .

FROM alpine:3.24
RUN adduser -S -H -u 10001 app
COPY --from=build /out/server /usr/local/bin/server
USER app
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=2s --retries=3 CMD ["server", "health"]
CMD ["server"]

I used alpine:3.24 as the final base, not scratch, so there is a shell for debugging and a real user. If you want the smallest possible image, FROM scratch with USER 10001 works too.

  • CGO_ENABLED=0 makes a static binary. Without it, the binary may need a C library the final image does not have, and the container fails with "no such file or directory".
  • -ldflags="-s -w" removes debug symbols, which makes the binary smaller.

Build and check the size

Set-Location small-go
docker build -q -t small-go . | Out-Null
$mb = (docker image inspect small-go --format "{{.Size}}") / 1MB
"image size: {0:N1} MB, under 50 MB: {1}" -f $mb, ($mb -lt 50)
image size: 6.2 MB, under 50 MB: True

Run it and wait for healthy

docker run -d --name small-go-run small-go | Out-Null
Start-Sleep -Seconds 8
docker inspect small-go-run --format "{{.State.Health.Status}}"
docker exec small-go-run wget -qO- http://127.0.0.1:8080/
docker exec small-go-run id
healthy
ok
uid=10001(app) gid=65533(nogroup) groups=65533(nogroup)

Healthy, answering, and running as user 10001, not root.

What about Node?

For Node, the same pattern applies: build in one stage (npm ci, then your build script), and in the final stage copy only the built files and production dependencies (npm ci --omit=dev). But the final image still needs the Node runtime itself, and the official Alpine-based Node image is already larger than 50 MB before your app is added. So "under 50 MB" is realistic for Go, Rust and other compiled languages, and not for Node with the official images. I did not build a Node image for this page.

If the image is still big

  • Check that the final stage starts with its own FROM and does not COPY --from=build a whole folder.
  • Run docker history small-go to see which step added the size.
  • Static files or assets copied into the final stage count too. Copy only what the service serves.

Related course: Docker Advanced.

About

Build a Go service in a multi-stage Dockerfile and ship it in an image well under 50 MB, with a non-root user and a health check. After this, you know why Go gets that small and Node does not.

Responsible
Christopher R
Last Update
08/06/2026
Reading Time
4 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.