Build a GitOps CI/CD Pipeline with ArgoCD
Build a pipeline where GitHub Actions tests and pushes an image, then updates a config repo that ArgoCD syncs to Kubernetes.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a GitOps CI/CD pipeline: GitHub Actions tests your code, builds a container image, pushes it to GitHub Container Registry, and commits the new image tag to a separate config repository, which ArgoCD then syncs to Kubernetes. CI never touches the cluster, and every production change is a reviewable Git commit.
The problem this pipeline solves
In a classic push-based pipeline, the CI system holds cluster credentials and runs kubectl apply. That means a compromised CI job can reach production, and nobody can answer "what exactly is running right now?" without logging in. Picture a team at Swiggy shipping an order-status API many times a day: they need fast deploys, a clear audit trail and a one-step rollback.
This project splits the work between two repositories with two different jobs:
| Repository | Contains | Who writes to it |
|---|---|---|
orders-api (app repo) |
Source code, tests, Dockerfile, CI workflow | Developers |
orders-api-config (config repo) |
Kubernetes manifests and the current image tag | The CI pipeline and reviewers |
CI controls which images exist. The config repo controls which image is deployed. ArgoCD, running inside the cluster, pulls from the config repo, so no credentials for the cluster ever leave it.
app repo] --> B subgraph CI[GitHub Actions] B[Test] --> C[Build and
push image] end C --> D[Update
config repo] E[ArgoCD] -->|polls| D E -->|syncs| F[Kubernetes] style CI fill:#F1F5F9,stroke:#475569,stroke-width:2px,color:#1E293B classDef purple fill:#EDE9FE,stroke:#8B5CF6,stroke-width:2px,color:#1E293B classDef blue fill:#DBEAFE,stroke:#3B82F6,stroke-width:2px,color:#1E293B classDef green fill:#D1FAE5,stroke:#10B981,stroke-width:2px,color:#1E293B classDef orange fill:#FFEDD5,stroke:#F97316,stroke-width:2px,color:#1E293B classDef red fill:#FEE2E2,stroke:#EF4444,stroke-width:2px,color:#1E293B classDef slate fill:#F1F5F9,stroke:#475569,stroke-width:2px,color:#1E293B class A purple class B,C blue class D orange class E green class F slate linkStyle default stroke:#475569,stroke-width:2px
What you will build
- A small Flask API called
orders-apiwith tests and a non-root Docker image - A GitHub Actions workflow with three jobs: test, build and push, update config
- A config repo using Kustomize that CI edits automatically
- An ArgoCD Application that deploys every new image tag on its own
- A bad release and a rollback, done through Git
What you need
| Tool | Check it works | Install guide |
|---|---|---|
| Docker | docker version |
Get Docker |
| kind | kind version |
kind quick start |
| kubectl | kubectl version --client |
Install kubectl |
| Git and GitHub account | git --version |
GitHub signup |
| Python 3.12 (optional, for local tests) | python3 --version |
python.org |
New to ArgoCD? Do Deploy Your First App with ArgoCD and GitOps first. This project assumes you know what an Application and a sync are. Plan for about 3 hours. Replace YOUR_USER with your GitHub username (in lowercase) everywhere it appears.
TipEach milestone ends with a Checkpoint. Do not move on until it matches, because later steps build on earlier ones.
Go deeper
- Argo CD best practices - why app code and config live in separate repos
- OpenGitOps principles - the vendor-neutral definition of GitOps
Milestone 1: Build the Orders API and Container Image
This milestone creates a small Flask API with tests and a production-style Docker image, and pushes it to a new orders-api GitHub repository. The pipeline needs something realistic to build and deploy.
Why these choices
The app is intentionally tiny so the focus stays on the pipeline. Two details matter for GitOps. The / endpoint returns the Git commit SHA baked into the image, so later you can prove which version is running. The container runs as a non-root user under gunicorn, a production WSGI server, because Flask's built-in server is meant for local development only.
Steps
Create an empty GitHub repo named orders-api, then:
mkdir orders-api && cd orders-apimkdir tests cat > app.py <<'EOF'import osfrom flask import Flask, jsonify app = Flask(__name__) @app.route("/")def home(): return jsonify( service="orders-api", version=os.getenv("APP_VERSION", "dev"), message="orders-api is running", status="healthy", ) @app.route("/health")def health(): return jsonify(status="ok"), 200EOF cat > requirements.txt <<'EOF'flask==3.0.3gunicorn==22.0.0EOF cat > pytest.ini <<'EOF'[pytest]pythonpath = .EOF cat > tests/test_app.py <<'EOF'from app import app def client(): app.config["TESTING"] = True return app.test_client() def test_home_returns_service_name(): response = client().get("/") assert response.status_code == 200 assert response.get_json()["service"] == "orders-api" def test_health_returns_ok(): response = client().get("/health") assert response.status_code == 200 assert response.get_json()["status"] == "ok"EOF cat > Dockerfile <<'EOF'FROM python:3.12-slimLABEL org.opencontainers.image.source="https://github.com/YOUR_USER/orders-api"WORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py .RUN useradd --create-home appuserUSER appuserARG GIT_SHA=devENV APP_VERSION=${GIT_SHA}EXPOSE 8080CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]EOF printf '.git\n__pycache__\ntests\n' > .dockerignoreTest locally:
python3 -m venv .venv && source .venv/bin/activatepip install -r requirements.txt pytestpytest -v docker build -t orders-api:local .docker run --rm -d --name orders-local -p 8081:8080 orders-api:localcurl -s localhost:8081/ && curl -s localhost:8081/healthdocker stop orders-localPush the code:
printf '.venv\n__pycache__\n' > .gitignoregit init -b maingit add .git commit -m "feat: add orders-api"git remote add origin https://github.com/YOUR_USER/orders-api.gitgit push -u origin mainCheckpoint
tests/test_app.py::test_home_returns_service_name PASSEDtests/test_app.py::test_health_returns_ok PASSED{"message":"orders-api is running","service":"orders-api","status":"healthy","version":"dev"}{"status":"ok"}Common MistakeCopying a multi-stage build that installs packages into
/root/.localand then switching to a non-root user. That user cannot read/root, so the container crashes on start. Install packages system-wide as shown, then switch users.
TipThe
LABEL org.opencontainers.image.sourceline links the image to your repository in GitHub Packages, which helps with package permissions later.
Go deeper
- Flask: deploying with gunicorn - why not the dev server
- pytest configuration - what
pythonpathdoes - Docker: build best practices - image hygiene
Milestone 2: Create the Config Repository
This milestone creates the repo that ArgoCD watches. It contains Kubernetes manifests and a Kustomize file that CI will update with each new image tag.
Why Kustomize and not `sed`
CI needs to change one thing: the image tag. Kustomize has a purpose-built images field for exactly this, and kustomize edit set image changes it safely. That is far more reliable than a sed regex, which can silently corrupt YAML. Kustomize is also built into kubectl, and ArgoCD understands it natively.
Steps
Create an empty public GitHub repo named orders-api-config, then:
mkdir orders-api-config && cd orders-api-configmkdir -p orders-api argocd cat > orders-api/deployment.yaml <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: orders-api labels: app: orders-apispec: replicas: 2 selector: matchLabels: app: orders-api template: metadata: labels: app: orders-api spec: containers: - name: orders-api image: ghcr.io/YOUR_USER/orders-api ports: - name: http containerPort: 8080 readinessProbe: httpGet: path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /health port: http initialDelaySeconds: 15 periodSeconds: 20 resources: requests: cpu: 50m memory: 64Mi limits: cpu: 200m memory: 128MiEOF cat > orders-api/service.yaml <<'EOF'apiVersion: v1kind: Servicemetadata: name: orders-apispec: selector: app: orders-api ports: - name: http port: 80 targetPort: httpEOF cat > orders-api/kustomization.yaml <<'EOF'apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: productionresources: - deployment.yaml - service.yamlEOF git init -b maingit add .git commit -m "feat: add orders-api manifests"git remote add origin https://github.com/YOUR_USER/orders-api-config.gitgit push -u origin mainThe image has no tag on purpose. CI adds an images: block to kustomization.yaml the first time it runs.
Checkpoint
Preview what Kustomize renders:
kubectl kustomize orders-api | grep -E 'namespace|image:' namespace: production image: ghcr.io/YOUR_USER/orders-apiCommon MistakeUsing uppercase letters in the image name. GitHub Container Registry requires lowercase, and the pipeline lowercases the repository name for you. If your username has capitals, write it in lowercase in
deployment.yamltoo.
Go deeper
- Kustomize images field - how tag overrides work
- Kubernetes: manage objects with Kustomize - the built-in tooling
- Argo CD with Kustomize - how ArgoCD renders it
Milestone 3: Build the GitHub Actions Pipeline
This milestone adds a three-job workflow to the app repo and runs it once. The jobs run in order: test, build and push the image, then update the config repo.
Why three jobs
Each job runs only if the one before it succeeds (needs:). Bad code fails at the test job, so no image is built and nothing is deployed. The last job is the GitOps step: it does not deploy anything. It only commits a new image tag to the config repo, and ArgoCD does the deploying.
Step 1: Give CI permission to write to the config repo
The default GITHUB_TOKEN can only write to the repo the workflow runs in, so it cannot push to orders-api-config. Create a token that can:
- In GitHub, open Settings, Developer settings, Personal access tokens, Fine-grained tokens, and generate a new token
- Set Repository access to Only select repositories and choose
orders-api-config - Under Repository permissions, set Contents to Read and write
- In the
orders-apirepo, open Settings, Secrets and variables, Actions, and add a secret namedCONFIG_REPO_TOKENwith that token
Step 2: Add the workflow
cd orders-apimkdir -p .github/workflows cat > .github/workflows/ci-cd.yml <<'EOF'name: ci-cd on: push: branches: [main] pull_request: branches: [main] env: REGISTRY: ghcr.io jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: pip install -r requirements.txt pytest - run: pytest -v build-and-push: needs: test if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read packages: write outputs: image: ${{ steps.vars.outputs.image }} tag: ${{ steps.vars.outputs.tag }} steps: - uses: actions/checkout@v4 - name: Compute image name and tag id: vars run: | echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true build-args: GIT_SHA=${{ github.sha }} tags: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.tag }} update-config: needs: build-and-push runs-on: ubuntu-latest concurrency: config-repo-update steps: - uses: actions/checkout@v4 with: repository: ${{ github.repository_owner }}/orders-api-config token: ${{ secrets.CONFIG_REPO_TOKEN }} - name: Point the deployment at the new image working-directory: orders-api run: | IMAGE="${{ needs.build-and-push.outputs.image }}" TAG="${{ needs.build-and-push.outputs.tag }}" kustomize edit set image "${IMAGE}=${IMAGE}:${TAG}" cat kustomization.yaml - name: Commit and push run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add orders-api/kustomization.yaml git commit -m "deploy: orders-api ${{ needs.build-and-push.outputs.tag }}" git pushEOF git add .git commit -m "ci: add test, build and gitops update pipeline"git pushStep 3: Make the image pullable
After the first run, GitHub creates the package as private. Open your profile, then Packages, then orders-api, then Package settings, and under Danger Zone change visibility to Public so the cluster can pull it without credentials.
Checkpoint
- The Actions tab on
orders-apishowstest,build-and-pushandupdate-configall green - The Packages page lists
orders-apiwith a tag likesha-1a2b3c4 - In
orders-api-configthere is a new commit nameddeploy: orders-api sha-1a2b3c4, andorders-api/kustomization.yamlnow has animages:block
| Job | Runs when | What it does |
|---|---|---|
test |
Every push and pull request | Installs dependencies and runs pytest |
build-and-push |
Push to main only |
Builds the image and pushes it with a sha- tag |
update-config |
After a successful push | Sets the new tag in the config repo with Kustomize |
Common MistakeUsing the default
GITHUB_TOKENto push to the config repo. It only has access to the current repo, so the last job fails with a 403 error. Create the fine-grained token and theCONFIG_REPO_TOKENsecret as shown.
SecurityScope the token to the single config repo with only the Contents permission. If it leaks, the damage is limited to that repo and it cannot reach your app code or cluster.
TipThe
concurrencyline makes two quick pushes update the config repo one after the other instead of racing and failing with a rejectedgit push. Image tags use the short commit SHA, which is immutable, neverlatest.
Go deeper
- Docker: build and push with GitHub Actions - the actions used in this workflow
- Working with the GitHub Container Registry - packages, visibility and permissions
- Managing personal access tokens - fine-grained token setup
- Control workflow concurrency - the
concurrencykey
Milestone 4: Prepare the Cluster and Install ArgoCD
This milestone creates a local cluster and installs ArgoCD. If you completed Deploy Your First App with ArgoCD and GitOps and its cluster still exists, run only the verification commands and move on.
Why it is brief here
The beginner project explains what each ArgoCD component does. Here you need a working ArgoCD and nothing more.
Steps
kind create cluster --name gitops-lab kubectl create namespace argocdkubectl apply -n argocd --server-side --force-conflicts \ -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yamlkubectl wait --for=condition=ready pod --all -n argocd --timeout=300sIn a second terminal, open the UI and get the password:
kubectl get secret argocd-initial-admin-secret -n argocd \ -o jsonpath='{.data.password}' | base64 -d; echokubectl port-forward svc/argocd-server -n argocd 8080:443Install the CLI (brew install argocd on macOS, or download from the releases page) and log in:
argocd login localhost:8080 --username admin --password 'PASTE_PASSWORD_HERE' --insecureCheckpoint
kubectl get pods -n argocdargocd account get-user-infoAll ArgoCD pods are Running, and argocd account get-user-info shows Logged In: true.
Common MistakeLeaving out
--server-side --force-conflicts. The install fails because the ApplicationSet CRD is too large for client-side apply.
Go deeper
- Argo CD getting started - official install guide
Milestone 5: Create the ArgoCD Application
This milestone tells ArgoCD to watch the config repo and keep the production namespace in sync with it. The Application is the link between Git and the cluster.
Why `selfHeal` and `prune`
automated makes ArgoCD sync without a human clicking. selfHeal reverts manual changes made in the cluster, and prune deletes resources you remove from Git. Together they make Git the only way to change the app.
Steps
Create argocd/orders-api-app.yaml in the config repo:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: orders-api namespace: argocdspec: project: default source: repoURL: https://github.com/YOUR_USER/orders-api-config.git targetRevision: main path: orders-api destination: server: https://kubernetes.default.svc namespace: production syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=truecd orders-api-configgit pullgit add argocd/orders-api-app.yamlgit commit -m "feat: add argocd application"git push kubectl apply -f argocd/orders-api-app.yamlargocd app get orders-apikubectl get pods -n productionTest the running service, using port 8081 because 8080 is used by the ArgoCD UI:
kubectl port-forward svc/orders-api -n production 8081:80curl -s localhost:8081/Checkpoint
Sync Status: Synced to main (abc1234)Health Status: Healthy{"message":"orders-api is running","service":"orders-api","status":"healthy","version":"1a2b3c4d..."}The version is the full commit SHA of your app repo, which proves the image CI built is the one running.
If your config repo is private, give ArgoCD read access first:
argocd repo add https://github.com/YOUR_USER/orders-api-config.git \ --username YOUR_USER --password YOUR_READ_ONLY_TOKENCommon MistakePods stuck in
ImagePullBackOff. The image package is still private. Set it to public (Milestone 3, Step 3) or create an image pull secret for the namespace.
SecurityNever commit plaintext Secrets to the config repo. For real apps use Sealed Secrets or the External Secrets Operator so only encrypted data lives in Git.
Go deeper
- Private repositories in Argo CD - credentials for private config repos
- Sync options -
CreateNamespaceand friends - Image pull secrets - for private registries
Milestone 6: Ship a Change End to End
This milestone changes the app code, pushes it, and watches the change travel through the whole pipeline to the cluster without you running any deploy command.
Why this is the payoff
You touch only the app repo. CI builds a new image, writes its tag into the config repo, and ArgoCD notices and rolls it out. Every hop is a Git commit you can inspect.
Steps
Change the message in app.py and update nothing else:
message="orders-api v2 is running",cd orders-apigit add app.pygit commit -m "feat: update status message"git pushWatch each stage:
# 1. CI runs (check the Actions tab), then the config repo gets a new commit# 2. Ask ArgoCD to refresh instead of waiting up to 3 minutesargocd app get orders-api --refreshkubectl get pods -n production -wCheckpoint
kubectl port-forward svc/orders-api -n production 8081:80curl -s localhost:8081/The response now shows "message":"orders-api v2 is running" and a new version SHA. In the config repo, git log --oneline shows a new deploy: orders-api sha-... commit.
TipThe default polling interval is about 3 minutes. A GitHub webhook to ArgoCD makes syncs start in seconds. See the webhook guide below.
Common MistakeEditing the image tag in
kustomization.yamlby hand and pushing a tag that was never built. ArgoCD syncs it happily and the pods fail to pull. Let CI own that line.
Go deeper
- Argo CD webhook configuration - instant syncs from GitHub
- Tracking strategies - how ArgoCD decides which revision to deploy
Milestone 7: Roll Back a Bad Release
This milestone ships a release that passes tests but crashes at runtime, then rolls it back by reverting a commit in the config repo. It also shows what tests can and cannot catch.
Why revert the config repo first
Tests cannot catch everything, such as a broken start command. The fastest rollback reverts the config repo commit, which points ArgoCD back at the previous image that already exists, so there is no rebuild. Fix the app code afterwards, without time pressure.
Steps
Break the start command in a way the tests will not notice:
cd orders-apised -i.bak 's|"app:app"|"app:wrong_name"|' Dockerfilerm Dockerfile.bakgit add Dockerfilegit commit -m "chore: bad start command (on purpose)"git pushCI passes because the unit tests never run the container. After the pipeline finishes and ArgoCD syncs:
argocd app get orders-api --refreshkubectl get pods -n productionYou will see new pods in CrashLoopBackOff while the old pods keep serving traffic. Roll back in the config repo:
cd orders-api-configgit pullgit revert HEAD --no-editgit push argocd app get orders-api --refreshkubectl get pods -n productionFinally, fix the cause in the app repo so the bad commit does not ship again:
cd orders-apigit revert HEAD --no-editgit pushCheckpoint
Sync Status: SyncedHealth Status: HealthyAll pods are Running, and curl -s localhost:8081/ returns your last good version.
Common MistakeReverting only the app repo. That triggers a brand new pipeline run and a rebuild, which is slower than reverting the config repo, and the broken image stays deployed until it finishes.
TipKubernetes rolling updates kept the old pods alive here because the new pods never became ready. Readiness probes are what turn a bad release into a stalled rollout instead of an outage.
Go deeper
- Kubernetes: rolling update - why old pods survive
- Configure liveness and readiness probes - the safety net used here
Validation & Testing
This final step proves the pipeline works from commit to running pod, and gives you a checklist to show your work.
Verification commands
# 1. ArgoCD reports the app as synced and healthyargocd app get orders-api # 2. Pods are running in productionkubectl get pods -n production # 3. The running image tag matches the latest deploy commitkubectl get deployment orders-api -n production \ -o jsonpath='{.spec.template.spec.containers[0].image}'; echo # 4. The service answerskubectl port-forward svc/orders-api -n production 8081:80 &curl -s localhost:8081/healthExpected: Synced and Healthy, 2 pods Running, an image ending in :sha-xxxxxxx that matches the newest deploy: commit in the config repo, and {"status":"ok"}.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
update-config fails with 403 |
Token missing or too narrow | Recreate the fine-grained token with Contents read and write on the config repo |
update-config fails with kustomize: command not found |
Runner image changed | Add a step that installs Kustomize before the update step |
ImagePullBackOff |
Package is private or tag missing | Make the package public and confirm the tag exists in Packages |
| ArgoCD never syncs the new tag | Waiting for the poll | Run argocd app get orders-api --refresh or add a webhook |
git push rejected in update-config |
Two runs raced | Keep the concurrency line on that job |
Pods CrashLoopBackOff |
Bad start command or missing env | Run kubectl logs on the pod, then revert in the config repo |
Clean up
kind delete cluster --name gitops-labWhat to try next
- Build a GitOps Delivery Platform with ArgoCD - add staging and production, ApplicationSets and canary releases
- Add a GitHub webhook so ArgoCD syncs within seconds of a config commit
- Add a vulnerability scan to the
build-and-pushjob before the image is pushed
Go deeper
- GitHub Actions documentation - workflow syntax reference
- Argo CD documentation home - full reference