How-To

Docker Multi-Stage Builds for Network Tools on Windows & macOS

17 min read

The Back Room Tech is reader-supported. We may earn a commission when you buy through links on our site. Learn more.

Shipping curl, dig, ping, nc, and tcpdump in production makes troubleshooting easier. Those packages also need scanning and patching.

Docker multi-stage builds give us one Dockerfile, a lean production image, and a separate debug image. We’ll build a small Go service. Then we’ll test it from the same Docker network and network namespace.

What Is a Docker Multi-Stage Build?

A Docker multi-stage build uses several FROM instructions in one Dockerfile. Each instruction starts a clean stage. AS gives each stage a useful name:

FROM golang:1.24-alpine AS build
FROM gcr.io/distroless/static-debian12:nonroot AS production
FROM alpine:3.21 AS debug

COPY --from=build moves selected files from the build stage into a later stage. The source, compiler, package cache, and build dependencies stay behind.

That boundary fixes a common single-stage mistake. You install a compiler and diagnostic tools, build the app, then deploy the entire workshop. The image takes longer to pull and has more software to patch. An intruder also gets more useful commands.

Official Docker multi-stage build documentation showing multiple FROM instructions, a named stage with AS, and COPY --from

Prerequisites

Make sure you have:

  • A supported Windows or macOS system capable of running Docker Desktop (virtualization enabled in firmware, and WSL2 on Windows — see Docker’s system requirements for exact OS/build minimums)
  • Docker Desktop installed and its engine running
  • Docker Buildx, which current Docker Desktop releases include
  • At least 4 GB of free memory and 5 GB of free disk space for this lab
  • Internet access to download Go, Alpine, distroless, and netshoot images
  • PowerShell or Windows Terminal on Windows
  • Terminal and a text editor on macOS
  • Permission to run containers and create Docker networks
RequirementDetails
Tested application stackGo 1.24, Alpine 3.21, distroless Debian 12
Docker clientDocker 27.x or newer recommended (29.x is current at time of writing)
Windows interfacePowerShell 7 or Windows PowerShell
macOS interfaceTerminal using Zsh or Bash
AccountNot required for local builds; registry publishing requires registry access
Docker Desktop licenseConfirm that Docker Personal or your organization’s paid plan covers your use

Docker publishes its current terms on the pricing page and pricing FAQ. Check them before using Docker Desktop at work. Licensing surprises don’t improve when found during an audit.

Step-by-Step Guide

Step 1: Install and Verify Docker Desktop

Multi-stage builds are part of the Dockerfile format. There’s no extra package to install. You do need a working Docker engine with BuildKit support.

Windows

Download Docker Desktop from the official Docker website. Run the installer, finish its prompts, and start Docker Desktop. Installer choices vary by release and host setup. Keep the recommended container backend unless your organization requires another one.

Open PowerShell and run:

docker version
docker buildx version

Expected output includes client and server sections, followed by the Buildx version:

Client:
Version: 27.x.x

Server: Docker Desktop
Engine:
Version: 27.x.x

github.com/docker/buildx v0.x.x

If you get client details followed by a connection error, the CLI is installed but the engine isn’t ready. Give Docker Desktop another minute. Check its status before changing anything.

Windows desktop with Docker Desktop running and PowerShell open for Docker verification

macOS

Download the correct Docker Desktop package for your Mac from the official Docker website. Choose the Apple silicon or Intel build that matches your hardware. Install it, then start Docker Desktop.

Open Terminal and run:

docker version
docker buildx version

Expected output:

Client:
Version: 27.x.x

Server: Docker Desktop
Engine:
Version: 27.x.x

github.com/docker/buildx v0.x.x

Both client and server output matter. Client output alone proves the command exists, but it won’t build an image.

macOS desktop with Docker Desktop running and Terminal open for Docker verification

Step 2: Create the Example Project

The example service listens on TCP port 8080 and returns its hostname. That’s enough behavior to test image targets, Docker DNS, routing, and port access. We can focus on the network work instead of extra application code.

Windows

Create the project directory in PowerShell:

New-Item -ItemType Directory -Force -Path "$HOME\docker-multistage-network"
Set-Location "$HOME\docker-multistage-network"
notepad app.go

Paste this code into Notepad, save it as app.go, and close Notepad:

