How to Build a CI/CD Pipeline Using GitHub Actions
Most teams have CI. Few have great CD. This guide covers building a complete, production-grade pipeline with GitHub Actions for CI — building, testing, scanning, and pushing a Docker image — and ArgoCD for GitOps-based Kubernetes deployments. No stored cloud credentials. Instant rollback. Works for any language.
Why GitHub Actions?
GitHub Actions is the natural choice if your code is already on GitHub. It has native integration with your repository, a rich marketplace of reusable actions, and OIDC-based authentication to cloud providers — meaning you never store AWS access keys as secrets.
It's also free for public repositories and includes generous minutes for private repos. For most startups, it's the lowest-friction CI platform available.
The Pipeline We're Building
Our target pipeline has two parts:
- CI (GitHub Actions): Triggered on push/PR. Runs lint, unit tests, Docker build, Trivy security scan, and pushes the image to ECR.
- CD (ArgoCD): ArgoCD watches the GitOps repository. When the image tag is updated, it syncs the Kubernetes cluster automatically.
This is the two-repo GitOps pattern: one repo for application code, one for Kubernetes manifests. The CI pipeline updates the manifest repo; ArgoCD deploys it.
Step 1: Set Up OIDC Authentication to AWS
Instead of storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub secrets, we use OpenID Connect (OIDC). GitHub issues a short-lived JWT; AWS validates it and issues temporary credentials.
Create the IAM OIDC Provider
# Create the OIDC provider in your AWS account
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Create the IAM Role
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repo:your-org/your-repo:*"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}]
}
Attach an inline policy granting ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:PutImage, and ecr:InitiateLayerUpload to this role.
Step 2: Write the CI Workflow
Create .github/workflows/ci.yml in your application repository:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # needed for OIDC
contents: read
env:
AWS_REGION: ap-south-1
ECR_REPOSITORY: my-app
IMAGE_TAG: ${{ github.sha }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run unit tests
run: |
# Replace with your test command
npm ci && npm test
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build Docker image
run: |
docker build -t ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}"
exit-code: '1'
severity: 'CRITICAL'
- name: Push to ECR
if: github.ref == 'refs/heads/main'
run: |
docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
- name: Update image tag in GitOps repo
if: github.ref == 'refs/heads/main'
uses: actions/checkout@v4
with:
repository: your-org/gitops-repo
token: ${{ secrets.GITOPS_PAT }}
path: gitops
- name: Patch Kustomize image tag
if: github.ref == 'refs/heads/main'
run: |
cd gitops
kustomize edit set image \
my-app=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
git config user.email "ci@omphoratech.com"
git config user.name "CI Bot"
git add .
git commit -m "ci: update my-app image to ${{ env.IMAGE_TAG }}"
git push
Key Points
id-token: writepermission is required for OIDC — without it, the JWT isn't issued.- Trivy runs before the push. If a critical CVE is found, the build fails before the image reaches production.
- The image tag update only runs on
main, not on pull requests. - Use a dedicated machine user or GitHub App for the GitOps PAT, not a personal token.
Step 3: Write a Good Dockerfile
A multi-stage Dockerfile keeps your production image small and secure — no dev dependencies, no build tools:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
Running as a non-root user (USER app) is a security baseline requirement for most container security policies.
Step 4: Set Up ArgoCD for Continuous Deployment
Install ArgoCD on your EKS cluster using Helm — see our GitOps guide for detailed ArgoCD setup. Once installed, create an Application that points at your GitOps repository:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops-repo
targetRevision: main
path: apps/my-app/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
With automated.selfHeal: true, ArgoCD will revert any manual changes to the cluster. Git is the only source of truth.
Step 5: Environment Promotion with Kustomize
Structure your GitOps repo to support multiple environments:
gitops-repo/
apps/
my-app/
base/
deployment.yaml
service.yaml
kustomization.yaml
overlays/
staging/
kustomization.yaml # patches image, replicas
production/
kustomization.yaml # patches image, replicas, resource limits
The CI pipeline updates the staging overlay first. A separate promotion step (manual approval or a time delay) updates production. This gives you a proper staging gate without a separate CD tool.
Handling Rollbacks
Rolling back with ArgoCD is a one-click operation in the UI: select the target revision (any previous Git commit), click Rollback. ArgoCD resyncs the cluster to that state. The image is still in ECR, so no rebuilding required.
You can also rollback from the CLI:
argocd app history my-app
argocd app rollback my-app <revision-id>
Adding Slack Notifications
Add deployment notifications to GitHub Actions using the Slack webhook action:
- name: Notify Slack on success
if: success() && github.ref == 'refs/heads/main'
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": "✅ Deployed *my-app* `${{ env.IMAGE_TAG }}` to production"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Summary
This pipeline gives you:
- No stored cloud credentials — OIDC only
- Tests and security scanning on every commit
- Immutable image tags (git SHA) in ECR
- GitOps-based deployments via ArgoCD
- One-click rollback to any previous version
- Environment promotion via Kustomize overlays
The goal of CI/CD isn't just speed — it's confidence. Every engineer on the team should be able to deploy to production without fear.
Need this set up for your team?
We implement production CI/CD pipelines as part of our DevOps consulting service. Typically takes 1–2 weeks.
Book Free Consultation