Building a Local GitOps Pipeline with ArgoCD on kind

Building a Local GitOps Pipeline with ArgoCD on kind


Introduction

In Part 1, we set up a multi-node cluster with kind and manually deployed a Spring Boot app (marketplace). The process involved building an image, loading it with kind load, writing Deployment YAML, and running kubectl apply.

It worked, but the problem is repeating this process every time. Changing image tags, editing YAML files, and running kubectl apply again whenever code changes is inefficient. It is also error-prone — forgetting to update a tag or deploying to the wrong environment.

In this post, we install ArgoCD and build a GitOps pipeline where a Git push automatically triggers deployment. Deployment configurations are managed declaratively in a Git repository, and ArgoCD watches that repository and automatically applies changes to the cluster.

This is the second post in the Local K8s Practice series.


What is GitOps

GitOps is simple. Git is the Single Source of Truth for deployments.

The difference from traditional deployment becomes clear in comparison:

AspectTraditionalGitOps
Deployment triggerSomeone runs kubectl applyGit push triggers auto-detection
State managementApplied directly to the cluster. No record anywhereAll state declared in Git
RollbackMust remember the previous YAMLJust git revert
AuditNo way to know who changed what and whenFull traceability via Git commit history

There are three core principles:

  1. Declarative definition: Declare the desired cluster state in YAML
  2. Git = truth: The state in Git is the desired state of the cluster
  3. Automatic sync: If Git and the cluster state differ, reconcile automatically

ArgoCD is a tool that implements these GitOps principles. It periodically polls the Git repository and automatically applies changes to the cluster when it detects them. Flux is a similar tool, but ArgoCD has an intuitive web UI that makes it easy to visually inspect the state.


Installing ArgoCD

We use Helm for the installation. You can also install with kubectl apply, but Helm makes upgrades and removal easier.

Adding the Helm Chart Repo

helm repo add argo https://argoproj.github.io/argo-helm
helm repo update

Installing ArgoCD

helm install argocd argo/argo-cd -n argocd --create-namespace

The --create-namespace flag automatically creates the argocd namespace.

Once installed, check the Pod status:

kubectl get pods -n argocd
NAME                                                READY   STATUS    RESTARTS   AGE
argocd-application-controller-0                     1/1     Running   0          60s
argocd-applicationset-controller-xxxxxxxxxx-xxxxx   1/1     Running   0          60s
argocd-dex-server-xxxxxxxxxx-xxxxx                  1/1     Running   0          60s
argocd-notifications-controller-xxxxxxxxxx-xxxxx    1/1     Running   0          60s
argocd-redis-xxxxxxxxxx-xxxxx                       1/1     Running   0          60s
argocd-repo-server-xxxxxxxxxx-xxxxx                 1/1     Running   0          60s
argocd-server-xxxxxxxxxx-xxxxx                      1/1     Running   0          60s

All 7 Pods should be in Running status. Here is a quick summary of each component:

ComponentRole
application-controllerCore component that compares Git and cluster state, then syncs
repo-serverClones/pulls Git repos and renders Helm/Kustomize manifests
serverWeb UI and API server
dex-serverSSO authentication (GitHub, LDAP, and other external identity providers)
redisCaching layer
notifications-controllerManages notifications to Slack, email, etc.
applicationset-controllerManages ApplicationSets (template-based multi-app management)

Accessing the Web UI

To access the ArgoCD server, you need port forwarding:

kubectl port-forward svc/argocd-server -n argocd 8080:443

Now open https://localhost:8080 in your browser. You will see a browser warning because of the self-signed certificate — ignore it and proceed.

Getting the Initial Password

ArgoCD stores the admin account’s initial password in a Secret at install time. Retrieve it with:

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

The output string is the password. Login credentials:

  • Username: admin
  • Password: the string from the command above

After logging in, you will see the ArgoCD dashboard. Since no Application has been registered yet, it will be an empty screen.

