Skip to main content

ArgoCD GitOps Deployment in Easy Steps

·1448 words·7 mins
Milad Zangeneh
Author
Milad Zangeneh

ArgoCD GitOps deployment overview

I recently set up a GitOps workflow for a few Python microservices I’ve been working on, and I wanted to share how I did it. The whole thing turned out to be pretty clean once the pieces clicked together, so hopefully this saves you some time if you’re going down the same path.

What I Started With
#

I had three Python applications (let’s call them application (a), application (b), and application (c)) that I built as part of a platform project. Each one had its own Dockerfile, and I built and pushed all three Docker images to my self-hosted Nexus registry. So at this point, the container images were sitting in Nexus, ready to be pulled by Kubernetes.

Next, I created a Helm chart for each application. Nothing fancy (just the standard deployment, service, ingress, and the usual Kubernetes resources). I packaged the charts and pushed them to the Helm repository in Nexus as well. So now Nexus was hosting both my Docker images and my Helm charts.

The missing piece was: how do I actually deploy these to my Kubernetes cluster in a repeatable, Git-driven way? That’s where ArgoCD and GitOps come in.

The Idea Behind the Setup
#

Instead of running helm install manually or writing CI/CD pipeline steps that talk to Kubernetes directly, I wanted a setup where I just push config to a Git repo and ArgoCD takes care of the rest. The repo wouldn’t contain any Helm charts (those are already in Nexus). It would only contain ArgoCD resources and the Helm values files for each app and environment.

Here’s the repo structure I ended up with:

platform-gitops/
└── argocd/
    ├── root.yaml
    ├── applicationsets/
    │   ├── dev-services.yaml        # ApplicationSet for dev
    │   └── prod-services.yaml       # ApplicationSet for prod
    └── values/
        ├── application-a/
        │   ├── dev.yaml
        │   └── prod.yaml
        ├── application-b/
        │   ├── dev.yaml
        │   └── prod.yaml
        └── application-c/
            ├── dev.yaml
            └── prod.yaml

Three layers:

  • A single root Application that bootstraps everything.
  • Two ApplicationSets that dynamically generate ArgoCD Applications (one for dev, one for prod).
  • Plain Helm values files, one per app per environment.

Let me walk through each piece.

Step 1 (The Root Application)
#

This is the only thing you apply manually (CLI or Argo CD UI). Once it’s in the cluster, ArgoCD watches the applicationsets/ directory in your Git repo and keeps everything in sync from there.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-gitops
  namespace: argocd
spec:
  project: default
  source:
    repoURL: git@github.com:your-org/platform-gitops.git
    path: argocd/applicationsets/
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      selfHeal: true
      prune: false

Before this works, ArgoCD needs credentials for both the private Git repository and the private Helm repository. If those repositories are not already registered in ArgoCD, the Applications will be created but won’t be able to sync.

Save this as argocd/root.yaml and apply it once:

kubectl apply -f argocd/root.yaml

I set selfHeal to true so that if anyone manually changes something in the cluster, ArgoCD reverts it (a bit like how an agent-based configuration management system such as Puppet keeps correcting drift when the live system no longer matches the catalog you defined). And I intentionally left prune as false on this root app (if the Git source has a hiccup, I don’t want ArgoCD to delete all my ApplicationSets in one go).

Step 2 (ApplicationSets for Dev and Prod)
#

An ApplicationSet is basically a template that generates multiple ArgoCD Applications. I used the list generator, which means I explicitly list each service and its chart details. It’s straightforward and easy to reason about.

Dev ApplicationSet
#

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: dev-services
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - name: application-a
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-a
            chartVersion: 0.1.0
            namespace: dev

          - name: application-b
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-b
            chartVersion: 0.1.0
            namespace: dev

          - name: application-c
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-c
            chartVersion: 0.1.0
            namespace: dev

  template:
    metadata:
      name: "{{name}}-dev"
    spec:
      project: default
      sources:
        - repoURL: "{{helmRepoURL}}"
          chart: "{{chart}}"
          targetRevision: "{{chartVersion}}"
          helm:
            valueFiles:
              - $values/argocd/values/{{name}}/dev.yaml

        - repoURL: git@github.com:your-org/platform-gitops.git
          targetRevision: main
          ref: values

      destination:
        server: https://kubernetes.default.svc
        namespace: "{{namespace}}"

      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

Prod ApplicationSet
#

