ArgoCD GitOps Implementation: From Installation to a Complete CD Flow

This post records how I installed ArgoCD into a k3s cluster, created an ArgoCD Application, and switched the whole CD flow from “GitHub Actions directly running kubectl apply” to “git is the source of truth and ArgoCD performs sync.”

Control Flow Shift

flowchart TD
    subgraph Push-based
        P1[Developer push] --> P2[GitHub Actions]
        P2 -->|kubectl apply + K8s credentials| P3[K8s Cluster]
    end

    subgraph Pull-based GitOps
        G1[Developer push] --> G2[GitHub Actions]
        G2 -->|push image + update YAML| G3[Git Repo]
        G3 -->|ArgoCD polls every 3min| G4[ArgoCD in Cluster]
        G4 -->|kubectl apply internal| G5[K8s Cluster]
    end
flowchart TD
    subgraph Push-based
        P1[Developer push] --> P2[GitHub Actions]
        P2 -->|kubectl apply + K8s credentials| P3[K8s Cluster]
    end

    subgraph Pull-based GitOps
        G1[Developer push] --> G2[GitHub Actions]
        G2 -->|push image + update YAML| G3[Git Repo]
        G3 -->|ArgoCD polls every 3min| G4[ArgoCD in Cluster]
        G4 -->|kubectl apply internal| G5[K8s Cluster]
    end
flowchart TD
    subgraph Push-based
        P1[Developer push] --> P2[GitHub Actions]
        P2 -->|kubectl apply + K8s credentials| P3[K8s Cluster]
    end

    subgraph Pull-based GitOps
        G1[Developer push] --> G2[GitHub Actions]
        G2 -->|push image + update YAML| G3[Git Repo]
        G3 -->|ArgoCD polls every 3min| G4[ArgoCD in Cluster]
        G4 -->|kubectl apply internal| G5[K8s Cluster]
    end
Push-based Pull-based (ArgoCD)
CI needs K8s permission yes no
Drift detection none automatic detect and repair
Rollback manually run old workflow one-click in UI or git revert
Visibility CI log complete sync status in ArgoCD UI

The root issue in push-based CD is a blurry security boundary: the CI runner holds K8s credentials, and if those credentials leak, an attacker can operate the cluster directly. Pull-based keeps control inside the cluster, and CI only needs permission to write git.

Internal Control Plane