Tip: To change the password, install the ArgoCD CLI and run argocd account update-password. For local practice, the initial password is fine as-is.


Setting Up the marketplace-deploy Project

One important GitOps pattern is separating the app code repository from the deployment config repository.

marketplace/              <-- App source code (Spring Boot)
marketplace-deploy/       <-- Deployment config (Helm chart, values)

Why separate them?

  • Separation of concerns: Developers focus on app code; deployment config is managed independently
  • Deployment history isolation: App code commits and deployment config changes do not mix
  • Access control: In production, you can manage access to the deployment repo separately
  • Clear ArgoCD watch target: ArgoCD only watches the deploy repo. App code changes go through CI, which builds the image and updates the tag in the deploy repo

Project Structure

The marketplace-deploy repository (https://github.com/rhcwlq89/marketplace-deploy.git) has this structure:

marketplace-deploy/
└── helm/
    └── marketplace/
        ├── Chart.yaml
        ├── values.yaml
        └── templates/
            ├── deployment.yaml
            ├── service.yaml
            └── ingress.yaml

A single Helm chart manages the Deployment, Service, and Ingress. Let us walk through each file.

Chart.yaml

apiVersion: v2
name: marketplace
description: Marketplace Spring Boot application
type: application
version: 0.1.0
appVersion: "v1"
FieldDescription
apiVersion: v2Helm 3 chart format
nameChart name. Used in resource names
type: applicationDeployable chart. library type is for shared templates
versionThe chart’s own version
appVersionThe version of the app being deployed. Keeping this in sync with the image tag simplifies management

values.yaml

replicaCount: 2

image:
  repository: marketplace
  tag: v2
  pullPolicy: Never  # Loaded directly via kind load

service:
  type: ClusterIP
  port: 8080

ingress:
  enabled: true
  host: marketplace.local

resources:
  requests:
    cpu: 200m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

env:
  SPRING_PROFILES_ACTIVE: local

Here is what each value means:

ValueDescription
replicaCount: 2Runs 2 Pods. With 2 worker nodes, one Pod lands on each
image.pullPolicy: NeverUses images loaded via kind load, so it does not pull from a registry
service.type: ClusterIPAccessible only within the cluster. Exposed externally through Ingress
resources.requestsMinimum guaranteed resources for Pod scheduling. The scheduler uses this to select a node
resources.limitsMaximum resources a Pod can use. Exceeding causes OOMKilled (memory) or throttling (CPU)
envSets the Spring Boot profile to local

The difference between requests and limits is important. requests means “I need at least this much,” and limits means “I cannot use more than this.” Setting only requests allows unlimited resource usage. Setting only limits auto-sets requests to the same value. Setting both is the safest approach.

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
  labels:
    app: {{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        app: {{ .Chart.Name }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: 8080
          env:
            {{- range $key, $value := .Values.env }}
            - name: {{ $key }}
              value: "{{ $value }}"
            {{- end }}
          resources:
            requests:
              cpu: {{ .Values.resources.requests.cpu }}
              memory: {{ .Values.resources.requests.memory }}
            limits:
              cpu: {{ .Values.resources.limits.cpu }}
              memory: {{ .Values.resources.limits.memory }}
          readinessProbe:
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 30

This structure uses Helm template syntax ({{ }}) to inject values from values.yaml. {{ .Chart.Name }} resolves to the name field in Chart.yaml (marketplace).

A few important points to highlight:

readinessProbe and livenessProbe

ProbePurposeOn Failure
readinessProbeChecks if the Pod is ready to receive trafficRemoved from Service endpoints (traffic blocked)
livenessProbeChecks if the Pod is alivePod is restarted

Both use Spring Boot Actuator’s /actuator/health endpoint. The initialDelaySeconds differs between readiness (30s) and liveness (60s) for a reason. Spring Boot apps take time to start up. If liveness checks start too early, the app might still be booting and get flagged as “dead,” causing a restart loop. That is why the liveness initial delay is set longer.

env iteration syntax

{{- range $key, $value := .Values.env }}
- name: {{ $key }}
  value: "{{ $value }}"
{{- end }}

This iterates over the env map in values.yaml and injects environment variables. To add a new environment variable later, just add a key-value pair to the env section in values.yaml. No need to modify the template, which simplifies maintenance.

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: {{ .Chart.Name }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: 8080
      protocol: TCP
  selector:
    app: {{ .Chart.Name }}

A Service is a load balancer that sits in front of Pods. The selector finds Pods with the app: marketplace label and distributes traffic to them. Since the type is ClusterIP, it is only accessible within the cluster. External access is handled by Ingress.

ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Chart.Name }}
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ .Chart.Name }}
                port:
                  number: {{ .Values.service.port }}

This routes requests coming to the marketplace.local host to the marketplace Service. The Nginx Ingress Controller installed in Part 1 reads this Ingress rule and performs the actual routing.

To access marketplace.local locally, add the following to /etc/hosts:

127.0.0.1 marketplace.local

Registering the ArgoCD Application

Now it is time to tell ArgoCD: “Watch this Git repository and apply changes to the cluster.” This is defined as an Application resource.

Writing the Application YAML

Create an argocd-application.yaml file:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: marketplace
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/rhcwlq89/marketplace-deploy.git
    targetRevision: HEAD
    path: helm/marketplace
    helm:
      valueFiles:
        - values.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      selfHeal: true
      prune: true

This single YAML file is the core of the GitOps pipeline. Let us look at each field in detail:

source section

FieldDescription
repoURLThe Git repository URL to watch
targetRevision: HEADAlways tracks the latest commit. You can also specify a branch or tag
pathPath to the Helm chart within the repository
helm.valueFilesValues file to use. For environment separation, you can use values-dev.yaml, values-prod.yaml, etc.

destination section

FieldDescription
serverTarget cluster. https://kubernetes.default.svc refers to the same cluster where ArgoCD is installed
namespaceNamespace to deploy resources into

syncPolicy section

This is the heart of the automation:

OptionDescription
automatedAutomatically syncs when Git changes are detected. Without this, you must manually click the Sync button
selfHeal: trueIf someone manually changes the cluster via kubectl, ArgoCD reverts it to the Git state. Enforces the “Git is truth” principle
prune: trueIf a resource is deleted from Git, it is also deleted from the cluster. Without this, deleting a file in Git leaves the resource in the cluster

Why is selfHeal important? In practice, someone inevitably says “it is urgent, let me just kubectl edit this real quick.” This creates drift — a mismatch between Git and the cluster state. With selfHeal enabled, ArgoCD detects such manual changes and reverts them to the Git state. It may feel restrictive at first, but it is a powerful safeguard for long-term environment consistency.

Registering the Application

kubectl apply -f argocd-application.yaml
application.argoproj.io/marketplace created

Check the status after registration:

kubectl get applications -n argocd
NAME          SYNC STATUS   HEALTH STATUS
marketplace   Synced        Healthy

Synced + Healthy means the Git repository state has been successfully applied to the cluster.

The ArgoCD web UI (https://localhost:8080) gives a more intuitive view. The marketplace Application card shows up in green, and clicking it reveals a tree diagram showing the relationships between all resources — Deployment, Service, Ingress, Pods, and more.


Verifying the GitOps Pipeline

Let us confirm that a Git push actually triggers a deployment.

Changing the Image Tag

In the marketplace-deploy repository, change the image tag in values.yaml:

image:
  repository: marketplace
  tag: v3    # Changed from v2 to v3
  pullPolicy: Never

Prerequisite: The new image (marketplace:v3) must be built and loaded via kind load beforehand. In a local environment without CI automation, this part is manual.

docker build -t marketplace:v3 .
kind load docker-image marketplace:v3 --name marketplace

Git Push

cd marketplace-deploy
git add helm/marketplace/values.yaml
git commit -m "chore: update image tag to v3"
git push origin main

Confirming ArgoCD Auto-Sync

ArgoCD polls the Git repository every 3 minutes by default. When it detects changes, it automatically performs a sync.

Tip: If you do not want to wait 3 minutes, click the Refresh button in the web UI, or run argocd app sync marketplace via the ArgoCD CLI for an immediate sync.

Once sync completes, you can watch new Pods being created:

kubectl get pods -w
NAME                           READY   STATUS              RESTARTS   AGE
marketplace-xxxxxxxxxx-aaaaa   1/1     Running             0          5h
marketplace-xxxxxxxxxx-bbbbb   1/1     Running             0          5h
marketplace-xxxxxxxxxx-ccccc   0/1     ContainerCreating   0          5s
marketplace-xxxxxxxxxx-ddddd   0/1     ContainerCreating   0          5s
marketplace-xxxxxxxxxx-ccccc   1/1     Running             0          35s
marketplace-xxxxxxxxxx-ddddd   1/1     Running             0          35s
marketplace-xxxxxxxxxx-aaaaa   1/1     Terminating         0          5h
marketplace-xxxxxxxxxx-bbbbb   1/1     Terminating         0          5h

Kubernetes’ Rolling Update strategy ensures new Pods come up first (ContainerCreating then Running), followed by old Pods shutting down (Terminating). The update proceeds with zero downtime.

Verify the deployed image tag:

kubectl get deployment marketplace -o jsonpath="{.spec.template.spec.containers[0].image}"
marketplace:v3

The image has been successfully updated to v3.

End-to-End Flow Summary

Here is the complete GitOps pipeline flow:

Developer: Change image.tag to v3 in values.yaml
  |
Developer: git commit & push
  |
ArgoCD: Poll Git repo -> Detect changes
  |
ArgoCD: Render Helm chart (with updated values.yaml)
  |
ArgoCD: Compare rendered result with current cluster state (diff)
  |
ArgoCD: Apply changes via kubectl apply if there is a diff
  |
Kubernetes: Rolling Update replaces Pods
  |
Result: Service running with new image (zero downtime)

The developer’s job is just two steps: edit values.yaml and push. ArgoCD handles everything else automatically.


Verifying selfHeal in Action

Let us also confirm how selfHeal: true works in practice.

Manually change the replica count:

kubectl scale deployment marketplace --replicas=1

Check again after a moment:

kubectl get pods
NAME                           READY   STATUS    RESTARTS   AGE
marketplace-xxxxxxxxxx-aaaaa   1/1     Running   0          2m
marketplace-xxxxxxxxxx-bbbbb   1/1     Running   0          15s

ArgoCD detected that “Git says replicaCount: 2 but the cluster has only 1” and automatically restored it to 2.

This is the power of selfHeal. If someone accidentally changes a setting during operation, it is automatically reverted to the Git state. The “Git is truth” principle is technically enforced.


Summary

Here is a recap of what this post covered:

StepDescription
GitOps conceptGit = Single Source of Truth. Declarative state management
ArgoCD installationInstall via Helm, access web UI, retrieve initial password
Deploy repo setupWrite Helm chart in the marketplace-deploy project
Application registrationConnect Git repo to ArgoCD, configure automated sync
VerificationChange values.yaml, push, confirm auto-deployment

Current CI/CD status:

CI (build):  Manual -- docker build -> kind load
CD (deploy): Automated -- ArgoCD detects Git changes -> auto-sync

The CI part is still manual. In a production environment, CI tools like GitHub Actions automate this as well. The full flow becomes: code push, image build, registry push, and deploy repo tag update — all automated.

For local practice, however, this is sufficient. The goal is to experience the core concept of “Git push triggers automatic deployment” firsthand.

In the next post, we will install Loki + Grafana on this cluster to collect and visualize Pod logs for a monitoring setup. Deployment is now automated, so it is time to verify “is the deployed app actually running well.”

This post is part of the Coupang Partners program, and a commission is earned from qualifying purchases.