GitHub Actions CI/CD Pipeline to Kubernetes — From Code Push to Live Deployment

Tisighe Livinstone

Tisighe Livinstone

14 November 2024·10 min read
GitHub Actions CI/CD Pipeline to Kubernetes — From Code Push to Live Deployment

I used to deploy applications by running a shell script from my laptop. The process was straightforward: build the Docker image, push it to Amazon ECR, then update the Kubernetes deployment with kubectl set image. It worked, but it relied on manual steps, offered little visibility into what had been deployed, and made every release dependent on the person running the commands.

Moving the deployment pipeline into GitHub Actions solved those problems. Every merge to main now runs the same sequence automatically—testing the application, building the image, scanning it for vulnerabilities, pushing it to ECR, and deploying it to Kubernetes. Every deployment is repeatable, traceable, and recorded in GitHub.

The goal isn't simply to automate deployments. It's to make every deployment repeatable, auditable, and safe. If something breaks, you should know exactly which commit introduced the change and be able to roll back in minutes — not scramble through terminal history trying to figure out what was deployed last.

GitHub Actions to EKS pipeline — developer pushes code through tests, build, Trivy scan, ECR push, and deployment to EKS

Prerequisites

  • AWS CLI v2 configured

  • An ECR repository to push images to

  • A Kubernetes cluster with kubectl configured

  • A GitHub repository with your application code

aws ecr create-repository --repository-name my-app --region eu-west-1

Why GitHub Actions Over CodeBuild?

CodeBuild is already handling CI in this setup — it builds the image and pushes to ECR. So why GitHub Actions? CodeBuild lives entirely inside AWS. That's great for security but means the pipeline is invisible unless you're in the AWS Console. GitHub Actions lives where the code lives. The workflow file is in the repo, the run history is in GitHub, and any developer can see what deployed and when without AWS access. For teams that don't want to give every engineer AWS Console access, that visibility matters.

Jenkins solves the same problem but requires a server to maintain, plugins to keep updated, and credentials to manage separately. For a Kubernetes deployment pipeline, that operational overhead rarely justifies itself when GitHub Actions is already available where your code lives.

Why ECR over Docker Hub? ECR lives inside your AWS account. Images never leave your network boundary, access is controlled by IAM, and you do not pay egress fees when EKS nodes pull from ECR in the same region. Docker Hub is fine for public images. For production workloads, keep images in ECR.

Step 1: Authenticate to AWS (OIDC, Not Access Keys)

There are two ways to authenticate GitHub Actions with AWS. One is correct, one is legacy.

Option A — OIDC (Recommended)

OIDC lets GitHub Actions assume an IAM role directly using a temporary token. No long-lived credentials stored anywhere. Nothing to rotate. Nothing to leak.

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-deploy
    aws-region: eu-west-1

First create the OIDC provider in AWS (once per account):

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Then create an IAM role with this trust policy:

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:YOUR_USERNAME@YOUR_USER_ID/YOUR_REPO@YOUR_REPO_ID:*"
    }
  }
}

The immutable sub claim. GitHub's sub claim now uses a format that includes numeric user and repository IDs — not just the name. The format is repo:USERNAME@USER_ID/REPO@REPO_ID:*. If your trust policy uses the simple name format, the assume-role call fails with a generic "Not authorized" error. To find your exact sub value, add a temporary step to decode the JWT mid-pipeline:

- name: Debug OIDC token
  run: |
    TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
    echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

The sub field in the output is the exact value your trust policy needs to match.

The IAM role also needs these permissions attached:

aws iam attach-role-policy \
  --role-name github-actions-eks-deploy \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser

# Add role to EKS aws-auth ConfigMap so it can authenticate against the cluster
kubectl edit configmap aws-auth -n kube-system
# Add under mapRoles:
# - rolearn: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-eks-deploy
#   username: github-actions
#   groups:
#     - system:masters

Production note: Make the IAM role as narrow as possible. It only needs permission to push to ECR and update the specific Kubernetes deployment. Giving it broad AWS access is the same as putting your root credentials in a GitHub Secret.

Option B — Access Keys (Legacy)

If OIDC is not an option, store credentials as GitHub Secrets under Settings → Secrets and Variables → Actions:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_REGION           # eu-west-1

Production note: Long-lived access keys are a persistent security risk. If you must use them, rotate them regularly, scope them to minimum permissions, and treat them with the same care as a production database password.

Step 2: Build the Image — Why Commit SHA?

Every image gets tagged with the Git commit SHA, not just latest. This matters more than it sounds. latest is mutable — it can point to a completely different image every time it is pulled. A commit SHA is immutable. When you run kubectl describe pod and see an image tag, you know exactly which commit is running. Rollback becomes as simple as pointing to a previous SHA.

- name: Build Docker image
  env:
    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
  run: |
    docker build \
      --pull \
      -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
      -t $ECR_REGISTRY/$ECR_REPOSITORY:latest \
      ./apps/api

Why --pull? This tells Docker to always fetch the latest version of the base image rather than using a cached layer. Base image security patches are picked up on every build — without it, a cached layer from weeks ago could hide a known vulnerability.

Production note: Always push both the SHA tag and latest. The SHA tag gives you traceability. latest is useful for pulling the most recent version without knowing the SHA — but deployments should always reference the SHA, not latest.

Step 3: Scan Before You Push — Why Trivy?

