Challenge, Medium,  on  ContainersProgramming

Optimize Container Images with Multi-Stage Builds

This challenge is a sequel to Build and Compile Applications in Dockerfiles. In that challenge, you containerized a Go backend and a TypeScript frontend using single-stage Dockerfiles that both build and run the applications.

While those Dockerfiles produced working images, they have a significant problem: the final images are bloated with unnecessary build tools and dev dependencies.

The problem with single-stage builds

When you use the golang image to compile and run a Go application in the same stage, the final image inherits the entire Go compiler toolchain - over 800MB of packages and hundreds of CVEs that you don't need at runtime:

A single-stage Dockerfile for a Go application includes the Go compiler in the final image.

Similarly, when you build a TypeScript frontend in a single stage, the TypeScript compiler, type definitions, and all other dev dependencies remain in the final image even though they're only needed during compilation:

A single-stage Dockerfile for a Node.js application includes dev dependencies in the final image.

The solution: Multi-Stage Builds

Multi-stage builds solve this by allowing multiple FROM instructions in a single Dockerfile - one for building the application and another for running it. The COPY --from=<build-stage> instruction lets you cherry-pick only the build artifacts you need into the final runtime image:

A multi-stage Dockerfile separates the build and runtime environments.

Explore the applications

The applications are the same as in the previous challenge:

Go Backend
TypeScript Frontend

The backend source is in ~/backend/. It's a Go HTTP server with a couple of API endpoints:

cat ~/backend/main.go

The build command: go build -o server . (produces a binary).

The frontend source is in ~/frontend/. It's a TypeScript Express.js server that serves an HTML page and proxies API requests to the backend:

cat ~/frontend/src/server.ts
cat ~/frontend/package.json

The build process: npm ci (install dependencies) followed by npm run build (compile TypeScript to JavaScript in dist/).

The task

Your goal is to write multi-stage Dockerfiles that produce slim, production-ready images:

  1. Create a multi-stage Dockerfile in ~/backend/ and build a Docker image named my-backend:v1.0.0.
  2. Create a multi-stage Dockerfile in ~/frontend/ and build a Docker image named my-frontend:v1.0.0.
  3. Both images should start correctly, respond on their respective ports, and be free of unnecessary build tools.

You can test your images at any point:

docker run -p 8080:8080 my-backend:v1.0.0
docker run -p 3000:3000 my-frontend:v1.0.0

Go backend

Backend multi-stage hint

Your Dockerfile needs two stages:

  • A build stage that starts FROM an image with the Go compiler to compile the source code.
  • A runtime stage that starts FROM a minimal base image and copies only the compiled binary from the build stage.

The runtime stage should NOT use the golang image as its base.

Backend debugging hint

If the container crashes, run it in the foreground to see the error:

docker run my-backend:v1.0.0
Backend size hint

A compiled Go binary is self-contained - it doesn't need the Go compiler or standard library sources at runtime. Pick a minimal base image for your runtime stage (think Alpine, Debian slim, or even a distroless image). With CGO_ENABLED=0, the binary is statically linked and can run on virtually any base.

TypeScript frontend

Frontend multi-stage hint

Your Dockerfile needs two stages:

  • A build stage that installs all dependencies (including dev ones like the TypeScript compiler), then compiles the TypeScript source into JavaScript.
  • A runtime stage that installs only the production dependencies and copies the compiled JavaScript files from the build stage.

The key is ensuring that dev dependencies like typescript don't end up in the final image.

Frontend debugging hint

If the container crashes, run it in the foreground to see the error:

docker run my-frontend:v1.0.0
Frontend optimization hint

The compiled JavaScript needs production dependencies like express to run, but it does NOT need dev dependencies like typescript. Think about which npm install options control whether dev dependencies are included.