Skip to content

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 pulldocker tagdocker 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
NAME          CHART VERSION   APP VERSION   DESCRIPTION
argo/argo-cd  9.5.15          v3.4.2        A Helm chart for Argo CD...

Capture the versions from your output:

export ARGOCD_CHART="9.5.15"   # CHART VERSION
export ARGOCD_APP="v3.4.2"     # APP VERSION

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):

export REDIS_TAG="8.2.3-alpine"    # redis.image.tag
export DEX_TAG="v2.45.1"           # dex.image.tag

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

kubectl create namespace argocd

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
Watch progress in a second terminal with kubectl get pods -n argocd -w, or add --debug to the install to surface template-rendering issues.

2.4 Verify

kubectl get pods -n argocd
All of argocd-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:

echo "<INGRESS_IP>  <ARGOCD_HOST>" | sudo tee -a /etc/hosts

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:

kubectl scale deployment <git-server> -n <ns> --replicas=0
sleep 20
kubectl scale deployment <git-server> -n <ns> --replicas=1