Part ofCI/CD Pipelines·Step 2 of 3
DevOps & Platform

GitHub Actions: CI/CD for Containers and Kubernetes

Intermediate65 min to complete20 min readJune 1, 2026Updated August 19, 2026

Quick answer

Build production-grade CI/CD with GitHub Actions — test on every PR, build and push Docker images to GHCR, deploy to Kubernetes on merge, and automate releases with conventional commits. Includes matrix builds, caching, reusable workflows, and environment protection gates.

intermediate · 65 min

Before you begin

  • Git and GitHub basics — branches, pull requests, repository settings
  • Docker fundamentals — building and pushing images
  • Kubernetes basics — kubectl, Deployments (for the deploy section)
GitHub Actions
CI/CD
Docker
Kubernetes
Automation
DevOps

GitHub Actions: CI/CD for Containers and Kubernetes

GitHub Actions runs CI/CD workflows directly in your repository — no separate CI server to manage. Every push, pull request, or tag triggers workflows defined as YAML files in .github/workflows/. Runners execute the jobs: GitHub-hosted (Ubuntu, macOS, Windows) or self-hosted.

This tutorial builds up a full pipeline: lint and test on every PR, build and push a Docker image on merge to main, deploy to Kubernetes, and cut versioned releases automatically.


Workflow Anatomy

yaml
1# .github/workflows/ci.yml
2name: CI                        # Display name in the GitHub UI
3
4on:                             # Triggers
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10jobs:
11  test:                         # Job ID
12    runs-on: ubuntu-latest      # Runner
13
14    steps:
15      - name: Checkout code
16        uses: actions/checkout@v4
17
18      - name: Set up Node.js
19        uses: actions/setup-node@v4
20        with:
21          node-version: '20'
22
23      - name: Install dependencies
24        run: npm ci
25
26      - name: Run tests
27        run: npm test

Key concepts:

TermMeaning
WorkflowA YAML file in .github/workflows/ — triggered by events
JobA group of steps that run on the same runner
StepA single task — either uses (a pre-built action) or run (a shell command)
ActionA reusable unit of automation from the GitHub Marketplace
RunnerThe machine that executes jobs

Triggers (on:)

yaml
1on:
2  # Push to specific branches
3  push:
4    branches: [main, 'release/**']
5    paths:
6      - 'src/**'           # Only trigger if these paths changed
7      - 'package.json'
8
9  # Pull requests targeting main
10  pull_request:
11    branches: [main]
12    types: [opened, synchronize, reopened]
13
14  # Manual trigger with optional inputs
15  workflow_dispatch:
16    inputs:
17      environment:
18        description: 'Target environment'
19        required: true
20        default: 'staging'
21        type: choice
22        options: [staging, production]
23
24  # On release published (use with Release Please)
25  release:
26    types: [published]
27
28  # Scheduled (cron)
29  schedule:
30    - cron: '0 2 * * 1'   # Every Monday at 02:00 UTC

Contexts and Expressions

GitHub Actions provides context objects accessible with ${{ }}:

yaml
1steps:
2  - run: |
3      echo "Repo:    ${{ github.repository }}"        # owner/repo
4      echo "Branch:  ${{ github.ref_name }}"          # main
5      echo "SHA:     ${{ github.sha }}"               # full 40-char commit SHA
6      echo "Short:   ${GITHUB_SHA::7}"               # first 7 chars via bash substring
7      echo "Actor:   ${{ github.actor }}"             # who triggered the run
8      echo "Event:   ${{ github.event_name }}"        # push / pull_request / etc.
9      echo "Run ID:  ${{ github.run_id }}"
10      echo "Run #:   ${{ github.run_number }}"

Secrets and variables

Secrets are set in Settings → Secrets and variables → Actions and referenced as ${{ secrets.NAME }}. They are masked in logs.

yaml
1env:
2  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
3
4steps:
5  - run: npm publish
6    env:
7      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Repository variables (non-secret config values) are accessed with ${{ vars.NAME }}.


Job Dependencies and Outputs

