Docker with GitHub Actions: Build, Cache, and Deploy Without the Pain
Docker with GitHub Actions: build efficient CI/CD pipelines.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
- ✓Basic knowledge of Docker and Dockerfile syntax
- ✓Familiarity with GitHub Actions workflow YAML
- ✓A GitHub repository with a Dockerized application
To build and push a Docker image with GitHub Actions, use the docker/build-push-action action. Set up secrets for registry credentials, cache layers with cache-from and cache-to, and use matrix builds for multi-architecture images.
Imagine you're a chef who needs to prepare the same meal in dozens of restaurants. Instead of cooking from scratch each time, you prep ingredients in a central kitchen (build stage), pack them into sealed containers (Docker images), and ship them to each restaurant (registry). GitHub Actions is your automated kitchen manager that follows your recipe (workflow) to do this every time you update the menu (push code).
You've been burned by 'it works on my machine' one too many times. Docker fixes that, but only if your CI/CD pipeline actually builds the same image you tested locally. GitHub Actions is the most popular CI/CD platform on the planet, and yet I still see teams pushing 2GB images because they don't understand layer caching. Here's the blunt truth: your pipeline is probably slower and more fragile than it should be.
The core problem is that most tutorials show you how to run docker build in a workflow and call it a day. They ignore the real-world constraints: limited runner disk space, network egress costs, and the fact that rebuilding an image from scratch every time is a waste of time and money. You need a pipeline that's fast, reliable, and doesn't cost a fortune in GitHub Actions minutes.
By the end of this article, you'll be able to set up a production-grade Docker CI/CD pipeline that caches intelligently, builds multi-architecture images, and deploys securely — all while avoiding the gotchas that have taken down production services at 3 AM.
Why Your Docker Builds Are Slow (And How to Fix It)
Every time you run docker build, Docker executes each instruction in your Dockerfile, creating a new layer. If nothing changed, Docker uses the cached layer — but only if the context and previous layers are identical. The problem: GitHub Actions runners are ephemeral. They start fresh each time, so your local cache is gone. Without caching, you rebuild every layer from scratch. That's slow and expensive.
The fix is to use Docker's --cache-from flag to pull cached layers from a registry. GitHub Actions has a built-in cache action, but for Docker, the best approach is to use the docker/build-push-action with cache-from and cache-to pointing to the same registry where you push the final image. This way, layers are stored alongside your image, and subsequent builds reuse them.
mode=max stores all layers, making subsequent builds fast but uses more storage in the registry. mode=min only caches the final layer — faster push but slower rebuilds. For production, use mode=max and set up a lifecycle policy to delete old cache images after 30 days.Multi-Architecture Builds: Arm64 Without the Headache
Apple Silicon is everywhere, and AWS Graviton instances are cheap. If you're still building only for amd64, you're leaving performance and cost on the table. But building for multiple architectures manually is a pain. Docker Buildx makes it trivial with the --platform flag.
In GitHub Actions, you can build for linux/amd64 and linux/arm64 in one step using QEMU emulation. The trick is to set up QEMU before the build and use a matrix strategy if you need separate builds per architecture. For most cases, a single build with multiple platforms works fine, but be aware: QEMU emulation is slow. For large images, consider native arm64 runners (GitHub now offers them).
ubuntu-24.04-arm runner for the arm64 build in a matrix. It's faster and avoids emulation bugs.Secrets Management: Don't Hardcode Credentials
I've seen Dockerfiles with ENV API_KEY=supersecret committed to public repos. That's a fireable offense. GitHub Actions provides secrets, but you need to pass them to Docker correctly. The naive approach is to use --build-arg, but that leaks secrets into the image history. Instead, use Docker's --secret flag (BuildKit) to mount secrets at build time without persisting them.
For runtime secrets, pass them as environment variables in the container using GitHub secrets. Never bake secrets into the image.
--build-arg values are visible in docker history and can be read by anyone with pull access to the image. Always use --secret with BuildKit for build-time secrets. For runtime secrets, use environment variables passed at container start.Testing Your Docker Image in CI Before Push
Building an image is useless if it doesn't work. You should run integration tests against the freshly built image before pushing it to the registry. The pattern: build the image, run it as a service container, execute tests against it, then push only if tests pass.
GitHub Actions supports service containers natively. You can spin up your Docker image as a service, run tests in a separate job step, and conditionally push based on the test result. This prevents broken images from reaching production.
needs dependency prevents push from running if tests fail. This is the standard pattern for production CI/CD pipelines.When Not to Use Docker with GitHub Actions
Docker in CI is not always the answer. If your application is a simple static site, deploying directly to a CDN (like Netlify or Vercel) is faster and cheaper. If you're running a monolith that rarely changes, a traditional VM deployment might be simpler. Docker adds complexity: you need to manage a registry, handle image cleanup, and deal with build failures due to cache invalidation.
Also, if your team doesn't have Docker expertise, the learning curve can slow down development. For small projects or prototypes, consider using GitHub Actions' built-in deploy actions (like azure/webapps-deploy or google-github-actions/deploy-cloudrun) that handle containerization for you.
My rule of thumb: use Docker in CI when you have multiple services, need consistent environments across dev/staging/prod, or are deploying to Kubernetes. Otherwise, keep it simple.
The 4GB Container That Kept Dying
node:18 (full OS) instead of node:18-alpine. The container's memory limit was 512MB, but the image itself consumed 300MB+ just for the OS overhead, leaving little room for the app.node:18-alpine, used multi-stage build with npm ci --only=production in the final stage, and set --memory=256m --memory-reservation=128m in the deployment. Image size dropped to 150MB. OOM kills stopped.- Your Docker image size directly impacts runtime memory pressure.
- Always use slim base images and multi-stage builds — your container's memory limit includes the image overhead.
Error: buildx failed with: ERROR: failed to solve: failed to cache: error writing layer blob: no space left on devicedf -h in a step. 2. Clean Docker cache: docker system prune -af. 3. Reduce cache mode to min temporarily. 4. Use a larger runner (e.g., ubuntu-latest-8-cores).denied: requested access to the resource is not foundGITHUB_TOKEN has write:packages scope.exec format error on arm64docker/setup-qemu-action@v3. 2. Check that your base image supports arm64 (e.g., node:18-alpine does). 3. If using native arm64 runner, verify the Dockerfile doesn't have amd64-specific binaries.docker system prune -afdf -h- name: Clean Docker cache\n run: docker system prune -af| File | Command / Code | Purpose |
|---|---|---|
| .github | name: Build and Push Docker Image | Why Your Docker Builds Are Slow (And How to Fix It) |
| .github | name: Multi-Arch Build | Multi-Architecture Builds |
| .github | name: Secure Build | Secrets Management |
| .github | name: Test and Push | Testing Your Docker Image in CI Before Push |
Key takeaways
cache-from and cache-to to avoid rebuilding from scratch every time.--build-arg for secrets; use BuildKit's --secret mount instead.Interview Questions on This Topic
How does Docker layer caching work in GitHub Actions, and what happens when the cache is invalidated?
--cache-from to pull cached layers from a registry. Cache invalidation occurs when a layer's instruction or its parent layer changes. For example, if you change a COPY instruction, all subsequent layers are rebuilt. To minimize invalidation, order your Dockerfile from least to most frequently changing instructions (e.g., package.json before source code).Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Docker. Mark it forged?
3 min read · try the examples if you haven't