Modern CI/CD Pipelines with GitHub Actions and Docker: A Complete Implementation Guide

Learn how to build production-grade CI/CD pipelines using GitHub Actions and Docker. From multi-stage builds to zero-downtime rollouts, this guide covers everything you need to ship faster.

DevOps⚙
CI/CDDockerGitHub ActionsDevOps

Modern CI/CD Pipelines with GitHub Actions and Docker: A Complete Implementation Guide

Is your company ready for AI? Download our free checklist →

Download checklist

Introduction: Why CI/CD Still Matters in 2026

Software teams ship code faster than ever before. According to the DORA 2024 State of DevOps Report, elite performers deploy 973 times more frequently than low performers and recover from incidents 6,576 times faster. The common thread? Mature, automated CI/CD pipelines.

While the tooling landscape has exploded (GitLab CI, Jenkins, CircleCI, Buildkite, Drone), GitHub Actions combined with Docker has become the de facto standard for teams already living in the GitHub ecosystem. With over 518 million developers on GitHub and native Docker support baked into runners, you can go from commit to production in minutes.

In this guide, we'll build a production-grade pipeline from scratch: optimized Docker images, security scanning, automated tests, multi-environment deployments, and zero-downtime rollouts.

The Building Blocks: CI, CD, and Containers

Before diving into implementation, let's clarify the fundamentals:

  • Continuous Integration (CI): Every code change is automatically built and tested. Failures surface within minutes, not days.
  • Continuous Delivery (CD): Every change that passes CI is automatically packaged and ready for production deployment.
  • Continuous Deployment: A superset where every passing change is automatically deployed to production.
  • Containers: Lightweight, portable runtime units that package an application with its dependencies, ensuring consistency across environments.

Docker solves the classic "it works on my machine" problem. When you combine Docker's reproducibility with GitHub Actions' event-driven automation, you get pipelines that are both fast and trustworthy.

Why GitHub Actions and Docker Are a Perfect Match

GitHub Actions has quietly become one of the most popular CI/CD platforms. Here's why:

  1. Zero infrastructure to manage — hosted runners are pre-installed with Docker, Node, Python, Go, and dozens of other tools.
  2. First-class Docker support — the docker/build-push-action and Docker layer caching reduce build times by up to 90%.
  3. Tight GitHub integration — pull requests, issues, releases, and branch protections all trigger workflows natively.
  4. Massive marketplace — 20,000+ pre-built actions, including official Docker, AWS, Azure, GCP, Kubernetes, and security scanning tools.
  5. Generous free tier — 2,000 minutes/month for private repos on the free plan.

For Docker specifically, GitHub-hosted runners come with the Docker daemon pre-configured, supporting BuildKit, multi-platform builds (linux/amd64, linux/arm64), and rootless builds.

Project Setup: A Real-World Example

Let's build a Node.js microservice with a complete pipeline. Start by initializing the project:

mkdir my-service && cd my-service
git init
npm init -y
npm install express

Create a simple Express application:

// src/index.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