Trivy scans the image for known vulnerabilities before it goes anywhere near ECR or the cluster. The key decision here is where in the pipeline the scan runs. Scanning after push means vulnerable images are already in your registry. Scanning before push means nothing gets stored if the scan fails.

- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
  with:
    image-ref: "${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}"
    format: "table"
    exit-code: "1"
    severity: "CRITICAL,HIGH"
    ignore-unfixed: true
    trivyignores: apps/api/.trivyignore

What is .trivyignore? For base image CVEs that have no fix available in the current Alpine release, add a .trivyignore file listing the CVE IDs. Trivy still reports them but won't block the pipeline. Document why each CVE is ignored — "no fix available in current alpine release" is a legitimate and honest reason.

Setting exit-code: "1" on CRITICAL and HIGH means the pipeline stops here if serious vulnerabilities are found. The image never reaches ECR. This is the fail-fast principle applied to security.

Step 4: Push to ECR and Deploy

- name: Push to ECR
  run: |
    docker push ${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
    docker push ${{ secrets.ECR_REPOSITORY }}:latest

- name: Deploy to Kubernetes
  run: |
    echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > /tmp/kubeconfig
    export KUBECONFIG=/tmp/kubeconfig
    kubectl set image deployment/my-app \
      my-app=${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} \
      -n production
    kubectl rollout status deployment/my-app -n production --timeout=5m

- name: Verify
  run: |
    export KUBECONFIG=/tmp/kubeconfig
    kubectl get pods -n production -l app=my-app
    echo "Deployed: ${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}"

Production note: kubectl set image works for simple setups but bypasses your Helm chart and leaves no trace in Git. The pipeline's job is to get the image into ECR — deploying to the cluster belongs in GitOps. In Part 5, Argo CD picks up from here: the pipeline commits the new image tag to the config repo, and Argo CD syncs the cluster automatically. No kubectl commands in CI, no direct cluster access required.

The Dockerfile — Why Multi-Stage?

A multi-stage build separates what you need to build from what you need to run. The builder stage installs all dependencies including dev tools. The runner stage copies only production dependencies and compiled output — no source code, no dev tooling, smaller image, reduced attack surface.

FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts

COPY src/ ./src/

FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]

If your application is packaged with Helm — as it should be in most production environments — the image tag lives in values.yaml. Updating that file and committing it is what makes GitOps straightforward: the pipeline updates the tag, Argo CD detects the change, and the cluster syncs. The kubectl set image step above works for simpler setups but does not scale to that pattern.

The Full Workflow

name: Build, Test, and Deploy

on:
  push:
    branches:
      - master

env:
  IMAGE_TAG: ${{ github.sha }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "24"
          cache: "npm"
      - run: npm ci
      - run: npm test
      - run: npm run lint

  build-and-deploy:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-deploy
          aws-region: eu-west-1

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build image
        run: |
          docker build -t ${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} .
          docker tag ${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} ${{ secrets.ECR_REPOSITORY }}:latest

      - name: Scan for vulnerabilities
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: "${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}"
          format: "table"
          exit-code: "1"
          severity: "CRITICAL,HIGH"
          ignore-unfixed: true
          trivyignores: apps/api/.trivyignore

      - name: Push to ECR
        run: |
          docker push ${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
          docker push ${{ secrets.ECR_REPOSITORY }}:latest

      - name: Deploy
        run: |
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          kubectl set image deployment/my-app \
            my-app=${{ secrets.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} \
            -n production
          kubectl rollout status deployment/my-app -n production --timeout=5m

The workflow above is slightly condensed for readability. The complete file — with the pinned Trivy SHA, .trivyignore, and all production settings — is in the repo: eks-app-deployment/.github/workflows/deploy.yml.

To run it: push any small change to master, or trigger it manually from the Actions tab using the Run workflow button — that is what workflow_dispatch enables. Watch the two jobs run in sequence — the test job must pass before build-and-deploy starts. Each step logs its output in real time. When the Trivy step shows a clean table and the push step confirms both SHA and latest tags, the image is in ECR and the pipeline has done its job.

image.png

Rolling Back

Because every image is tagged with a commit SHA, rollback is precise. You know exactly what you are going back to:

git log --oneline | head -5

kubectl set image deployment/my-app \
  my-app=123456789.dkr.ecr.eu-west-1.amazonaws.com/my-app:PREVIOUS_SHA \
  -n production

kubectl rollout status deployment/my-app -n production

If you move to GitOps with Argo CD — which the next post covers — rollback becomes a git revert. The pipeline itself becomes the deployment record, and every rollback has an author and a reason in the Git history.

Key Takeaways

  • Use OIDC over long-lived access keys — no credentials to rotate or leak

  • Tag images with commit SHAs, not just latest — every deployment should be traceable to a specific commit

  • Scan before you push — a vulnerable image that never reaches the registry is better than one that does

  • Fail fast — tests and security scans should stop the pipeline before anything reaches production

  • Keep deployment history in GitHub — every deploy has an author, a timestamp, and a diff

Next: GitOps with Argo CD — replacing the kubectl set image step here with automatic deployments driven entirely from Git.

Tisighe Livinstone

Tisighe Livinstone

Cloud & DevOps Engineer

Writing about real infrastructure challenges — Kubernetes, Terraform, GitOps, observability, and cloud security. Based on things I've actually built and broken in production.

Comments

All comments are reviewed before appearing.

Leave a Comment