package main

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

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		hostname, err := os.Hostname()
		if err != nil {
			hostname = "unknown"
		}

		w.Header().Set("Content-Type", "text/plain")
		fmt.Fprintf(w, "service=network-demo hostname=%s\n", hostname)
	})

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

macOS

Create the directory and open the file with Nano:

mkdir -p "$HOME/docker-multistage-network"
cd "$HOME/docker-multistage-network"
nano app.go

Paste the same Go code. Press Control+O, then Enter to save. Press Control+X to exit.

Verify that the file exists.

Windows:

Get-ChildItem

macOS:

ls -la

Expected result:

app.go

If the file appears as app.go.txt on Windows, rename it before continuing. Notepad’s extension handling has wasted enough afternoons already.

Step 3: Create Build, Production, and Debug Stages

Open a new file named Dockerfile.

Windows:

notepad Dockerfile

macOS:

nano Dockerfile

Add this Dockerfile:

# syntax=docker/dockerfile:1

# Build stage: contains the Go compiler and source code.
FROM golang:1.24-alpine AS build

WORKDIR /src

COPY app.go ./

# BuildKit supplies TARGETOS and TARGETARCH for local and multi-platform builds.
ARG TARGETOS
ARG TARGETARCH

# CGO_ENABLED=0 produces a static binary suitable for the distroless static image.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
    go build -trimpath -ldflags="-s -w" -o /out/network-demo ./app.go

# Production stage: no compiler, package manager, or shell.
FROM gcr.io/distroless/static-debian12:nonroot AS production

COPY --from=build /out/network-demo /network-demo

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/network-demo"]

# Debug stage: separately targeted image with approved network tools.
FROM alpine:3.21 AS debug

RUN apk add --no-cache \
    bind-tools \
    curl \
    iproute2 \
    iputils \
    netcat-openbsd \
    tcpdump \
    traceroute

COPY --from=build /out/network-demo /usr/local/bin/network-demo

EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/network-demo"]

On macOS, save with Control+O, press Enter, and exit with Control+X.

The stages have separate jobs:

  • build compiles the application.
  • production receives only the static binary.
  • debug receives the same binary, plus a shell and network utilities.

COPY --from=build is the useful boundary. It copies /out/network-demo. The Go compiler, source tree, and Alpine package database don’t enter production.

The production stage also runs as nonroot. That’s the right default here, though it may expose apps that assume they can write anywhere.

Completed Dockerfile at readable scale showing the build, production, and debug stages, including COPY --from and the debug packages

Step 4: Build the Production Target

Run the build from the directory that contains Dockerfile.

Windows:

Set-Location "$HOME\docker-multistage-network"
docker build --target production --tag network-demo:production .

macOS:

cd "$HOME/docker-multistage-network"
docker build --target production --tag network-demo:production .

The flags do specific work:

  • --target production stops at and exports the named production stage.
  • --tag network-demo:production assigns an explicit production tag.
  • . sends the current directory as the build context.

Expected final lines resemble:

=> exporting to image
=> => naming to docker.io/library/network-demo:production

The target has the binary, certificate data from the base image, and little else. You won’t find sh, apk, curl, or dig inside it. That’s deliberate, and it changes how we’ll troubleshoot later.

Step 5: Build Only the Debug Stage

Build the debug target with a different tag:

docker build --target debug --tag network-demo:debug .

The same command works in PowerShell and macOS Terminal.

Expected final lines:

=> exporting to image
=> => naming to docker.io/library/network-demo:debug

--target debug tells Docker to export only the debug stage. Keep its tag clearly different from production. Reusing the release tag puts the tool-filled image one typo away from deployment.

PowerShell showing successful docker build commands with --target production and --target debug and distinct image tags

Step 6: Compare the Images

List both images:

docker image ls network-demo

Expected output follows this pattern:

REPOSITORY TAG IMAGE ID CREATED SIZE
network-demo debug abc123… 1 minute ago 25MB
network-demo production def456… 2 minutes ago 10MB

Exact sizes depend on the CPU architecture and base-image release. The pattern matters. The debug image is larger because it has a shell, package manager, DNS tools, and packet tools.

Keeping curl, ping, dig, nc, and tcpdump out of production has three practical effects:

  • Fewer packages need vulnerability scanning and patching.
  • Someone who reaches the container has fewer discovery and transfer tools.
  • Deployment nodes pull and unpack fewer bytes.