The prod version looks almost the same. The only differences are the environment name in the generated app names, the namespace, and which values file gets used:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: prod-services
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - name: application-a
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-a
            chartVersion: 0.1.0
            namespace: prod

          - name: application-b
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-b
            chartVersion: 0.1.0
            namespace: prod

          - name: application-c
            helmRepoURL: https://nexus.example.com/repository/helm-charts/
            chart: application-c
            chartVersion: 0.1.0
            namespace: prod

  template:
    metadata:
      name: "{{name}}-prod"
    spec:
      project: default
      sources:
        - repoURL: "{{helmRepoURL}}"
          chart: "{{chart}}"
          targetRevision: "{{chartVersion}}"
          helm:
            valueFiles:
              - $values/argocd/values/{{name}}/prod.yaml

        - repoURL: git@github.com:your-org/platform-gitops.git
          targetRevision: main
          ref: values

      destination:
        server: https://kubernetes.default.svc
        namespace: "{{namespace}}"

      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

How Multi-Source Apps Work
#

The key thing to notice is the sources block. Each generated Application has two sources:

  1. The first source pulls the Helm chart from Nexus (where I pushed my charts earlier).
  2. The second source points to this same Git repo with ref: values, which creates a $values alias.

When ArgoCD sees $values/argocd/values/application-a/dev.yaml, it knows to fetch the chart from the Helm repo but overlay it with values from that file in Git. This is what makes the separation work (charts live in Nexus, config lives in Git). Just keep in mind that ArgoCD still needs access to both repositories up front.

Step 3 (Per-Environment Values Files)
#

These are just standard Helm values files. Each app gets a dev.yaml and a prod.yaml with the overrides for that environment. In these examples, I assume the referenced secrets already exist in the cluster and are managed separately.

Dev values for application (a)
#

replicaCount: 1

imagePullSecrets:
  - name: registry-credentials

ingress:
  enabled: true
  className: traefik
  hosts:
    - host: dev-app-a.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: dev-app-a-tls
      hosts:
        - dev-app-a.example.com

Prod values for application (a)
#

replicaCount: 2

imagePullSecrets:
  - name: registry-credentials

ingress:
  enabled: true
  className: traefik
  hosts:
    - host: prod-app-a.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: prod-app-a-tls
      hosts:
        - prod-app-a.example.com

Pretty simple. Prod gets more replicas and a different hostname. You can add whatever else your chart supports here (resource limits, environment variables, autoscaling rules, and so on).

Step 4 (Adding a New Application)
#

This is where the whole setup pays off. Say I build a fourth Python service, containerize it, push the image to Nexus, create a Helm chart for it, and push that to Nexus too. To get it deployed through ArgoCD, I only need to:

  1. Add a new entry to the list generator in both ApplicationSets:
- name: application-d
  helmRepoURL: https://nexus.example.com/repository/helm-charts/
  chart: application-d
  chartVersion: 0.1.0
  namespace: dev
  1. Create the values directory and files:
mkdir -p argocd/values/application-d

Then write dev.yaml and prod.yaml with the appropriate overrides.

  1. Commit and push.

That’s it. No clicking around in UIs, no running helm install, no updating CI pipelines. ArgoCD picks up the change from Git and creates the new Application automatically.

Step 5 (Applying and Verifying)
#

Once your repo is ready and you’ve applied the root app:

kubectl apply -f argocd/root.yaml

You can check that everything came up:

kubectl get applicationsets -n argocd
kubectl get applications -n argocd

You should see six Applications (three per environment), all synced and healthy.

A Few Practical Notes
#

Pin chart versions. Don’t use wildcards like *. Use a fixed version so you always know what is running, and test it in dev before prod.

Keep prune off on the root app. If your Git source is broken or temporarily empty, you don’t want ArgoCD deleting all your ApplicationSets by mistake.

Turn on selfHeal. If someone changes something by hand in the cluster, ArgoCD will bring it back to what is in Git.

Think about how prod should sync. In this example, prod auto-syncs from main to keep things simple. In a stricter setup, you might prefer manual sync, a release branch, or tags.

Use CreateNamespace. It lets ArgoCD create the dev and prod namespaces for you if they don’t exist yet.

Wrapping Up
#

This setup gave me a simple way to deploy my apps with Git. I build and push the image, publish the Helm chart, update the values or chart version in the GitOps repo, and ArgoCD takes care of the rest.

Once the structure is in place, adding a new service is quick.

I left a few production topics out on purpose. I didn’t cover AppProjects, secrets management, or access and SSO in this post. I’ll write separate posts about those, including ArgoCD with Keycloak. If you want to look into that now, the official ArgoCD Keycloak guide is a good place to start.