yaml
1jobs:
2  test:
3    runs-on: ubuntu-latest
4    outputs:
5      version: ${{ steps.get-version.outputs.version }}
6    steps:
7      - uses: actions/checkout@v4
8
9      - name: Get version from package.json
10        id: get-version
11        run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
12
13  build:
14    needs: test               # Only runs if test job succeeds
15    runs-on: ubuntu-latest
16    steps:
17      - run: echo "Building version ${{ needs.test.outputs.version }}"

$GITHUB_OUTPUT is the file-based mechanism for passing values between steps and jobs. Use echo "key=value" >> $GITHUB_OUTPUT to set an output.


Caching Dependencies

yaml
1- name: Set up Node.js with cache
2  uses: actions/setup-node@v4
3  with:
4    node-version: '20'
5    cache: 'npm'              # Automatically caches ~/.npm based on package-lock.json hash
6
7- run: npm ci

For other ecosystems, use actions/cache@v4 directly:

yaml
1- name: Cache pip packages
2  uses: actions/cache@v4
3  with:
4    path: ~/.cache/pip
5    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
6    restore-keys: |
7      ${{ runner.os }}-pip-

Cache is keyed by hash — if package-lock.json changes, a new cache is created. The restore-keys fall back to the most recent cache with a matching prefix.


Matrix Builds

Test across multiple versions or configurations in parallel:

yaml
1jobs:
2  test:
3    runs-on: ubuntu-latest
4    strategy:
5      matrix:
6        node-version: ['18', '20', '22']
7      fail-fast: false        # Don't cancel other matrix jobs if one fails
8
9    steps:
10      - uses: actions/checkout@v4
11
12      - name: Set up Node ${{ matrix.node-version }}
13        uses: actions/setup-node@v4
14        with:
15          node-version: ${{ matrix.node-version }}
16          cache: 'npm'
17
18      - run: npm ci
19      - run: npm test

Matrix can have multiple dimensions:

yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node-version: ['20', '22']
    # Produces 4 jobs: ubuntu+20, ubuntu+22, macos+20, macos+22

Building and Pushing Docker Images to GHCR

GitHub Container Registry (ghcr.io) is the natural registry for GitHub Actions — authenticated with GITHUB_TOKEN, no separate credentials needed.

yaml
1# .github/workflows/docker.yml
2name: Build and Push
3
4on:
5  push:
6    branches: [main]
7
8permissions:
9  contents: read
10  packages: write         # Required to push to GHCR
11
12jobs:
13  build:
14    runs-on: ubuntu-latest
15
16    steps:
17      - uses: actions/checkout@v4
18
19      - name: Log in to GHCR
20        uses: docker/login-action@v3
21        with:
22          registry: ghcr.io
23          username: ${{ github.actor }}
24          password: ${{ secrets.GITHUB_TOKEN }}
25
26      - name: Extract metadata
27        id: meta
28        uses: docker/metadata-action@v5
29        with:
30          images: ghcr.io/${{ github.repository }}
31          tags: |
32            type=sha,prefix=,format=short          # abc1234
33            type=ref,event=branch                  # main
34            type=semver,pattern={{version}}        # 1.2.3 (on tags)
35            type=semver,pattern={{major}}.{{minor}} # 1.2 (on tags)
36
37      - name: Build and push
38        id: build
39        uses: docker/build-push-action@v5
40        with:
41          context: .
42          push: true
43          tags: ${{ steps.meta.outputs.tags }}
44          labels: ${{ steps.meta.outputs.labels }}
45          cache-from: type=gha          # GitHub Actions cache for layers
46          cache-to: type=gha,mode=max

docker/metadata-action generates consistent image tags based on the git event. On a push to main with commit abc1234, it produces ghcr.io/owner/repo:main and ghcr.io/owner/repo:abc1234.


Deploying to Kubernetes