A smaller image doesn’t make a vulnerable app safe. It reduces the runtime surface and maintenance work. That’s useful, though less glamorous than vendors tend to imply.

PowerShell output from docker image ls comparing network-demo production and debug image tags and sizes

Step 7: Run and Verify the Production Container

Create a user-defined network and start the application:

docker network create demo-net
docker run --detach --name network-demo-app --network demo-net --publish 8080:8080 network-demo:production

--detach runs the container in the background. --network demo-net joins the named network. --publish 8080:8080 maps host port 8080 to container port 8080.

Expected output:

demo-net
a-long-container-id

Test the application from Windows PowerShell:

Invoke-WebRequest -UseBasicParsing http://localhost:8080

Expected content:

service=network-demo hostname=network-demo-app

Test it from macOS:

curl http://localhost:8080

Expected response:

service=network-demo hostname=network-demo-app

Confirm the container is running:

docker container ls --filter name=network-demo-app

Expected output shows Up and the port mapping 0.0.0.0:8080->8080/tcp. If the container has exited, check docker logs network-demo-app before rebuilding anything.

Step 8: Confirm the Production Image Has No Shell

Try to execute /bin/sh:

docker exec -it network-demo-app /bin/sh

Expected error:

exec: “/bin/sh”: stat /bin/sh: no such file or directory

That failure confirms the distroless layout. A distroless image has no shell or package manager. Your debugging plan can’t depend on docker exec -it ... sh.

Don’t install tools in a live production container. This image won’t allow it anyway. Mutable containers also stop matching the tested artifact. That makes the next incident harder to reproduce.

Terminal showing docker exec attempting /bin/sh in the distroless production container and the resulting missing-file error

Step 9: Run the Debug Target on the Same Named Network

The custom debug image starts the app by default. Override its entry point to open the Alpine shell:

docker run --rm -it --network demo-net --entrypoint /bin/sh network-demo:debug

Inside the debug container, test Docker DNS first:

dig network-demo-app

Expected output includes an address in the answer section:

;; ANSWER SECTION:
network-demo-app. 600 IN A 172.x.x.x

Then test the application port:

curl http://network-demo-app:8080
nc -vz network-demo-app 8080

Expected results:

service=network-demo hostname=network-demo-app
network-demo-app (…) 8080 open

The order matters. If dig fails, check the service name and network membership. If DNS works but nc fails, check the route, listening address, and port. Testing HTTP first can turn several faults into one vague error.

Type exit to remove the temporary debug container:

exit
Debug target running on demo-net with dig resolving network-demo-app followed by successful curl and netcat port tests

Step 10: Use Netshoot on the Same Docker Network

A custom debug stage makes sense when you need the app binary and a controlled package list. For wider diagnostics, nicolaka/netshoot provides a maintained set of network tools.

Run it on demo-net:

docker run --rm -it --network demo-net nicolaka/netshoot

--rm deletes the diagnostic container when it exits. It leaves the downloaded image, app container, and Docker network intact.

Inside netshoot, run:

dig network-demo-app
curl http://network-demo-app:8080
nc -vz network-demo-app 8080
ip route

Use the tools in this order:

TestToolWhat it isolates
Name lookupdig or nslookupDocker DNS and returned addresses
TCP connectionnc -vzRoute, listener, and port reachability
HTTP responsecurlApplication-layer health
Route tableip routeGateway and network path
Path discoverytracerouteWhere routed traffic stops
Packet observationtcpdumpRequests, replies, retransmits, and resets

Exit when finished:

exit

Netshoot carries far more software than this small service needs. That’s useful during an incident. It’s also why I’d keep it out of the app image and pull it only where policy allows.

Public netshoot Docker Hub page showing the image name and its documented DNS, connectivity, routing, and packet tools
PowerShell launching netshoot on demo-net and showing a successful service-name lookup and TCP port test

Step 11: Share the Application’s Exact Network Namespace

Joining the same named network gives a debug container its own interface and IP address. Share the app’s network namespace when you need its exact interfaces, routes, and loopback address:

docker run --rm -it --network container:network-demo-app nicolaka/netshoot

