Let’s talk about your container images. Think of your first Docker image like a backpack you pack for a weekend trip. You throw in everything you might need: the compiler, the build tools, all the libraries, your source code, maybe even a hefty IDE. By the time you’re done, that backpack is bursting at the seams. Now, imagine carrying that heavy thing all the way to production. It’s slow, cumbersome, and full of stuff you don’t actually need for the journey's end.
This is a common story in the world of containers. We create these massive images during development and then push them to production, slowing down our pipelines and increasing our security risks. But what if we could hand off a sleek, lightweight package for the final trip? That's precisely what multi stage builds allow us to do. Paired with the power of GitHub Container Registry (GHCR), you can create incredibly efficient and secure DevOps workflows. Let’s learn how to become a container packing pro!
The Magic of Multi Stage Builds
So what is this sorcery? A multi stage build is a feature within your Dockerfile that lets you use multiple FROM instructions. Each FROM instruction starts a new, temporary build stage. You can copy artifacts, like your compiled application, from one stage to another, leaving all the development baggage behind.
The result? A tiny final image that contains only what’s absolutely necessary to run your application.
A Tale of Two Dockerfiles
Let’s see this in action with a simple Node.js application. Here’s how a typical, single stage Dockerfile might look:
# The "heavy backpack" approach
FROM node:18
WORKDIR /app
# Copy package files and install all dependencies
COPY package*.json ./
RUN npm install
# Copy the rest of the source code
COPY . .
# Build the application
RUN npm run build
# Expose a port and run the app
EXPOSE 3000
CMD [ "node", "dist/main.js" ]
This image will contain the entire Node.js runtime, all the devDependencies, the source code, and the final build artifact. It's big!
Now, let’s apply the multi stage magic:
# The "sleek and efficient" approach
# Stage 1: The Build Environment
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
# Install all dependencies, including dev ones
RUN npm install
COPY . .
# Create the production build
RUN npm run build
# Stage 2: The Production Environment
FROM node:18-alpine
WORKDIR /app
# Only copy the necessary node_modules for production
COPY --from=builder /app/package*.json ./
RUN npm install --omit=dev
# Copy the built application from the 'builder' stage
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD [ "node", "dist/main.js" ]
Look at that! We have two stages.
The
builderstage: This is our workshop. We use the fullnode:18image, install all dependencies (including the heavy ones needed for building), and compile our application.The final stage: This is our lean production package. We start fresh with a slim
node:18-alpineimage. Crucially, we useCOPY --from=builderto cherry pick only the compiled code (distfolder) and the production dependencies from the first stage. The compiler, test runners, and other development tools are all left behind.
The final image is a fraction of the original size, making it faster to pull, faster to deploy, and more secure because it has a smaller attack surface.
Pro Tips: Optimizing Layers and Caching
Creating small images is awesome, but we can also make our build process faster. Docker builds images in layers, and it loves to reuse them. Understanding how to work with this cache is a superpower.
Layering is Everything
Each instruction in your Dockerfile (COPY, RUN, ADD) creates a new layer. When you rebuild an image, Docker checks if a layer has changed. If not, it uses a cached version, which is super fast.
To take advantage of this, order your instructions from least to most frequently changed.
Consider our Node.js example. The package.json file changes less often than our source code. That's why we copy it and run npm install before we copy the rest of the code.
# Good: Caches the dependency layer
COPY package*.json ./
RUN npm install
COPY . . # This will change often
# Bad: Invalidates the cache every time a source file changes
COPY . .
RUN npm install
If you change a single line in your source code, the first example will use the cached layer for the npm install step, saving you a lot of time. The second example would have to rerun npm install every single time.
Keep It Clean
Try to chain related RUN commands together using &&. This creates a single layer instead of multiple ones, making your images a bit leaner. For example:
# Better
RUN apt-get update && apt-get install -y git
This is generally better than having two separate RUN commands for apt-get update and apt-get install.
Pushing Your Optimized Images to GHCR
Now that we have a beautifully optimized image, let's get it into our GitHub Container Registry and automate the process with GitHub Actions. This is where everything comes together. A lean image pushed to a registry right next to your code means your deployment pipeline will be lightning fast.
Here’s a GitHub Actions workflow that builds our multi stage Dockerfile and pushes the final image to GHCR.
name: Build and Push Optimized Image to GHCR
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Let's quickly review the key parts:
permissions: We give the workflow permission to write topackages, which is GHCR.docker/login-action: This securely logs us into GHCR using the temporaryGITHUB_TOKEN. No secrets needed!docker/build-push-action: This is the star of the show. It builds our multi stage Dockerfile and pushes the final, small image to GHCR.cache-fromandcache-to: This is a fantastic feature. It tells the action to use the GitHub Actions cache as a build cache. This means subsequent builds on the same branch can reuse layers from previous runs, making your CI builds dramatically faster.
With this workflow, every push to main results in a small, secure, and production ready image being published directly to your project’s home on GitHub. Deployments become quicker, and your storage costs on GHCR are lower because the images are so much smaller.
Your New Reality
By mastering multi stage builds and optimizing your Dockerfiles, you fundamentally change your container workflow. You move from creating clunky, all in one images to producing sleek, purpose built artifacts for production. This isn't just a best practice; it's a transformative approach that leads to faster pipelines, more secure applications, and a happier development team. So go on, give your containers the upgrade they deserve!