Post

How Blacksmith Accelerates Docker Builds in CI/CD Pipelines

How Blacksmith Accelerates Docker Builds in CI/CD Pipelines

Introduction

Every developer knows the frustration: you push a minor code change, trigger your GitHub Actions workflow, and then wait. And wait. Your Docker build churns through layers it’s already built before, npm reinstalls packages that haven’t changed, and what should take seconds stretches into minutes. For teams deploying multiple times daily, these slow builds don’t just waste time—they break flow, delay feedback, and compound across every pull request.

Blacksmith emerged as a solution to this exact problem. By providing intelligent, persistent caching infrastructure specifically designed for GitHub Actions, Blacksmith transforms CI/CD pipeline performance. In this post, we’ll explore how Blacksmith works, why traditional caching falls short, and how to integrate it into your workflows for dramatic build time improvements.

The CI/CD Caching Problem

Before diving into Blacksmith, let’s understand why GitHub Actions builds are inherently slow.

Why GitHub Actions Builds Are Slow

GitHub Actions runners are ephemeral—they spin up fresh for each job, execute your workflow, and terminate. This stateless design ensures consistency and security, but creates a significant performance bottleneck: every build starts from scratch.

Consider a typical Docker build workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ❌ Problematic: No persistent cache
name: Build and Deploy
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build Docker image
        run: docker build -t myapp:latest .
      
      - name: Push to registry
        run: docker push myapp:latest

What happens on each run:

  1. Fresh Ubuntu runner provisions (~20-30 seconds)
  2. Docker pulls base images from scratch (1-2 minutes)
  3. All layers rebuild, even unchanged dependencies (2-5 minutes)
  4. Total time: 3-7 minutes per build

For a team with 20 developers pushing 5 times daily, that’s 300-700 minutes of waiting every single day.

Traditional Caching Limitations

GitHub Actions provides built-in caching via actions/cache, but it has significant limitations:

1
2
3
4
5
6
7
# ⚠️ Limited: GitHub Actions cache
- uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: $-buildx-$
    restore-keys: |
      $-buildx-

Limitations:

  • Size restrictions: 10GB cache limit per repository
  • Slow restore: Cache downloads compete with other network operations
  • Eviction policies: Caches older than 7 days are automatically purged
  • Network overhead: Fetching large caches from GitHub’s storage adds latency

For large monorepos or projects with heavy dependencies (Node.js with thousands of packages, large Python environments), these constraints make traditional caching insufficient.

What Is Blacksmith?

Blacksmith is a managed caching infrastructure that sits between your GitHub Actions runners and your build processes. Think of it as a CDN for your build artifacts—intelligently positioned, blazingly fast, and purpose-built for CI/CD workloads.

Core Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────┐
│  GitHub Actions │
│     Runner      │
└────────┬────────┘
         │
         ▼
┌─────────────────┐     ┌──────────────┐
│   Blacksmith    │────▶│ Docker Hub   │
│   Cache Layer   │     │ NPM Registry │
└─────────────────┘     │ RubyGems     │
         │              └──────────────┘
         ▼
┌─────────────────┐
│  Fast SSD/NVMe  │
│  Persistent     │
│  Cache Storage  │
└─────────────────┘

Key Components:

  1. High-speed cache nodes: Geographically distributed for low latency
  2. Smart invalidation: Understands semantic versioning and dependency graphs
  3. Transparent proxying: Works with existing Docker, npm, pip, gem commands
  4. Intelligent warming: Predictively caches based on usage patterns

How Blacksmith Works

1. Layer-Aware Docker Caching

Blacksmith understands Docker’s layer architecture and caches each layer independently with content-addressable storage.

Before Blacksmith:

1
2
3
4
5
6
7
# Every build pulls everything fresh
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install          # ⏱️ 2-3 minutes every time
COPY . .
RUN npm run build        # ⏱️ 1-2 minutes

With Blacksmith:

1
2
3
4
5
6
FROM node:18-alpine      # ✅ Cached: 5 seconds
WORKDIR /app
COPY package*.json ./
RUN npm install          # ✅ Cached: 10 seconds (if package.json unchanged)
COPY . .
RUN npm run build        # ⏱️ Only this runs: 1-2 minutes

Blacksmith caches:

  • Base image layers (node:18-alpine)
  • Dependency installation layers (npm install output)
  • Build artifacts (when layer hasn’t changed)

Result: Build time drops from 4-5 minutes to 1-2 minutes on typical iterations.

2. Package Registry Proxying

Blacksmith acts as a transparent caching proxy for package registries.

1
2
3
4
5
6
# Without Blacksmith: Downloads from public registry
npm install express     # Fetches from npmjs.com

# With Blacksmith: Proxied and cached
npm install express     # First time: fetch + cache
npm install express     # Subsequent: instant from cache

This works for:

  • npm/yarn (Node.js)
  • pip (Python)
  • gem (Ruby)
  • Maven/Gradle (Java)
  • NuGet (.NET)

3. Intelligent Cache Warming

Blacksmith analyzes your repository’s dependency patterns and pre-warms caches before builds even start.

Example workflow:

  1. You push code at 9:00 AM
  2. Blacksmith detects package.json changed
  3. Background process pre-fetches new dependencies
  4. When GitHub Actions runner starts at 9:01 AM, cache is ready
  5. Build begins with zero cold-start penalty

Integrating Blacksmith into GitHub Actions

Step 1: Initial Setup

First, sign up for Blacksmith and obtain your cache endpoint URL and authentication token.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# .github/workflows/build.yml
name: Build with Blacksmith
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Configure Blacksmith
        env:
          BLACKSMITH_TOKEN: $
        run: |
          # Configure Docker to use Blacksmith registry mirror
          mkdir -p ~/.docker
          cat > ~/.docker/daemon.json <<EOF
          {
            "registry-mirrors": ["https://cache.blacksmith.sh"],
            "insecure-registries": ["cache.blacksmith.sh"]
          }
          EOF

Step 2: Docker Build with Blacksmith

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
        with:
          driver-opts: |
            image=moby/buildkit:latest
            network=host
      
      - name: Build with cache
        uses: docker/build-push-action@v4
        with:
          context: .
          push: false
          tags: myapp:latest
          cache-from: type=registry,ref=cache.blacksmith.sh/myapp:buildcache
          cache-to: type=registry,ref=cache.blacksmith.sh/myapp:buildcache,mode=max

Key parameters:

  • cache-from: Pull cache layers from Blacksmith
  • cache-to: Push new cache layers back to Blacksmith
  • mode=max: Cache all layers, not just final image

Step 3: Optimizing Multi-Stage Builds

For complex applications, use multi-stage builds to maximize caching efficiency:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Stage 1: Dependencies (rarely changes)
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Build (changes frequently)
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production (minimal)
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

Cache behavior:

  • deps stage: Cached until package.json changes
  • builder stage: Rebuilds only when source code changes
  • runner stage: Reconstructs from cached stages

Performance impact:

  • Cold build (no cache): 8-10 minutes
  • Warm build (code change only): 2-3 minutes
  • Hot build (no changes): 30-45 seconds

Step 4: NPM/Yarn Caching

Configure npm to use Blacksmith as a registry proxy:

1
2
3
4
5
6
7
8
9
10
      - name: Setup Node.js with Blacksmith
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          registry-url: 'https://npm.blacksmith.sh'
        env:
          NODE_AUTH_TOKEN: $
      
      - name: Install dependencies
        run: npm ci

Before: npm ci takes 90-120 seconds
After: npm ci takes 15-20 seconds on cache hit

Real-World Performance Benchmarks

Case Study: Medium-Sized Node.js Application

Setup:

  • Express.js API with 150 dependencies
  • PostgreSQL integration tests
  • Docker multi-stage build
  • 3 environments (dev, staging, prod)

Build times without Blacksmith:

  • Clean build: 6m 45s
  • Incremental build: 4m 20s
  • Cache hit rate: ~30%

Build times with Blacksmith:

  • Clean build: 2m 10s (68% faster)
  • Incremental build: 1m 15s (71% faster)
  • Cache hit rate: ~85%

Cost impact:

  • GitHub Actions minutes saved: ~75% reduction
  • For 100 builds/week: 300 minutes saved$15-30/month in runner costs
  • Blacksmith cost: $49/month
  • ROI: Positive after ~150 builds/month, plus developer time saved

Case Study: Large Monorepo

Setup:

  • Turborepo with 12 TypeScript packages
  • Shared component library
  • End-to-end Playwright tests
  • Multiple Docker services

Build times without Blacksmith:

  • Full build: 18m 30s
  • Partial rebuild: 8m 45s
  • Test suite: 12m 15s

Build times with Blacksmith:

  • Full build: 5m 20s (71% faster)
  • Partial rebuild: 2m 10s (75% faster)
  • Test suite: 3m 45s (69% faster)

Developer impact:

  • 40 developers × 5 pushes/day = 200 builds/day
  • Time saved per day: 2,640 minutes (44 hours)
  • Annual developer productivity gain: ~$180,000 (at $75/hour blended rate)

Common Pitfalls and Solutions

Pitfall 1: Cache Invalidation Issues

Problem: Stale dependencies cause runtime errors despite successful builds.

1
2
3
# ❌ Bad: Broad COPY invalidates cache unnecessarily
COPY . .
RUN npm install

Solution: Copy dependency manifests first, install, then copy source.

1
2
3
4
# ✅ Good: Precise cache invalidation
COPY package*.json ./
RUN npm ci
COPY . .

Pitfall 2: Ignoring .dockerignore

Problem: Including node_modules, .git, or build artifacts in context invalidates cache.

# .dockerignore
node_modules
.git
.github
dist
*.log
.env

Impact: Context size drops from 500MB to 10MB, improving cache performance.

Pitfall 3: Not Using BuildKit

Problem: Legacy Docker builder has poor layer caching.

1
2
3
4
5
# ✅ Always enable BuildKit
- name: Build with BuildKit
  env:
    DOCKER_BUILDKIT: 1
  run: docker build -t myapp:latest .

Pitfall 4: Overly Aggressive Cache Keys

Problem: Cache never hits because key is too specific.

1
2
3
4
5
# ❌ Bad: SHA changes every commit
cache-key: $

# ✅ Good: Hash of dependency files
cache-key: $

When to Use Blacksmith

✅ Ideal Use Cases

  • High-frequency deployments: Teams pushing 10+ times daily
  • Large dependency trees: Node.js, Python, or Java projects with 100+ packages
  • Docker-heavy workflows: Microservices, containerized applications
  • Monorepos: Multiple packages sharing common dependencies
  • Distributed teams: Consistent cache performance across regions

⚠️ May Not Be Worth It

  • Simple static sites: Jekyll, Hugo builds with minimal dependencies
  • Infrequent builds: Less than 5 builds per week
  • Small projects: Single-file scripts or minimal dependency projects
  • Cost-sensitive hobbyists: Free GitHub Actions tier is sufficient

Advanced: Blacksmith + BuildKit Inline Cache

For ultimate performance, combine Blacksmith with BuildKit’s inline cache export:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- name: Build and push with inline cache
  uses: docker/build-push-action@v4
  with:
    context: .
    push: true
    tags: |
      myregistry/myapp:$
      myregistry/myapp:latest
    cache-from: |
      type=registry,ref=cache.blacksmith.sh/myapp:buildcache
      type=registry,ref=myregistry/myapp:latest
    cache-to: type=inline
    build-args: |
      BUILDKIT_INLINE_CACHE=1

This embeds cache metadata directly in the image, allowing any runner to benefit from previous builds without additional cache storage.

Monitoring and Optimization

Measuring Cache Effectiveness

Add metrics to your workflow:

1
2
3
4
- name: Analyze cache performance
  run: |
    echo "Cache hit rate: $(docker buildx du | grep 'shared' | awk '{print $2}')"
    echo "Total cache size: $(docker buildx du | grep 'Total' | awk '{print $2}')"

Blacksmith Dashboard Metrics

Monitor through Blacksmith’s dashboard:

  • Cache hit rate: Target >80% for optimal performance
  • Bandwidth saved: Track data not downloaded from public registries
  • Build time reduction: Compare against baseline
  • Regional performance: Ensure cache nodes are serving your runners efficiently

Conclusion

Blacksmith transforms GitHub Actions from a bottleneck into a competitive advantage. By intelligently caching Docker layers, proxying package registries, and pre-warming dependencies, it cuts build times by 60-80% for typical workflows. For teams serious about CI/CD performance, the combination of faster feedback loops, reduced runner costs, and improved developer experience makes Blacksmith a compelling investment.

The key insight: CI/CD caching isn’t just about storing files—it’s about understanding dependency semantics, layer relationships, and workflow patterns. Blacksmith’s architecture reflects this understanding, delivering performance gains that generic caching solutions simply can’t match.

Start with your slowest builds. Measure baseline performance. Integrate Blacksmith. Watch your pipeline transform from “let’s grab coffee” to “already done.”

Suggested Reading

This post is licensed under CC BY 4.0 by the author.