ArgoCD — Air-Gapped Installation¶
This guide installs Argo CD into an air-gapped Kubernetes cluster using the standard mirror-then-install Helm pattern: charts and container images are mirrored to the internal registry on a connected host, then installed into the cluster, which never reaches the internet. The GitOps pipeline (connecting repositories, App-of-Apps, day-2 workflow) is covered separately in ArgoCD GitOps Pipeline.
Conventions used in this guide
Replace the placeholders below with your environment's values.
| Placeholder | Meaning | Example |
|---|---|---|
<GIT_HOST> |
Internal Git + Helm registry host | gitlab.example.internal |
<HELM_REPO> |
Helm HTTP registry URL | https://<GIT_HOST>/api/v4/projects/<id>/packages/helm/stable |
<REGISTRY> |
Container image registry + project path | registry.example.internal/platform |
<ARGOCD_HOST> |
ArgoCD ingress hostname | argocd.example.internal |
<INGRESS_IP> |
Ingress controller address | 10.0.0.10 |
Commands run on a connected host (with registry + internet access, referred to as the bastion)
or the cluster as noted. <GIT_USER> / <GIT_TOKEN> are the registry credentials.
Security — use v3.4.2 or later
Argo CD v3.4.1 and earlier 3.4.x builds carry two unpatched CVEs. v3.4.2 is the minimum safe version.
| CVE | Severity | Issue |
|---|---|---|
| CVE-2026-45738 | High (CVSS 7.3) | Stored XSS in app link annotations — a developer can escalate to admin via JavaScript injection |
| CVE-2026-45737 | Moderate (CVSS 6.3) | Kubernetes Secret values exposed in diffs via the kubectl.io/last-applied-configuration annotation |
| CVE-2026-42880 | Critical | Plaintext secret extraction via ServerSideDiff — affects 3.2.0–3.3.8 only; the 3.4.x branch is not affected |
Helm chart 9.5.15 (app version v3.4.2) is confirmed patched for all three. Verify the current
fixed version against the Argo CD security advisories before installing.
This is a reusable pattern
The sequence below applies to any Helm-packaged application in an air-gapped cluster — the same steps are used for cert-manager, External Secrets Operator, Reloader, HashiCorp Vault, and any other add-on. Learn it once, repeat it for every tool.
Why mirror before installing¶
Cluster nodes have no internet access. A direct install fetches the manifest but then fails when nodes
try to pull images from quay.io / ghcr.io / docker.io, leaving pods in ImagePullBackOff.
The correct air-gapped flow keeps all pulls internal:
Connected host (bastion)
→ pull Helm chart tarball
→ pull container images
→ push everything to the internal registry
↓
Cluster (no internet)
→ helm install from the internal Helm registry
→ nodes pull images from the internal registry ✓
The air-gap install pattern¶
| Step | Action | Tool |
|---|---|---|
| 1 | Add the upstream Helm repo on the bastion | helm repo add |
| 2 | Pull the chart tarball | helm pull --version <ver> -d /tmp/ |
| 3 | Push the chart to the internal Helm registry | helm push / curl POST |
| 4 | Identify the exact image versions the chart uses | helm show values … \| grep -E "repository\|tag" |
| 5 | Pull → retag → push each image to the internal registry | docker pull → docker tag → docker push |
| 6 | Write a values file overriding every image.repository to <REGISTRY> |
— |
| 7 | Deploy from the internal registry | helm install / ArgoCD Application |
Prerequisites¶
# On the bastion — the internal Git/registry must be reachable
curl -sk https://<GIT_HOST> -o /dev/null -w "%{http_code}\n" # expect 200/302
helm version # v3.x
docker version | grep Version
- cert-manager must already be installed (it provides the internal CA that ArgoCD trusts in Phase 3).
- The ingress controller (e.g. ingress-nginx) must be running and reachable at
<INGRESS_IP>.
Phase 1 — Mirror ArgoCD to the internal registry¶
1.1 Add the Helm repo and find versions¶
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
helm search repo argo/argo-cd
Capture the versions from your output:
1.2 Pull and push the Helm chart¶
helm pull argo/argo-cd --version ${ARGOCD_CHART} -d /tmp/
# Push to the internal Helm HTTP registry
curl -sk -u "<GIT_USER>:<GIT_TOKEN>" \
-X POST "<HELM_REPO>/api/charts" \
-F "chart=@/tmp/argo-cd-${ARGOCD_CHART}.tgz"
1.3 Identify the exact image versions¶
helm show values argo/argo-cd --version ${ARGOCD_CHART} \
| grep -E "^\s+repository:|^\s+tag:" | grep -v "#" | head -20
Capture the tags from your output (chart 9.x uses these three images):
1.4 Mirror the container images¶
Argo CD needs three images. Pull on the bastion, retag to the internal registry, push:
docker login <REGISTRY> --username <GIT_USER> --password-stdin <<< "<GIT_TOKEN>"
# Argo CD — used by server, repo-server, application-controller,
# applicationset-controller, notifications-controller
docker pull quay.io/argoproj/argocd:${ARGOCD_APP}
docker tag quay.io/argoproj/argocd:${ARGOCD_APP} <REGISTRY>/argocd:${ARGOCD_APP}
docker push <REGISTRY>/argocd:${ARGOCD_APP}
# Redis — used by argocd-redis (chart 9.x pulls from AWS ECR Public, not Docker Hub)
docker pull public.ecr.aws/docker/library/redis:${REDIS_TAG}
docker tag public.ecr.aws/docker/library/redis:${REDIS_TAG} <REGISTRY>/redis:${REDIS_TAG}
docker push <REGISTRY>/redis:${REDIS_TAG}
# Dex — used by argocd-dex-server (OIDC / SSO)
docker pull ghcr.io/dexidp/dex:${DEX_TAG}
docker tag ghcr.io/dexidp/dex:${DEX_TAG} <REGISTRY>/dex:${DEX_TAG}
docker push <REGISTRY>/dex:${DEX_TAG}
1.5 Verify the mirror¶
Confirm all four artifacts are present in the internal registry:
| Artifact | Type | Version |
|---|---|---|
argo-cd |
Helm chart | 9.5.15 |
argocd |
Container image | v3.4.2 |
redis |
Container image | 8.2.3-alpine |
dex |
Container image | v2.45.1 |
Phase 2 — Deploy ArgoCD from the internal registry¶
From here the cluster needs zero internet access — everything comes from the internal registry.
2.1 Create the namespace¶
2.2 Write the values file¶
Override every image repository to the internal registry, and use literal version strings.
Do not put shell variables in the values file
Helm is not a shell — it does not expand ${VAR} inside a YAML file. A tag like
tag: "${REDIS_TAG}" is passed literally to Kubernetes, which rejects it with InvalidImageName.
The helm install still reports STATUS: deployed; the failure only surfaces when the pod tries to
pull the image. Always write real version strings (or render with envsubst first in CI).
cat > /tmp/argocd-values.yaml << 'EOF'
global:
image:
repository: <REGISTRY>/argocd
tag: "v3.4.2" # patched for CVE-2026-45737 / -45738 / -42880
redis:
image:
repository: <REGISTRY>/redis
tag: "8.2.3-alpine" # chart 9.x sources Redis from AWS ECR Public
dex:
image:
repository: <REGISTRY>/dex
tag: "v2.45.1"
configs:
params:
server.insecure: "true" # ArgoCD serves plain HTTP; ingress terminates TLS
server:
service:
type: ClusterIP # internal only — ingress routes externally
ingress:
enabled: true
ingressClassName: nginx
hostname: <ARGOCD_HOST> # chart 9.x uses hostname: (singular), NOT hosts: []
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
tls: true
EOF
Chart 9.x uses hostname:, not hosts:
Argo CD Helm chart 9.x changed the ingress schema from the array form (hosts: [...]) to a single
hostname: string. The old form is silently ignored and the ingress falls back to the chart
default (argocd.example.com) with no error. If kubectl get ingress -n argocd shows the wrong host,
switch to hostname: + tls: true and run helm upgrade.
Why ClusterIP + Ingress (not NodePort)
NodePort exposes a raw port on every node and is not suitable for production. ClusterIP + Ingress is the standard pattern: the service is internal-only and the ingress controller handles TLS termination and host-based routing.
2.3 Deploy¶
helm install argocd oci://<REGISTRY>/argo-cd \
--version 9.5.15 -n argocd \
-f /tmp/argocd-values.yaml \
--wait --timeout 10m
kubectl get pods -n argocd -w, or add --debug to the
install to surface template-rendering issues.
2.4 Verify¶
All ofargocd-application-controller, -applicationset-controller, -dex-server,
-notifications-controller, -redis, -repo-server, -server should be Running.
Confirm every image comes from the internal registry — nothing from the internet:
kubectl get pods -n argocd \
-o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
# Every line must start with <REGISTRY>/
Phase 3 — Trust the internal CA¶
So ArgoCD can pull charts/repos from the internal Git host over HTTPS without insecure: true, add the
internal CA to its trust store. (cert-manager must already have created the CA.)
# Extract the internal CA certificate from cert-manager
kubectl get secret -n cert-manager <ca-secret> \
-o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/internal-ca.crt
head -1 /tmp/internal-ca.crt # expect: -----BEGIN CERTIFICATE-----
# Add it to ArgoCD's per-host trusted-cert ConfigMap
kubectl create configmap argocd-tls-certs-cm \
--from-file=<GIT_HOST>=/tmp/internal-ca.crt \
-n argocd --dry-run=client -o yaml | kubectl apply -f -
# The repo-server reads the cert store on startup — restart it
kubectl rollout restart deployment/argocd-repo-server -n argocd
kubectl rollout status deployment/argocd-repo-server -n argocd --timeout=120s
Which hosts need an entry?
Only add a hostname to argocd-tls-certs-cm if ArgoCD connects to it as a chart or Git source.
Services ArgoCD merely deploys (e.g. Vault) do not need an entry. If all internal services share
one root CA, reuse the same certificate file for every entry, then restart the repo-server.
Phase 4 — Access and CLI setup¶
4.1 DNS resolution¶
<ARGOCD_HOST> must resolve — via internal DNS, or /etc/hosts on any machine running the argocd CLI
or opening the UI:
The CLI host needs the entry too
The argocd CLI resolves <ARGOCD_HOST> locally. Without the entry, commands fail with
dial tcp: lookup <ARGOCD_HOST> ... server misbehaving before any connection is attempted.
4.2 Retrieve the initial admin password¶
kubectl get secret argocd-initial-admin-secret -n argocd \
-o jsonpath="{.data.password}" | base64 -d && echo
4.3 Install the CLI¶
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -sSL -o /tmp/argocd \
"https://github.com/argoproj/argo-cd/releases/download/${ARGOCD_APP}/argocd-linux-${ARCH}"
chmod +x /tmp/argocd && sudo mv /tmp/argocd /usr/local/bin/argocd
argocd version --client
4.4 Log in and rotate the password¶
argocd login <ARGOCD_HOST> --username admin --password <initial-password> --insecure
argocd account update-password --current-password <initial-password> --new-password <new-password>
ArgoCD is live
Open https://<ARGOCD_HOST> and log in as admin. Proceed to
ArgoCD GitOps Pipeline to connect repositories and set up App-of-Apps.
Summary¶
| Item | Value |
|---|---|
| UI | https://<ARGOCD_HOST> |
| Namespace | argocd |
| Service type | ClusterIP (ingress terminates TLS) |
| Helm chart | oci://<REGISTRY>/argo-cd (9.5.15 / app v3.4.2) |
| Images | <REGISTRY>/{argocd,redis,dex} |
Reference — pattern for other add-ons¶
Every cluster add-on follows the same three moves:
# A. Chart → internal Helm registry
helm repo add <repo> <url> && helm repo update
helm pull <repo>/<chart> --version <ver> -d /tmp/
curl -sk -u "<GIT_USER>:<GIT_TOKEN>" -X POST "<HELM_REPO>/api/charts" -F "chart=@/tmp/<chart>-<ver>.tgz"
# B. Images → internal container registry
docker pull <source>/<image>:<tag>
docker tag <source>/<image>:<tag> <REGISTRY>/<image>:<tag>
docker push <REGISTRY>/<image>:<tag>
# C. Reference the internal Helm registry in the ArgoCD Application
# repoURL: '<HELM_REPO>' chart: <chart> targetRevision: <ver>
Two registries, one rule
| Artifact | Registry | Protocol |
|---|---|---|
| Helm charts | Helm HTTP registry (<HELM_REPO>) |
HTTP (curl POST / helm push) |
| Container images | Container registry (<REGISTRY>) |
OCI (docker push) |
Helm charts are served over HTTP because some charts exceed ArgoCD's OCI layer limit; container images always use OCI — the only protocol the container runtime speaks.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
ImagePullBackOff after install |
Image not mirrored | Re-run Phase 1.4; kubectl describe pod -n argocd <pod> |
helm install — chart not found |
Chart not pushed | Re-run Phase 1.2; check <HELM_REPO>/index.yaml for argo-cd |
Pods stuck Pending |
Namespace missing | kubectl create namespace argocd |
<ARGOCD_HOST> not resolving |
/etc/hosts / DNS not set on the client and the CLI host |
Add the <INGRESS_IP> <ARGOCD_HOST> entry |
Browser 404 on <ARGOCD_HOST> |
Wrong ingress schema — hosts: array ignored by chart 9.x |
Use hostname: + tls: true; helm upgrade |
Ingress ADDRESS empty |
Ingress controller not ready | kubectl get pods -n <ingress-ns> |
helm push / curl POST → 401 |
Wrong or missing registry credentials | Verify <GIT_USER> / <GIT_TOKEN> |
Values file sends literal ${TAG} as image tag |
Helm does not expand shell variables in YAML | Use literal strings, or render with envsubst before helm install |
helm install shows no progress |
Default output prints only on completion | Add --debug, or kubectl get pods -n argocd -w |
Restarting a Git server on a ReadWriteOnce PVC
If your internal Git/registry runs on a ReadWriteOnce PVC, never kubectl rollout restart it —
the new pod is created before the old one releases the volume, causing a FailedAttachVolume
deadlock. Scale to 0, wait, then scale to 1 instead: