How to Add a 3rd-Party Helm Chart to Your GitOps Repo

2026/02/18

BUILDkubernetes🍞

I watched a coworker demo how to add Nexus Repository Manager to our Kubernetes cluster. Helm pull, kustomize overlay, ArgoCD application, targetRevision tricks, node scheduling. It looked straightforward. Then I tried to do it myself and realized I didn’t actually understand any of the steps — just the vibes.

So I reverse-engineered the whole thing. Here’s the playbook I wish someone had handed me — the full process of vendoring a 3rd-party Helm chart into a GitOps repo, deploying it with ArgoCD + Kustomize, and all the little things nobody explains.

The concepts — what even are these things

Before anything else, let’s define the tools. Because I was nodding along in meetings pretending I knew what “kustomize overlay” meant.

Helm = package manager for Kubernetes

Kubernetes needs YAML files to deploy anything — Deployment, Service, Ingress, PVC, etc. For Nexus, that’s ~10 YAML files. Writing them from scratch every time is painful.

Helm packages those YAML files into a chart — a reusable template with configurable defaults. Think npm for Kubernetes.

Chart = folder of templated YAML + a values.yaml with defaults

When you install a chart, Helm takes the templates, fills in the values, and applies the resulting YAML to Kubernetes:

templates/deployment.yaml (has {{ .Values.image }})
  + values.yaml (image: sonatype/nexus3:3.64.0)
  = final deployment.yaml (image: sonatype/nexus3:3.64.0)

📖 Official Helm docs — three big concepts

Kustomize = patching YAML without templates

Kustomize doesn’t use templates at all. It takes existing YAML and patches it — change a namespace, add a label, override a value. The key concept is overlays: a base layer + per-environment patches on top.

base/          ← the original YAML (shared across environments)
overlays/
  staging/     ← "take base, change namespace to staging"
  prod/        ← "take base, add resource limits, change replicas"

📖 Kustomize on kubernetes.io

How they work together

This is the part that confused me most. You can use Helm alone, Kustomize alone, or — like our repo — Kustomize wrapping Helm.

Kustomize can render a Helm chart instead of using helm install. This gives you Helm’s templating power + Kustomize’s overlay pattern:

Kustomize reads kustomization.yaml
    → Finds Helm chart at ../../base-64.2.0
    → Renders templates using overlay values.yaml
    → Sets namespace to "nexus"
    → Outputs final Kubernetes YAML
    → ArgoCD applies it to the cluster

Same chart, different values per cluster, all in git. That’s the whole point.

📖 ArgoCD + Kustomize integration

ArgoCD = watches git, applies to Kubernetes

ArgoCD reads the Kustomize output and kubectl applys it. Continuously. It watches your repo, detects changes, and re-syncs automatically. You push to git, things deploy. That’s GitOps.

📖 ArgoCD getting started

The architecture — what talks to what

Now that you know the pieces, here’s how they connect:

You (git push to branch)
    │
    ▼
GitHub repo ("mango")
    │
    ▼
ArgoCD (watches repo, reads Application CR)
    │
    ▼
Kustomize (renders vendored Helm chart + overlay values)
    │
    ▼
Kubernetes (creates Deployment, Service, Ingress, PVC, etc.)
    │
    ▼
Pod lands on a node (controlled by affinity/tolerations)

ArgoCD is the orchestrator. It watches your git repo, sees changes, and applies them to the cluster. But it doesn’t run helm install — it uses Kustomize to template the Helm chart, which gives you the layering you need for per-cluster overrides.

The repo is the source of truth. You don’t kubectl apply anything by hand. You push to git, ArgoCD syncs.

What you actually write — it’s only 3 files

Here’s the thing that surprised me. After vendoring the Helm chart (which is just a copy), the actual original work is 3 files, ~40 lines of YAML. That’s it.

Everything under base-64.2.0/ is copied from Sonatype’s Helm chart — 24 files, untouched. The only files you write from scratch:

File 1: ArgoCD Application CR (~25 lines)

Tells ArgoCD “deploy this thing from this path in this repo.”

File 2: Kustomize config (~13 lines)

Tells Kustomize “render this Helm chart with these values into this namespace.”

File 3: Overlay values (~2-10 lines)

Your actual customizations. In our case, just ingress: enabled: true.

The whole job is: copy a chart, write 3 small YAML files, push. Everything else is understanding what goes in those files — which is what this post is about.

Step 1: Pull the Helm chart

Every 3rd-party app starts as someone else’s Helm chart. Sonatype publishes the Nexus chart. Grafana publishes theirs. You pull it.

# Add the remote Helm repo
helm repo add sonatype https://sonatype.github.io/helm3-charts/
helm repo update

# Pull a specific version (always pin versions)
helm pull sonatype/nexus-repository-manager --version 64.2.0

# You get a .tgz file
ls
# nexus-repository-manager-64.2.0.tgz

That .tgz is the whole chart — templates, default values, helpers, tests. Everything.

