For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/docker/beginners-guide/11-docker-in-ci-cd.md.

Learn how to build and push Docker images in GitHub Actions, cache layers for faster CI builds, and deploy by pulling and restarting containers.

Docker in CI/CD

Building images on your laptop is only the first step. In a team environment, images should be built, tested, scanned, and pushed automatically on every commit - and deployed automatically on merges to the main branch. This is the core of a Docker-based CI/CD pipeline.

This chapter builds a complete GitHub Actions workflow that takes your code from a git push to a running container in production.


Why Build in CI?

ConcernLocal buildCI build
ReproducibilityDepends on developer's machine stateClean environment every time
Security scanningEasy to forgetEnforced in the pipeline
Registry credentialsOn developer laptops (risk)In CI secrets vault
Trigger-basedManualAutomatic on push/PR/tag
Parallel buildsLimited by laptopScalable

CI gives you a single, authoritative build process that every team member and every deployment can trust.


GitHub Actions Fundamentals

A GitHub Actions workflow is a YAML file in .github/workflows/. It defines:

  • Triggers (on:) - Which events start the workflow (push, pull_request, release, schedule)
  • Jobs - Parallel or sequential groups of steps
  • Steps - Individual shell commands or actions (reusable workflow units)
  • Runners - The VMs that execute jobs (ubuntu-latest, macos-latest, Windows)
name: CI

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t my-app .

Core Actions for Docker

ActionPurpose
docker/setup-buildx-actionEnables BuildKit and multi-platform builds
docker/login-actionAuthenticates to a registry
docker/metadata-actionGenerates tags and labels from git metadata
docker/build-push-actionBuilds and optionally pushes an image

These are the official Docker GitHub Actions, maintained by the Docker team.


A Complete Build-Test-Push Workflow

This workflow runs on every push to main and every pull request. It builds the image, runs tests, scans for vulnerabilities, and pushes to GHCR on merges to main.

# .github/workflows/docker.yml
name: Docker Build and Push

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}   # e.g. myorg/my-app

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write         # needed to push to GHCR
      security-events: write  # needed to upload SARIF scan results

    steps:
      # 1. Check out the code
      - name: Checkout repository
        uses: actions/checkout@v4

      # 2. Set up Docker Buildx (enables BuildKit and layer caching)
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 3. Authenticate to GHCR
      #    Only runs on push (not pull_request from forks, for security)
      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 4. Generate image tags and labels from git context
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # Tag with semver on git tag pushes: v1.2.3 → 1.2.3, 1.2, 1, latest
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            # Tag with full commit SHA on every push (matches ${{ github.sha }})
            type=sha,prefix=sha-,format=long
            # Tag branch name on branch pushes
            type=ref,event=branch
            # Tag PR number on pull requests
            type=ref,event=pr

      # 5. Build (and push on main/tag)
      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # Cache layers in the GitHub Actions cache
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # Build for multiple platforms
          platforms: linux/amd64,linux/arm64

      # 6. Scan the built image for CVEs
      - name: Run Docker Scout scan
        if: github.event_name != 'pull_request'
        uses: docker/scout-action@v1
        with:
          command: cves
          image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          only-severities: critical,high
          exit-code: true   # Fail the job if critical/high CVEs are found

Understanding Layer Caching in CI

Without caching, every CI run reinstalls dependencies from scratch. With caching, the dependency-install layer is reused until package.json changes - turning a 4-minute build into a 30-second one.

GitHub Actions cache (type=gha)

The cache-from: type=gha / cache-to: type=gha lines in build-push-action store BuildKit's layer cache in GitHub's cache service (up to 10 GB per repository, evicted after 7 days of inactivity).

This is the simplest caching strategy for GitHub Actions and works well for most projects.

Registry cache (type=registry)

Alternatively, store the cache in the same container registry:

- uses: docker/build-push-action@v6
  with:
    cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache
    cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max

This is useful for self-hosted runners that cannot use GitHub's cache service.

Maximising cache hit rates

For good caching, your Dockerfile must be ordered correctly (see Chapter 3). The mode=max option on cache-to exports all layers (not just the final stage), giving you the best chance of cache hits on subsequent builds.


Running Tests in CI

Run tests inside the Docker build itself using a tester stage (from Chapter 7), or run them as a separate step before building:

Option A: Tests inside the Docker build

# In your Dockerfile
FROM deps AS tester
COPY tests/ ./tests/
RUN npm test
# In GitHub Actions
- name: Build (runs tests as part of the build)
  uses: docker/build-push-action@v6
  with:
    target: tester   # build only up to the tester stage
    push: false

Option B: Tests as a separate CI step

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          docker build --target tester -t my-app:test .
          docker run --rm my-app:test

  build-push:
    needs: test   # only runs if test job passes
    runs-on: ubuntu-latest
    steps:
      # ... build and push steps

Deploying After Push

The simplest deployment strategy for a single server is to SSH in and docker pull + restart the container. This is not suitable for zero-downtime deployments (use Kubernetes or Docker Swarm for that), but it is perfectly adequate for many production workloads.

Deployment via SSH

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to production server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            # Pull the new image
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io \
              -u ${{ github.actor }} --password-stdin

            docker pull ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

            # Tag it as the current active version
            docker tag \
              ghcr.io/${{ github.repository }}:sha-${{ github.sha }} \
              ghcr.io/${{ github.repository }}:current

            # Restart the service with the new image
            docker compose -f /opt/my-app/docker-compose.yml pull
            docker compose -f /opt/my-app/docker-compose.yml up -d --no-deps api

            # Clean up old images
            docker image prune -f

Using GitHub Environments for protection

Note the environment: production in the job above. In your GitHub repo settings, you can configure environments with:

  • Required reviewers - A human must approve before the job runs
  • Deployment branches - Only main can deploy to production
  • Wait timer - Add a delay before deployment proceeds

This gives you a lightweight approval gate without a separate deployment tool.


A Minimal End-to-End Workflow

Here is a simplified workflow you can copy-paste and adapt:

# .github/workflows/ci.yml
name: CI/CD

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

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        if: github.event_name == 'push'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy
        if: github.event_name == 'push'
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker stop my-app || true
            docker rm my-app || true
            docker run -d \
              --name my-app \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /opt/my-app/.env \
              ghcr.io/${{ github.repository }}:${{ github.sha }}

CI/CD Best Practices

  1. Never use latest as the deploy tag - Always deploy by the exact image SHA or semver tag.
  2. Scan before deploying - Block the pipeline if critical CVEs are found.
  3. Pin action versions - Use @v4 not @main to avoid supply-chain attacks.
  4. Store credentials in GitHub Secrets - Never hardcode registry credentials in YAML.
  5. Use environments with protection rules - Require approval for production deployments.
  6. Keep build and deploy jobs separate - Build once, deploy the same image to staging and then production.
  7. Clean up old images - Add docker image prune to your deployment script.
  8. Test rollback - Have a documented and tested procedure for reverting to the previous image tag.