Why I Dropped Jenkins for GitHub Actions & ArgoCD: A Complete GitOps Migration Guide
After years of using Jenkins, the author explains why moving to a GitOps workflow with GitHub Actions for CI and ArgoCD for CD offers lower maintenance, tighter integration with Kubernetes, declarative configurations, and automated deployments, and provides a step‑by‑step guide covering environment requirements, repository layout, CI pipeline, ArgoCD application setup, multi‑environment strategies, secret management, RBAC, monitoring, troubleshooting, and migration best practices.
Overview
Jenkins was once the de‑facto CI/CD tool, but as codebases migrated to GitHub and Kubernetes became the standard deployment platform, the author found Jenkins increasingly costly to maintain and poorly aligned with cloud‑native practices. The article explains the decision to replace Jenkins with a GitOps stack built around GitHub Actions for CI and ArgoCD for CD.
What is GitOps?
GitOps treats a Git repository as the single source of truth . Instead of a push‑based CI/CD flow that directly applies manifests, a pull‑based model stores all declarative configuration in Git. ArgoCD continuously watches the repository and reconciles the cluster state to match the desired state defined in Git, providing built‑in auditability.
Technology Stack Selection
CI : GitHub Actions (build, test, image push)
CD : ArgoCD (application deployment, state sync)
Image Registry : Harbor or GitHub Container Registry (ghcr.io)
Configuration Management : Kustomize or Helm
Secret Management : Sealed Secrets or External Secrets
This combination leverages native GitHub integration, clear separation of responsibilities, and strong Kubernetes support.
Environment Requirements
Kubernetes 1.20+ (tested on 1.27)
kubectl with cluster access
GitHub repository with Actions permissions
Accessible container image registry
Step‑by‑Step Implementation
2.1 Install and Configure ArgoCD
# Create namespace
kubectl create namespace argocd
# Install HA version
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/ha/install.yaml
# Wait for all Pods to be ready
kubectl wait --for=condition=Ready pods --all -n argocd --timeout=300s
# Retrieve initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
# Expose service (testing)
kubectl port-forward svc/argocd-server -n argocd 8080:4432.2 Repository Structure Design
Separate the application code repository from the deployment configuration repository to avoid triggering full CI pipelines on config changes.
# Application code repo (app-repo)
app-repo/
├── src/
├── tests/
├── Dockerfile
├── .github/workflows/ci.yaml
└── README.md
# Deployment config repo (deploy-repo)
deploy-repo/
├── apps/
│ ├── frontend/
│ │ ├── base/
│ │ └── overlays/
│ └── backend/
├── argocd/applications/
└── README.md2.3 GitHub Actions CI Pipeline
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- name: Run tests
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage.out
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
update-manifest:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout deploy repo
uses: actions/checkout@v4
with:
repository: myorg/deploy-repo
token: ${{ secrets.DEPLOY_REPO_TOKEN }}
path: deploy-repo
- name: Update image tag
run: |
cd deploy-repo/apps/myapp/overlays/staging
kustomize edit set image myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }}
- name: Commit and push
run: |
cd deploy-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "chore: update myapp image to ${{ needs.build-and-push.outputs.image_tag }}"
git push2.4 ArgoCD Application Definition
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp-staging
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/deploy-repo.git
targetRevision: main
path: apps/myapp/overlays/staging
destination:
server: https://kubernetes.default.svc
namespace: myapp-staging
syncPolicy:
automated:
prune: true
selfHeal: true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3mThe syncPolicy enables automatic synchronization, pruning of removed resources, self‑healing of drift, and retry logic for transient failures.
2.5 Multi‑Environment Deployment with Kustomize
# Base deployment (apps/myapp/base/deployment.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
# Production overlay (apps/myapp/overlays/prod/kustomization.yaml)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namespace: myapp-prod
replicas:
- name: myapp
count: 3
patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/cpu
value: 500m
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: 512Mi
- op: replace
path: /spec/template/spec/containers/0/resources/limits/cpu
value: 2000m
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: 2Gi
target:
kind: Deployment
name: myapp
images:
- name: myapp
newName: ghcr.io/myorg/myapp
newTag: v1.2.32.6 Rollback Strategies
Three rollback options are provided:
Use the ArgoCD UI history view to sync to a previous revision.
Run argocd app rollback myapp-prod from the CLI.
Perform a Git revert (recommended) to keep a full history, then push.
2.7 Canary Releases with Argo Rollouts
# Install Argo Rollouts
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml
# Convert Deployment to Rollout
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: myapp
spec:
replicas: 5
strategy:
canary:
steps:
- setWeight: 20
- pause: {duration: 5m}
- setWeight: 40
- pause: {duration: 5m}
- setWeight: 60
- pause: {duration: 5m}
- setWeight: 80
- pause: {duration: 5m}
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: ghcr.io/myorg/myapp:v1.2.3
ports:
- containerPort: 8080Best Practices and Caveats
Secret Management
Two approaches are highlighted:
Sealed Secrets : Encrypt secrets with a cluster‑side key; only the cluster can decrypt.
External Secrets : Sync secrets from external stores such as AWS Secrets Manager or HashiCorp Vault.
# Install Sealed Secrets controller
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
# Encrypt a secret
kubeseal --format=yaml < secret.yaml > sealed-secret.yamlRBAC Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.csv: |
# Developers can view all apps and sync non‑prod environments
p, role:developer, applications, get, */*, allow
p, role:developer, applications, sync, */*-dev, allow
p, role:developer, applications, sync, */*-staging, allow
# SREs have full access
p, role:sre, applications, *, */*, allow
p, role:sre, clusters, *, *, allow
# Bind GitHub teams to roles
g, myorg:developers, role:developer
g, myorg:sre-team, role:sre
policy.default: role:readonlyPerformance Tuning
ArgoCD polls Git every 3 minutes by default; for high‑frequency changes, enable a GitHub webhook.
# Webhook configuration (GitHub Settings → Webhooks)
# URL: https://argocd.example.com/api/webhook
# Content type: application/json
# Secret stored in argocd-secret
apiVersion: v1
kind: Secret
metadata:
name: argocd-secret
namespace: argocd
stringData:
webhook.github.secret: your-webhook-secretCommon Issues and Fixes
Application stuck OutOfSync : Verify the latest commit, check webhook delivery, or manually refresh with argocd app get myapp --refresh. Ignoring replica count differences can also help.
Sync failure due to existing resources : Add the missing app.kubernetes.io/instance label so ArgoCD can adopt the resource.
Image pull errors : Ensure imagePullSecrets reference a secret that contains credentials for the registry.
Observability
ArgoCD exposes Prometheus metrics. Example alert rules:
# Alert when an application is out of sync for >15m
alert: ArgocdAppOutOfSync
expr: argocd_app_info{sync_status!="Synced"} == 1
for: 15m
labels:
severity: warning
annotations:
summary: "Application {{ $labels.name }} is out of sync"
# Alert when health status is degraded for >5m
alert: ArgocdAppHealthDegraded
expr: argocd_app_info{health_status!="Healthy"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "Application {{ $labels.name }} health is degraded"Jenkins vs. GitHub Actions + ArgoCD Comparison
Maintenance Cost : Jenkins requires dedicated servers and plugins; the GitHub‑ArgoCD combo is SaaS‑based with near‑zero ops.
Configuration Model : Jenkins mixes UI and Groovy scripts; GitHub Actions + ArgoCD are fully declarative YAML.
Kubernetes Integration : Jenkins needs plugins; ArgoCD natively syncs with Kubernetes.
Extensibility : Jenkins offers many plugins but can cause version conflicts; GitHub Actions allow custom actions and flexible composition.
Auditability : Jenkins needs extra setup; Git history provides complete audit trails.
Learning Curve : Jenkins Groovy is steep; GitHub Actions YAML is more approachable.
Cost : Self‑hosted Jenkins incurs hardware costs; GitHub Actions has generous free tiers.
Conclusion and Migration Advice
For teams already operating in Kubernetes, the GitHub Actions + ArgoCD stack offers a modern, cloud‑native CI/CD pipeline with clear separation of concerns, built‑in auditability, and easier maintenance. Migration can be incremental: start with GitHub Actions for CI on new projects, then gradually replace Jenkins‑driven pipelines in existing projects, and finally adopt ArgoCD for CD.
References
ArgoCD official documentation
GitHub Actions documentation
GitOps principles – Weaveworks
Argo Rollouts progressive delivery guide
Ops Community
A leading IT operations community where professionals share and grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