The container:network-demo-app mode makes netshoot use the running app container’s network namespace. Docker doesn’t create another network stack for the diagnostic container.

Test the application through loopback:

curl http://127.0.0.1:8080
ss -lnt
ip address
ip route

Expected curl response:

service=network-demo hostname=network-demo-app

Use this mode when a service listens only on 127.0.0.1 or has unusual routes. It also helps when the service acts differently from another bridge container. Sharing the namespace removes several variables at once.

Only the network namespace is shared. Netshoot doesn’t receive the app filesystem, process namespace, or environment. Namespace sharing sounds broad, so keep that boundary in mind.

Terminal showing netshoot launched with --network container:network-demo-app followed by curl to 127.0.0.1 and network namespace inspection

Step 12: Build AMD64 and ARM64 Images with Buildx

Multi-platform images help when one tag must run on Intel, AMD, and ARM64 systems. That includes Apple silicon and ARM cloud instances.

Check the available builders:

docker buildx ls

Create a dedicated builder only when the current builder can’t produce multi-platform output:

docker buildx create --name network-builder --driver docker-container --use
docker buildx inspect --bootstrap

Build and publish the production target:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --target production \
  --tag YOUR_REGISTRY/YOUR_NAMESPACE/network-demo:2026.08 \
  --push \
  .

--platform requests Linux images for AMD64 and ARM64. --push sends their manifests and layers straight to the registry. The classic local image store can’t always load a multi-platform result.

Build the debug image under a distinct tag:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --target debug \
  --tag YOUR_REGISTRY/YOUR_NAMESPACE/network-demo:debug-2026.08 \
  --push \
  .

Every base image and native dependency must support both architectures. This example uses Go’s target variables and creates a static binary. Apps linked against C libraries may need native builders or explicit cross-compilation setup. That gets complicated quickly.

Official Docker multi-platform build documentation showing docker buildx build and --platform linux/amd64,linux/arm64

Configuration

Choosing Alpine or Distroless for Production

Alpine and distroless both reduce image size, but they behave differently at runtime.

BaseAdvantagesCompatibility cautions
AlpineSmall, includes apk, and can include BusyBox toolsUses musl libc instead of glibc; native modules and precompiled binaries may fail
Distroless staticVery small runtime surface; good for static Go and Rust binariesNo shell or package manager; the binary must be self-contained
Distroless language runtimeMinimal runtime tailored to Java, Node.js, or PythonStartup paths, users, certificates, and supported modules must match the selected image
Debian slimBroader glibc compatibility and easier operational inspectionLarger than Alpine or distroless and contains more packages

Use distroless static for a tested static binary when your support process can work without a shell. Use Alpine only after testing musl compatibility. For Node.js native modules and third-party glibc binaries, Debian slim is often worth the extra bytes.

The smallest image can carry the largest support bill. Measure image size, startup time, and vulnerability findings. Count the time spent fixing runtime compatibility too.

Stage and Tag Naming

Keep stage names and tags explicit:

PurposeDockerfile stageSuggested tag
Release deploymentproductionapp:2026.08
Controlled diagnosticsdebugapp:debug-2026.08
Compilation onlybuildUsually not published

When --target is omitted, Docker exports the last stage. Always select --target production in release automation. That prevents a later Dockerfile edit from changing the published artifact.

CI/CD Separation

Use separate jobs or steps for release and debug outputs. The production job should always name the production target:

docker buildx build \
  --target production \
  --tag YOUR_REGISTRY/YOUR_NAMESPACE/network-demo:${RELEASE_TAG} \
  --push \
  .

A controlled debug job needs a separate target and a clear tag:

docker buildx build \
  --target debug \
  --tag YOUR_REGISTRY/YOUR_NAMESPACE/network-demo:debug-${RELEASE_TAG} \
  --push \
  .

Limit debug-image publishing to manual workflows or a private registry when possible. A debug image is an operations tool. Leaving it beside public release tags invites someone to deploy it at 02:00.

Tips and Troubleshooting

docker version Cannot Connect to the Engine

Error:

Cannot connect to the Docker daemon

Cause: Docker Desktop isn’t running, is still starting, or the client points to an unavailable Docker context.

Fix:

  • Start Docker Desktop.
  • Wait until its engine reports ready.
  • List contexts:
docker context ls
  • Select the local Desktop context shown by that command, then retry docker version.