yaml
1  deploy:
2    needs: build
3    runs-on: ubuntu-latest
4    environment: staging        # Links to a GitHub Environment (optional protection rules)
5
6    steps:
7      - name: Configure kubeconfig
8        run: |
9          mkdir -p ~/.kube
10          echo "${{ secrets.KUBECONFIG_STAGING }}" > ~/.kube/config
11
12      - name: Deploy
13        run: |
14          IMAGE=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
15          kubectl set image deployment/my-api my-api=${IMAGE} -n staging
16          kubectl rollout status deployment/my-api -n staging --timeout=120s

Pass the image digest (immutable) from the build job as an output:

yaml
1  build:
2    outputs:
3      digest: ${{ steps.build.outputs.digest }}
4    steps:
5      - name: Build and push
6        id: build
7        uses: docker/build-push-action@v5
8        with:
9          push: true
10          tags: ghcr.io/${{ github.repository }}:latest

Environment protection rules

GitHub Environments let you add required reviewers, deployment branch restrictions, and secrets scoped to an environment:

  1. Settings → Environments → New environment (e.g., production)
  2. Add required reviewers — the workflow pauses until they approve
  3. Add environment-scoped secrets (KUBECONFIG_PRODUCTION)
yaml
  deploy-production:
    needs: deploy-staging
    environment: production     # Pauses here for required reviewer approval
    steps: ...

Concurrency Control

Prevent multiple deploys from running simultaneously:

yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true    # Cancel the in-progress run when a new one starts

Use cancel-in-progress: false for production deploys — you want the current deploy to finish, not be cancelled mid-rollout.


A Complete Pipeline

yaml
1# .github/workflows/pipeline.yml
2name: Pipeline
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10permissions:
11  contents: read
12  packages: write
13  pull-requests: write    # Allows commenting on PRs
14
15concurrency:
16  group: ${{ github.workflow }}-${{ github.ref }}
17  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}   # Cancel PRs, not main
18
19jobs:
20  test:
21    runs-on: ubuntu-latest
22    steps:
23      - uses: actions/checkout@v4
24
25      - uses: actions/setup-node@v4
26        with:
27          node-version: '20'
28          cache: 'npm'
29
30      - run: npm ci
31      - run: npm run lint
32      - run: npm test -- --coverage
33
34      - name: Upload coverage
35        uses: actions/upload-artifact@v4
36        with:
37          name: coverage
38          path: coverage/
39
40  build:
41    needs: test
42    runs-on: ubuntu-latest
43    if: github.ref == 'refs/heads/main'     # Only build on main, not PRs
44    outputs:
45      digest: ${{ steps.build.outputs.digest }}
46      image: ghcr.io/${{ github.repository }}
47
48    steps:
49      - uses: actions/checkout@v4
50
51      - uses: docker/login-action@v3
52        with:
53          registry: ghcr.io
54          username: ${{ github.actor }}
55          password: ${{ secrets.GITHUB_TOKEN }}
56
57      - id: meta
58        uses: docker/metadata-action@v5
59        with:
60          images: ghcr.io/${{ github.repository }}
61          tags: type=sha,format=short
62
63      - name: Build and push
64        id: build
65        uses: docker/build-push-action@v5
66        with:
67          context: .
68          push: true
69          tags: ${{ steps.meta.outputs.tags }}
70          cache-from: type=gha
71          cache-to: type=gha,mode=max
72
73  deploy-staging:
74    needs: build
75    runs-on: ubuntu-latest
76    environment: staging
77
78    steps:
79      - name: Deploy to staging
80        run: |
81          mkdir -p ~/.kube
82          echo "${{ secrets.KUBECONFIG_STAGING }}" > ~/.kube/config
83          kubectl set image deployment/my-api \
84            my-api=${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }} \
85            -n staging
86          kubectl rollout status deployment/my-api -n staging --timeout=120s
87
88  deploy-production:
89    needs: deploy-staging
90    runs-on: ubuntu-latest
91    environment: production     # Requires manual approval
92
93    steps:
94      - name: Deploy to production
95        run: |
96          mkdir -p ~/.kube
97          echo "${{ secrets.KUBECONFIG_PRODUCTION }}" > ~/.kube/config
98          kubectl set image deployment/my-api \
99            my-api=${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }} \
100            -n production
101          kubectl rollout status deployment/my-api -n production --timeout=300s