app.get('/api/hello/:name', (req, res) => {
  res.json({ message: `Hello, ${req.params.name}!` });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Writing an Optimized Dockerfile

A modern Dockerfile uses multi-stage builds to keep production images small and secure. Here's a production-ready example:

# syntax=docker/dockerfile:1.7

# ---------- Stage 1: Dependencies ----------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

# ---------- Stage 2: Build ----------
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

# ---------- Stage 3: Production ----------
FROM node:20-alpine AS production
WORKDIR /app

# Security: run as non-root
RUN addgroup -g 1001 -S nodejs && \
    adduser -S appuser -u 1001

COPY --from=deps --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=appuser:nodejs /app/dist ./dist
COPY --from=build --chown=appuser:nodejs /app/package.json ./

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "dist/index.js"]

This produces an image that is typically less than 150 MB (versus 1+ GB for a naive build), runs as a non-root user, and includes a healthcheck.

Dockerignore Matters

A .dockerignore file is essential for keeping build context small and avoiding secret leaks:

.git
.github
node_modules
dist
coverage
.env*
*.md
.DS_Store

This can shrink your build context from hundreds of MB to a few KB, dramatically improving build speed.

Building the GitHub Actions Pipeline

Now for the main event. Create .github/workflows/ci-cd.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ---------- Lint & Test ----------
  test:
    name: Lint and Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info

  # ---------- Build & Push ----------
  build:
    name: Build and Push Docker Image
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

  # ---------- Deploy to Staging ----------
  deploy-staging:
    name: Deploy to Staging
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Kubernetes
        run: |
          echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > kubeconfig
          KUBECONFIG=kubeconfig kubectl set image deployment/my-service \
            my-service=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
            --namespace=staging
          KUBECONFIG=kubeconfig kubectl rollout status deployment/my-service -n staging

  # ---------- Deploy to Production ----------
  deploy-production:
    name: Deploy to Production
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4

      - name: Deploy with blue-green strategy
        run: |
          echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > kubeconfig
          KUBECONFIG=kubeconfig ./scripts/blue-green-deploy.sh \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

Caching Strategies That Actually Save Time

Docker layer caching is the single biggest performance win. The cache-from: type=gha option leverages GitHub Actions Cache, which is free for public repos and included for private repos on most plans.

For more aggressive caching, consider using registry cache with a dedicated cache image:

Want a personalized diagnostic? Complete our free checklist →

Download checklist
- name: Build and push
  uses: docker/buildx-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/myorg/my-service:cache
    cache-to: type=registry,ref=ghcr.io/myorg/my-service:cache,mode=max

Benchmarks from real projects show:

StrategyFirst BuildSubsequent Builds
No cache4m 12s4m 08s
GHA cache4m 20s0m 47s
Registry cache4m 35s0m 31s

That's an 88% reduction in build time.

Security: Don't Ship Vulnerabilities

A pipeline without security scanning is a liability. Add these critical steps:

1. Image Vulnerability Scanning

- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'

- name: Upload Trivy scan results
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: 'trivy-results.sarif'

2. Secret Scanning

- name: Scan for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.repository.default_branch }}
    head: HEAD

3. SBOM Generation

Software Bill of Materials is becoming a compliance requirement. Generate one automatically:

- name: Generate SBOM
  uses: anchore/sbom-action@v0
  with:
    image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
    format: spdx-json
    artifact-name: sbom.spdx.json

4. Image Signing with Cosign

Sign images for supply chain integrity:

- name: Install Cosign
  uses: sigstore/cosign-installer@v3

- name: Sign image
  env:
    COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
    COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
  run: |
    cosign sign --key env://COSIGN_KEY \
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

Testing in the Pipeline

A robust pipeline runs multiple test layers:

test-integration:
  name: Integration Tests
  runs-on: ubuntu-latest
  services:
    postgres:
      image: postgres:16-alpine
      env:
        POSTGRES_PASSWORD: testpass
      ports:
        - 5432:5432
      options: >-
        --health-cmd pg_isready
        --health-interval 10s
        --health-timeout 5s
        --health-retries 5

    redis:
      image: redis:7-alpine
      ports:
        - 6379:6379

  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'

    - run: npm ci
    - run: npm run test:integration
      env:
        DATABASE_URL: postgres://postgres:testpass@localhost:5432/test
        REDIS_URL: redis://localhost:6379

GitHub Actions services spin up companion containers automatically — perfect for integration tests that need databases, caches, or message brokers.

Deployment Strategies

Blue-Green Deployments

Run two identical production environments, switch traffic atomically:

#!/bin/bash
# scripts/blue-green-deploy.sh
set -euo pipefail