Don’t reinstall Docker Desktop before checking the context. The wrong context causes the same broad connection error and takes about ten seconds to verify.

The Build Reports an Empty TARGETARCH

Cause: An older builder may not supply the automatic platform arguments. A builder without BuildKit behavior can cause the same problem.

Fix: Confirm Buildx works:

docker buildx version
docker buildx inspect --bootstrap

For a standard local build, you can simplify the Go build line to GOOS=linux go build. Do that only when you don’t need multiple architectures.

The Production Container Has No Shell

Error:

exec: “/bin/sh”: stat /bin/sh: no such file or directory

Cause: Distroless omits shells and package managers by design.

Fix: Leave the production container unchanged. Use either:

docker run --rm -it --network demo-net nicolaka/netshoot

Or share its exact network namespace:

docker run --rm -it --network container:network-demo-app nicolaka/netshoot

This is the operations cost of a shell-free image. Plan for it before an outage, preferably while everyone still remembers the container name.

DNS Works but Port 8080 Is Closed

Cause: The name resolves, but the service may have stopped. It may also use the wrong interface or port.

Fix:

  • Resolve the name:
dig network-demo-app
  • Test the exact port:
nc -vz network-demo-app 8080
  • Inspect listeners from the shared network namespace:
docker run --rm -it --network container:network-demo-app nicolaka/netshoot ss -lnt

The Go example binds to :8080, which listens on available interfaces. An app bound to another address won’t accept this connection, even when Docker DNS works.

The Hostname Works in One Container but Not on the Host

Cause: Docker’s embedded DNS resolves container names inside the matching user-defined network. The host operating system doesn’t query that DNS zone.

Fix: Test network-demo-app from a diagnostic container attached to demo-net. From the host, use the published address http://localhost:8080.

This catches people because both requests happen on the same laptop. They still start from different network and DNS contexts.

The Debug Container Cannot Reproduce the Failure

Cause: The diagnostic container may use Docker’s default bridge instead of the application network.

Fix: Inspect the application’s networks:

docker inspect --format '{{json .NetworkSettings.Networks}}' network-demo-app

Start the diagnostic image with the matching --network value. Use --network container:network-demo-app when you need the same interfaces and routes.

The Production Image Is Still Large

Cause: The final stage may copy source directories, build caches, or dependency trees instead of one runtime file.

Fix:

  • Keep compilers and package installation in build.
  • Copy only required output with COPY --from.
  • Add a .dockerignore file for source trees with local build output.
  • Inspect the layers:
docker history network-demo:production

Docker’s Dockerfile best practices explain the wider build-context and layer rules. docker history gives you local evidence. Use it before guessing which layer grew.

The Binary Fails After Moving to Alpine or Distroless

Cause: The binary may need glibc, shared libraries, certificates, timezone data, or a shell startup script. The new base may omit those files.

Fix:

  • Confirm whether the binary is static.
  • Match its C library and runtime dependencies to the base image.
  • Replace shell-form startup scripts with an executable-form ENTRYPOINT.
  • Test the exact production target before publishing it.
  • Use Debian slim if compatibility matters more than the smallest possible image.

I’d choose Debian slim when a vendor supplies only a glibc-linked binary. Saving a few dozen megabytes rarely justifies fragile build logic and poor sleep.

Clean Up the Lab

Stop and remove the example container:

docker rm --force network-demo-app
docker network rm demo-net

These commands delete the named test container and network. They leave unrelated containers and networks alone.

Optionally remove the two locally built images:

docker image rm network-demo:production network-demo:debug

Wrapping Up

StepActionApplies To
BuildCompile in a tool-rich stageAll builds
ReleaseCopy only the binary into distrolessProduction
DiagnoseBuild the Alpine target with --target debugControlled debugging
AttachRun netshoot on the named network or container namespaceLive troubleshooting
PublishUse separate targets and tagsCI/CD
ExpandBuild linux/amd64 and linux/arm64 with BuildxMulti-platform deployments

The production image gets the runtime file instead of the whole build bench. For diagnostics, use the Alpine target, netshoot, or namespace sharing when you need the app’s exact network view.

Distroless suits static services with mature logs and metrics, but it removes the shell escape hatch. Keep the debug tag separate and test both paths before the incident clock starts.