GitHub Actions: CI/CD for Containers and Kubernetes
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.
- Workflow Anatomy
- Triggers (on:)
- Contexts and Expressions
- Job Dependencies and Outputs
- Caching Dependencies
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 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
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 testKey concepts:
| Term | Meaning |
|---|---|
| Workflow | A YAML file in .github/workflows/ — triggered by events |
| Job | A group of steps that run on the same runner |
| Step | A single task — either uses (a pre-built action) or run (a shell command) |
| Action | A reusable unit of automation from the GitHub Marketplace |
| Runner | The machine that executes jobs |
Triggers (on:)
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 UTCContexts and Expressions
GitHub Actions provides context objects accessible with ${{ }}:
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.
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
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
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 ciFor other ecosystems, use actions/cache@v4 directly:
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:
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 testMatrix can have multiple dimensions:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: ['20', '22']
# Produces 4 jobs: ubuntu+20, ubuntu+22, macos+20, macos+22Building 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.
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=maxdocker/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
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=120sPass the image digest (immutable) from the build job as an output:
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 }}:latestEnvironment protection rules
GitHub Environments let you add required reviewers, deployment branch restrictions, and secrets scoped to an environment:
- Settings → Environments → New environment (e.g.,
production) - Add required reviewers — the workflow pauses until they approve
- Add environment-scoped secrets (
KUBECONFIG_PRODUCTION)
deploy-production:
needs: deploy-staging
environment: production # Pauses here for required reviewer approval
steps: ...Concurrency Control
Prevent multiple deploys from running simultaneously:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel the in-progress run when a new one startsUse cancel-in-progress: false for production deploys — you want the current deploy to finish, not be cancelled mid-rollout.
A Complete Pipeline
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=300sReusable Workflows
Extract common jobs into a shared workflow callable from other repositories or workflows:
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:
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:
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 productionThis 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
- Jenkins Declarative Pipelines — self-hosted CI with more control over the runner environment
- Semantic Versioning and Conventional Commits — the commit convention that drives automated releases with Release Please
- Supply Chain Security: Sigstore and SLSA — sign the images this pipeline builds
Official References
- GitHub Actions documentation — workflow syntax, runners and reusable workflows
- Dockerfile best practices — layer caching, image size and build ordering
- Dockerfile reference — every instruction and its semantics
Next in CI/CD Pipelines
Jenkins Declarative Pipelines: CI/CD with Docker Agents
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.