Reusable Workflows

Extract common jobs into a shared workflow callable from other repositories or workflows:

yaml
1# .github/workflows/deploy.yml (reusable)
2on:
3  workflow_call:
4    inputs:
5      image:
6        required: true
7        type: string
8      namespace:
9        required: true
10        type: string
11      timeout:
12        required: false
13        type: string
14        default: '120s'
15    secrets:
16      kubeconfig:
17        required: true
18
19jobs:
20  deploy:
21    runs-on: ubuntu-latest
22    steps:
23      - name: Deploy
24        run: |
25          mkdir -p ~/.kube
26          echo "${{ secrets.kubeconfig }}" > ~/.kube/config
27          kubectl set image deployment/my-api my-api=${{ inputs.image }} -n ${{ inputs.namespace }}
28          kubectl rollout status deployment/my-api -n ${{ inputs.namespace }} --timeout=${{ inputs.timeout }}

Call it from another workflow:

yaml
1  deploy-staging:
2    needs: build
3    uses: ./.github/workflows/deploy.yml     # Same repo
4    with:
5      image: ghcr.io/myorg/my-api@${{ needs.build.outputs.digest }}
6      namespace: staging
7    secrets:
8      kubeconfig: ${{ secrets.KUBECONFIG_STAGING }}

Triggering Deploys from Releases (Release Please Integration)

When using Release Please for automated versioning, deploy on the release published event:

yaml
1# .github/workflows/release-deploy.yml
2name: Release Deploy
3
4on:
5  release:
6    types: [published]        # Release Please creates this when release PR is merged
7
8permissions:
9  contents: read
10  packages: write
11
12jobs:
13  deploy:
14    runs-on: ubuntu-latest
15    environment: production
16
17    steps:
18      - uses: actions/checkout@v4
19
20      - uses: docker/login-action@v3
21        with:
22          registry: ghcr.io
23          username: ${{ github.actor }}
24          password: ${{ secrets.GITHUB_TOKEN }}
25
26      - name: Build and push release image
27        uses: docker/build-push-action@v5
28        with:
29          push: true
30          tags: |
31            ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }}
32            ghcr.io/${{ github.repository }}:latest
33
34      - name: Deploy
35        run: |
36          mkdir -p ~/.kube
37          echo "${{ secrets.KUBECONFIG_PRODUCTION }}" > ~/.kube/config
38          kubectl set image deployment/my-api \
39            my-api=ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} \
40            -n production

This is the end-to-end flow: developer merges a PR with feat: commits → Release Please opens a release PR → team merges the release PR → GitHub release is created → this workflow triggers → versioned image is built and deployed.


Frequently Asked Questions

How should workflows authenticate to a cloud provider?

With OIDC, federating to a role rather than storing an access key. A stored key is long-lived, works from anywhere, and is exposed to every workflow that can read the secret. OIDC issues a short-lived token per run scoped to the repository and often the branch, with nothing stored to leak.

Should actions be pinned by tag or digest?

By commit digest. Tags are mutable, so an action you reviewed can change under the tag you trust — and it runs with your workflow's permissions and secrets. Pinning by digest makes the version you audited the version that runs, and Dependabot can move the pins forward.

Why is my cache not being used?

Cache keys are exact, so a key including a lockfile hash misses whenever dependencies change — which is correct. A permanent miss usually means the key includes something that varies every run, such as the commit SHA. Use restore-keys for a partial match fallback.

What is the difference between pull_request and pull_request_target?

pull_request runs the workflow from the merge commit with a restricted token and no access to secrets for forks. pull_request_target runs the base branch's workflow with full secrets, which is dangerous if it checks out and executes the fork's code. If you are unsure which you need, you need pull_request.

What's Next

Official References

Next in CI/CD Pipelines

Jenkins Declarative Pipelines: CI/CD with Docker Agents

Continue

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.