NEW_IMAGE=$1
CURRENT=$(kubectl get service my-service -o jsonpath='{.spec.selector.version}')
TARGET=$([ "$CURRENT" = "blue" ] && echo "green" || echo "blue")

echo "Deploying to $TARGET environment"
kubectl set image deployment/my-service-$TARGET \
  my-service=$NEW_IMAGE --record

kubectl rollout status deployment/my-service-$TARGET --timeout=5m

# Run smoke tests
./scripts/smoke-tests.sh http://my-service-$TARGET.staging.svc.cluster.local

echo "Switching traffic to $TARGET"
kubectl patch service my-service -p '{"spec":{"selector":{"version":"'"$TARGET"'"}}}'

Canary Releases with Flagger

For more advanced rollouts, use Flagger to automatically shift traffic based on metrics:

apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: my-service
spec:
  provider: kubernetes
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-service
  progressDeadlineSeconds: 60
  metrics:
    - name: request-success-rate
      thresholdRange:
        min: 99
      interval: 30s
    - name: request-duration
      thresholdRange:
        max: 500
      interval: 30s
  analysis:
    interval: 30s
    threshold: 5
    maxWeight: 50
    stepWeight: 5

Reusable Workflows: DRY for Your Pipelines

If you maintain multiple services, stop copy-pasting YAML. Create .github/workflows/reusable-build.yml:

name: Reusable Build and Deploy

on:
  workflow_call:
    inputs:
      service_name:
        required: true
        type: string
      environment:
        required: true
        type: string
        default: staging
    secrets:
      KUBECONFIG:
        required: true

jobs:
  build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      service_name: ${{ inputs.service_name }}

Then any service can reference it:

jobs:
  pipeline:
    uses: myorg/.github/.github/workflows/reusable-build.yml@main
    with:
      service_name: my-service
    secrets:
      KUBECONFIG: ${{ secrets.KUBECONFIG }}

Common Pitfalls to Avoid

  1. Not pinning action versions — Always use full SHA or version tags, never @master. Pinning reduces supply chain risk.
  2. Using latest tags in production — latest is mutable and breaks reproducibility. Use commit SHAs or semver.
  3. Storing secrets in workflow files — Use GitHub Secrets or an external vault. Never echo them in logs.
  4. Skipping healthchecks — Without them, Kubernetes can't tell a healthy pod from a broken one.
  5. Building on every push without context — Use path filters to skip irrelevant changes:

```yaml
on:
push:
paths:

  • 'src/**'
  • 'Dockerfile'
  • '.github/workflows/**'

```

  1. Ignoring build context size — A bloated build context (e.g., accidentally including node_modules) can push builds from seconds to minutes.

Monitoring Your Pipelines

A pipeline you don't monitor is a pipeline that quietly breaks. Add observability with:

  • GitHub's native analytics — track success rates, duration trends, and flaky tests.
  • OpenTelemetry exporters — emit workflow metrics to Datadog, Honeycomb, or Grafana Cloud.
  • Slack/Teams notifications — instant alerts on failures using slackapi/slack-github-action.
  • Pipeline dashboards — visualize MTTR, deployment frequency, and change failure rate.

Conclusion: Ship Faster, Sleep Better

A well-designed CI/CD pipeline with GitHub Actions and Docker isn't just about automation — it's about confidence. When every commit is built, tested, scanned, and deployed through the same proven process, your team can ship multiple times a day without breaking a sweat.

Start small: get tests running on every PR, then add Docker builds, then security scanning, then advanced deployments. Each step compounds. Within a few months, you'll be operating at elite DORA performance levels.

Ready to modernize your delivery pipeline? Tanok Tech specializes in designing custom CI/CD architectures for teams that need to scale. Whether you're migrating from Jenkins, optimizing slow Docker builds, or implementing zero-downtime deployments for Kubernetes, our engineers can help. Contact Tanok Tech today for a free consultation.

Ready for the next step? Evaluate your company with our free checklist →

Download checklist

Related posts