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]
endflowchart 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"] --- ACflowchart 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
|
|
After all pods are Running, access UI:
|
|
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:
|
|
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:
|
|
| 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 |
|
|
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:
|
|
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:
|
|
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 updatesequenceDiagram
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.