Step 2: Vendor it into your repo

Here’s where it gets opinionated. You don’t point ArgoCD at the remote Helm repo. You extract the chart and commit it directly into your git repo.

tar xzf nexus-repository-manager-64.2.0.tgz

# Move into your repo's 3rdparty directory
mv nexus-repository-manager \
  applications/3rdparty/nexus-repository-manager/base-64.2.0/nexus-repository-manager

Why vendor instead of pulling at deploy time?

Same idea as go vendor or committing a lock file. You trade repo size for reliability.

The directory structure ends up like this:

applications/3rdparty/nexus-repository-manager/
├── application-falcon-phx-ca-staging.yaml      # ArgoCD Application     ← YOU WRITE THIS
├── base-64.2.0/                                # Vendored chart         ← COPIED
│   └── nexus-repository-manager/
│       ├── Chart.yaml
│       ├── values.yaml                         # Upstream defaults (don't edit)
│       └── templates/
│           ├── deployment.yaml
│           ├── service.yaml
│           ├── ingress.yaml
│           ├── pvc.yaml
│           └── ...
└── overlays/
    └── falcon-phx-ca-staging/                  # Per-cluster config
        ├── kustomization.yaml                  # ← YOU WRITE THIS
        └── values.yaml                         # ← YOU WRITE THIS

Step 3: Create the Kustomize overlay

Kustomize is the layer between “generic Helm chart” and “what actually runs in this specific cluster.” It renders the Helm templates with your values and sets the namespace.

overlays/<cluster>/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

helmGlobals:
  chartHome: ../../base-64.2.0          # Where the vendored chart lives

helmCharts:
  - name: nexus-repository-manager
    namespace: nexus                     # All resources go here
    valuesFile: values.yaml              # Your overlay values
    includeCRDs: true

And the overlay values — overlays/<cluster>/values.yaml:

ingress:
  enabled: true

# Pod scheduling — make sure it lands on service nodes
nodeSelector:
  agentpool: service

# Resource limits — Sonatype recommends 4 CPU / 8Gi minimum
resources:
  requests:
    cpu: 4
    memory: 8Gi
  limits:
    cpu: 4
    memory: 8Gi

Important: Don’t edit the upstream values.yaml in base-64.2.0/. Keep it pristine. When you upgrade the chart, you want to diff cleanly against the new upstream defaults. All your customizations go in the overlay.

Step 4: Create the ArgoCD Application

This is the file that tells ArgoCD “deploy this thing.”

application-<cluster>.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: nexus-repository-manager        # Must be unique in ArgoCD
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io  # Clean up on delete
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/your-repo
    targetRevision: main                 # Which git ref to watch
    path: applications/3rdparty/nexus-repository-manager/overlays/<cluster>
  destination:
    server: https://kubernetes.default.svc
    namespace: nexus                     # Must match kustomization.yaml
  syncPolicy:
    automated:
      prune: true                        # Delete resources removed from git
      selfHeal: true                     # Revert manual kubectl changes
    syncOptions:
      - CreateNamespace=true             # Create namespace if missing

A few things to get right:

How ArgoCD discovers your app — the app-of-apps

Here’s something that tripped me up: you don’t manually register Applications in ArgoCD. There’s a root Application (the “app-of-apps”) that auto-discovers them.

In our repo, it looks like this:

# applications-falcon-phx-ca-staging.yaml (the root app)
spec:
  source:
    repoURL: https://github.com/your-org/mango
    targetRevision: HEAD
    path: .
    directory:
      recurse: true
      include: "**/application-falcon-phx-ca-staging.yaml"

It recursively scans the entire repo on main for any file matching that name. Every file it finds becomes an ArgoCD Application. Drop your file in, merge to main, and it appears in the ArgoCD UI automatically.

The gotcha: The root app watches main. If your Application CR only exists on a feature branch, ArgoCD never sees it. You either need to:

  1. Merge to main first — then the app-of-apps picks it up
  2. kubectl apply the CR directly — bypasses discovery, creates it immediately
  3. Have an ArgoCD admin create it — if you don’t have kubectl access

I didn’t have admin permissions and couldn’t kubectl apply, so I went with option 1 — merge the PR with targetRevision pointing at my dev branch. The app-of-apps creates the Application, and ArgoCD immediately starts watching my dev branch for changes.

RBAC — why you might not have permission

When I tried to create the Application from the ArgoCD UI, I got “permission denied.” Turns out ArgoCD has its own RBAC (Role-Based Access Control) — separate from Kubernetes RBAC.

RBAC answers one question: “Is this person allowed to do this thing?” Three concepts:

Concept What it is Example
Subject Who’s asking Your GitHub team
Role A named bag of permissions role:readonly, role:resync
Policy What a role can do applications, sync, */*, allow

The config lives in argocd-rbac-cm ConfigMap:

policy.csv: |
  # Define what the role can do
  p, role:resync, applications, sync, */*, allow

  # Assign GitHub teams to roles
  g, your-org:your-team, role:readonly
  g, your-org:your-team, role:resync