flowchart LR
    Git["Git Repo
k8s/base/"] -->|clone + render| RS[argocd-repo-server] RS -->|desired state| AC[argocd-application-controller] AC <-->|compare| K8s["k3s Cluster
actual state"] AC -->|diff found: sync| K8s Redis["argocd-redis
cache"] --- AC Server["argocd-server
UI + API"] --- AC
flowchart LR
    Git["Git Repo
k8s/base/"] -->|clone + render| RS[argocd-repo-server] RS -->|desired state| AC[argocd-application-controller] AC <-->|compare| K8s["k3s Cluster
actual state"] AC -->|diff found: sync| K8s Redis["argocd-redis
cache"] --- AC Server["argocd-server
UI + API"] --- AC
flowchart LR
    Git["Git Repo
k8s/base/"] -->|clone + render| RS[argocd-repo-server] RS -->|desired state| AC[argocd-application-controller] AC <-->|compare| K8s["k3s Cluster
actual state"] AC -->|diff found: sync| K8s Redis["argocd-redis
cache"] --- AC Server["argocd-server
UI + API"] --- AC

Responsibilities of each component:

Component Responsibility
argocd-server UI + API, operation entry point
argocd-repo-server clone git repo, render YAML (Helm/Kustomize/plain)
argocd-application-controller core loop, compares desired vs actual state every 3 minutes
argocd-applicationset-controller batch management for multiple Applications
argocd-dex-server SSO authentication (GitHub OAuth, LDAP)
argocd-redis cache application state to accelerate comparisons
argocd-notifications-controller send notifications (Slack, email)

Render YAML means ArgoCD expands Helm templates or Kustomize overlays into valid Kubernetes YAML. Plain YAML does not need render; what you see is what gets applied.

Initial Setup Steps

1
2
3
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl get pods -n argocd -w

After all pods are Running, access UI:

1
2
3
4
kubectl port-forward svc/argocd-server -n argocd 8080:443

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d && echo

Open https://localhost:8080, in browser, account admin.

Repo Directory Boundaries

The monitored path in ArgoCD should contain only YAML that ArgoCD is supposed to manage. If test resources and production resources are mixed in the same directory, ArgoCD cannot distinguish them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
k8s/
  base/            <- ArgoCD managed
    deployment.yaml
    service.yaml
    configmap.yaml
    hpa.yaml
    secret.yaml
  argocd/          <- Application manifest
    application.yaml
  test/            <- excluded from ArgoCD
    test-namespace.yaml
    test-liveness.yaml
    test-probe.yaml

base/ is the source of truth for ArgoCD. test/ is completely excluded from the GitOps flow.

Declarative App Registration

ArgoCD Application should live in git, not only created from UI. UI-only config has no version control and violates GitOps principles:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# k8s/argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: go-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/your-repo
    targetRevision: HEAD
    path: k8s/base
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
Field Meaning
namespace: argocd the Application object itself lives in argocd namespace
project: default ArgoCD Project, used for RBAC and resource isolation
targetRevision: HEAD always tracks latest commit
path: k8s/base monitor all YAML under this directory
server: kubernetes.default.svc the same cluster where ArgoCD runs
automated.prune: true resources deleted in git are also deleted in cluster
automated.selfHeal: true if someone changes cluster manually, ArgoCD restores git state
1
kubectl apply -f k8s/argocd/application.yaml

If the Application already exists from UI, kubectl apply updates the existing object and YAML becomes the source. After that, UI is used for observation only.

Credential Secret Wiring for Private Images

k3s is a local cluster and has no GCP Workload Identity. It needs explicit credentials to pull images from a private registry.

Create a non-expiring imagePullSecret from a Service Account key:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gcloud iam service-accounts keys create /tmp/ar-pull-key.json \
  --iam-account=YOUR_SA@YOUR_PROJECT.iam.gserviceaccount.com

kubectl create secret docker-registry ar-secret \
  --docker-server=us-east1-docker.pkg.dev \
  --docker-username=_json_key \
  --docker-password="$(cat /tmp/ar-pull-key.json)" \
  --namespace=default

rm /tmp/ar-pull-key.json

Delete SA key right after creation — the full content is already in Kubernetes Secret, and keeping it on local disk is unnecessary risk.

Add this into deployment.yaml, including imagePullSecrets:

1
2
3
4
5
6
spec:
  imagePullSecrets:
    - name: ar-secret
  containers:
    - name: go-api
      image: us-east1-docker.pkg.dev/YOUR_PROJECT/go-api/go-api:prod-7639a24

GKE does not need this because nodes can pull Artifact Registry through Workload Identity. ar-secret is a k3s local-environment constraint.

Deployment Timeline Loop

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant AR as Artifact Registry
    participant Git as Git Repo
    participant Argo as ArgoCD
    participant K8s as k3s Cluster

    Dev->>GH: push to main
    GH->>GH: go test + go build
    GH->>AR: docker push prod-{sha}
    GH->>Git: update deployment.yaml image tag
    GH->>Git: push gitops/update-image-{sha} branch
    Dev->>Git: merge PR to main
    loop every 3 minutes
        Argo->>Git: poll for changes
        Git-->>Argo: deployment.yaml changed
    end
    Argo->>K8s: kubectl apply k8s/base/
    K8s->>K8s: rolling update
sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant AR as Artifact Registry
    participant Git as Git Repo
    participant Argo as ArgoCD
    participant K8s as k3s Cluster

    Dev->>GH: push to main
    GH->>GH: go test + go build
    GH->>AR: docker push prod-{sha}
    GH->>Git: update deployment.yaml image tag
    GH->>Git: push gitops/update-image-{sha} branch
    Dev->>Git: merge PR to main
    loop every 3 minutes
        Argo->>Git: poll for changes
        Git-->>Argo: deployment.yaml changed
    end
    Argo->>K8s: kubectl apply k8s/base/
    K8s->>K8s: rolling update
sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant AR as Artifact Registry
    participant Git as Git Repo
    participant Argo as ArgoCD
    participant K8s as k3s Cluster

    Dev->>GH: push to main
    GH->>GH: go test + go build
    GH->>AR: docker push prod-{sha}
    GH->>Git: update deployment.yaml image tag
    GH->>Git: push gitops/update-image-{sha} branch
    Dev->>Git: merge PR to main
    loop every 3 minutes
        Argo->>Git: poll for changes
        Git-->>Argo: deployment.yaml changed
    end
    Argo->>K8s: kubectl apply k8s/base/
    K8s->>K8s: rolling update

Why CI should not push main directly: main has branch protection rules and requires PRs. CI pushes to a gitops branch to bypass that safely while keeping code review flow.

Drift detection: if someone manually runs kubectl edit deployment go-api and changes replicas, ArgoCD selfHeal automatically restores the value from git on the next poll. Cluster state is always decided by git.

Local and Managed Runtime Notes

k3s (local) GKE
imagePullSecret must create manually not needed, node has Workload Identity
Load Balancer none, need NodePort native L4/L7 LB support
Workload Identity unsupported natively supported
Cost free e2-small ~$38/80 days

k3s is good for local learning; GKE is closer to production. The next phase will run Prometheus + Grafana on GKE and remove ar-secret constraints.