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.
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
| Requirement | Details |
|---|---|
| Tested application stack | Go 1.24, Alpine 3.21, distroless Debian 12 |
| Docker client | Docker 27.x or newer recommended (29.x is current at time of writing) |
| Windows interface | PowerShell 7 or Windows PowerShell |
| macOS interface | Terminal using Zsh or Bash |
| Account | Not required for local builds; registry publishing requires registry access |
| Docker Desktop license | Confirm 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.xServer: Docker Desktop
Engine:
Version: 27.x.xgithub.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.
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.xServer: Docker Desktop
Engine:
Version: 27.x.xgithub.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.
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:
buildcompiles the application.productionreceives only the static binary.debugreceives 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.
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 productionstops at and exports the named production stage.--tag network-demo:productionassigns 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.
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.
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.
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
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:
| Test | Tool | What it isolates |
|---|---|---|
| Name lookup | dig or nslookup | Docker DNS and returned addresses |
| TCP connection | nc -vz | Route, listener, and port reachability |
| HTTP response | curl | Application-layer health |
| Route table | ip route | Gateway and network path |
| Path discovery | traceroute | Where routed traffic stops |
| Packet observation | tcpdump | Requests, 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.
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.
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.
Configuration
Choosing Alpine or Distroless for Production
Alpine and distroless both reduce image size, but they behave differently at runtime.
| Base | Advantages | Compatibility cautions |
|---|---|---|
| Alpine | Small, includes apk, and can include BusyBox tools | Uses musl libc instead of glibc; native modules and precompiled binaries may fail |
| Distroless static | Very small runtime surface; good for static Go and Rust binaries | No shell or package manager; the binary must be self-contained |
| Distroless language runtime | Minimal runtime tailored to Java, Node.js, or Python | Startup paths, users, certificates, and supported modules must match the selected image |
| Debian slim | Broader glibc compatibility and easier operational inspection | Larger 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:
| Purpose | Dockerfile stage | Suggested tag |
|---|---|---|
| Release deployment | production | app:2026.08 |
| Controlled diagnostics | debug | app:debug-2026.08 |
| Compilation only | build | Usually 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
.dockerignorefile 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
| Step | Action | Applies To |
|---|---|---|
| Build | Compile in a tool-rich stage | All builds |
| Release | Copy only the binary into distroless | Production |
| Diagnose | Build the Alpine target with --target debug | Controlled debugging |
| Attach | Run netshoot on the named network or container namespace | Live troubleshooting |
| Publish | Use separate targets and tags | CI/CD |
| Expand | Build linux/amd64 and linux/arm64 with Buildx | Multi-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.