Read it like English: “Policy: resync role can sync applications. Group: your-team gets readonly and resync roles.”

My team had readonly + resync — we could view and sync apps, but not create them. That’s by design. The app-of-apps pattern means humans don’t create Applications manually — they push to git and the root app handles it.

Step 5: The targetRevision trick — fast iteration

This is the single most useful thing I learned.

By default, targetRevision: main means ArgoCD only picks up merged changes. Your workflow becomes: push → PR → review → merge → wait for sync. Slow.

For development, point it at a separate dev branch:

# TODO: Change back to 'main' before merging PR
targetRevision: shiyuanzheng/dev

Now every push to your dev branch shows up in ArgoCD within ~3 minutes. Push, watch the ArgoCD UI, see your changes deploy. Fix something, push again. Instant feedback.

Why a separate branch? Keep your Application CR changes on one branch (the PR branch) and point targetRevision at a different branch for iteration. This way your PR branch stays clean, and you can push freely to the dev branch without worrying about PR noise.

shiyuanzheng/nexus-repository-manager   ← PR branch (clean, for review)
    │
    └── Application CR says: targetRevision: shiyuanzheng/dev
                                                │
shiyuanzheng/dev                        ← iteration branch (push freely)
    │
    └── ArgoCD watches this, syncs within ~3 min

The catch: You must change targetRevision back to main before merging your PR. If you forget, ArgoCD keeps watching a branch that might get deleted.

Step 6: Pod scheduling — where things actually run

Kubernetes clusters aren’t homogeneous. You’ll have different node pools — service nodes, CI runner nodes, GPU nodes, spot instances. You need to tell Kubernetes where your pod should land.

Three mechanisms, from simple to powerful:

nodeSelector — the simple one

nodeSelector:
  agentpool: service

“Only schedule me on nodes labeled agentpool=service.” Binary — it matches or it doesn’t. No partial preferences.

Node affinity — the flexible one

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: agentpool
              operator: In
              values:
                - service
                - service-large

Same idea, but you can say “service OR service-large” and mix required vs. preferred rules.

Tolerations — the bouncer system

Nodes can have taints — “don’t schedule anything here unless you explicitly tolerate it.” It’s like a velvet rope.

# Node taint (set by cluster admin):
#   kubernetes.azure.com/scalesetpriority=spot:NoSchedule
# Translation: "I'm a spot instance, don't run here unless you're okay with preemption"

# Your toleration (in values.yaml):
tolerations:
  - key: "kubernetes.azure.com/scalesetpriority"
    operator: "Equal"
    value: "spot"
    effect: "NoSchedule"

How to find the right values: Look at an existing app in your repo that deploys to the same cluster. Check their overlay values.yaml for nodeSelector, affinity, and tolerations. Or ask your cluster admin:

# See node labels
kubectl get nodes --show-labels | grep agentpool

# See node taints
kubectl describe node <node-name> | grep Taints

Step 7: Verify it works

After pushing, check these things:

# Is the ArgoCD Application synced?
# Check the ArgoCD UI, or:
argocd app get nexus-repository-manager

# Are pods running?
kubectl get pods -n nexus

# Are they on the right nodes?
kubectl get pods -n nexus -o wide
# The NODE column tells you where each pod landed

# Is the service reachable?
kubectl port-forward svc/nexus-repository-manager 8081:8081 -n nexus
# Then hit http://localhost:8081

The copy-paste trap

Real talk — most of this work is copying an existing app’s setup and modifying it. That’s fine. That’s how it’s done. But copy-paste is where the worst bugs hide.

Every time I reviewed the Nexus branch, I found another artifact from the arc-runner-set it was copied from: wrong metadata.name, wrong destination.namespace, stale comments. These aren’t syntax errors — the YAML is valid, Kustomize renders fine, ArgoCD syncs happily. You just end up deploying to the wrong namespace or overwriting another app’s ArgoCD entry.

Checklist after copy-pasting:

Quick reference — the full checklist

- [ ] helm repo add + helm pull (pin version)
- [ ] Extract and vendor into base-<version>/
- [ ] Create overlay kustomization.yaml (chartHome, namespace, valuesFile)
- [ ] Create overlay values.yaml (ingress, scheduling, resources)
- [ ] Create ArgoCD Application YAML
      - [ ] Unique metadata.name
      - [ ] Correct destination.namespace
      - [ ] targetRevision = your dev branch
- [ ] Push to branch, merge to main (app-of-apps discovers it)
- [ ] Iterate on shiyuanzheng/dev branch
- [ ] Verify in ArgoCD UI
- [ ] kubectl get pods -n <ns> -o wide (check node placement)
- [ ] Before final merge: targetRevision back to main

That’s the whole process. It’s not complicated once you see the full picture — it’s just that nobody shows you the full picture. They show you one step, assume you know the rest, and you spend a week figuring out why your pod is in the wrong namespace.