{"id":171,"date":"2026-06-20T12:00:00","date_gmt":"2026-06-20T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=171"},"modified":"2026-10-30T07:10:23","modified_gmt":"2026-10-30T07:10:23","slug":"part-08-gitops-with-argocd-vault-and-external-secrets","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=171","title":{"rendered":"Part 08 &#8211; GitOps with ArgoCD, Vault and External Secrets"},"content":{"rendered":"<h3 class=\"wp-block-heading\">Overview<\/h3><p class=\"wp-block-paragraph\">In Part 7 we built the CI\/CD pipeline. Jenkins builds images and pushes them to the local registry. But how does a new image actually get deployed to Kubernetes? That is where GitOps comes in.<\/p><p class=\"wp-block-paragraph\">In this article I cover:<\/p><ul class=\"wp-block-list\"><li>ArgoCD, watches a GitHub repository and keeps the cluster in sync automatically<\/li><li>Vault, stores secrets (passwords, API keys, tokens) securely<\/li><li>External Secrets Operator, syncs secrets from Vault into Kubernetes Secrets automatically<\/li><\/ul><h3 class=\"wp-block-heading\">What is GitOps?<\/h3><p class=\"wp-block-paragraph\">Traditional deployment:<\/p><pre class=\"wp-block-code\"><code>Developer, kubectl apply -f deployment.yaml, cluster\n(manual, no audit trail, easy to drift)<\/code><\/pre><p class=\"wp-block-paragraph\">GitOps:<\/p><pre class=\"wp-block-code\"><code>Developer, git push, GitHub repo, ArgoCD, cluster\n(automated, auditable, self-healing)<\/code><\/pre><p class=\"wp-block-paragraph\">With GitOps, Git is the single source of truth. The cluster state is defined entirely in YAML files in a repository. ArgoCD continuously compares the live cluster state against the repository and corrects any drift.<\/p><p class=\"wp-block-paragraph\">If someone manually runs kubectl delete deployment myapp, ArgoCD detects the cluster is out of sync and recreates it within minutes. No manual intervention needed.<\/p><h3 class=\"wp-block-heading\">GitOps Repository Structure<\/h3><pre class=\"wp-block-code\"><code>homelab-gitops\/\n\u251c\u2500\u2500 apps\/\n\u2502   \u251c\u2500\u2500 base\/\n\u2502   \u2502   \u251c\u2500\u2500 deployment.yaml\n\u2502   \u2502   \u251c\u2500\u2500 service.yaml\n\u2502   \u2502   \u2514\u2500\u2500 kustomization.yaml\n\u2502   \u2514\u2500\u2500 overlays\/\n\u2502       \u251c\u2500\u2500 dev\/\n\u2502       \u2514\u2500\u2500 prod\/\n\u251c\u2500\u2500 infra\/\n\u2502   \u251c\u2500\u2500 monitoring\/\n\u2502   \u251c\u2500\u2500 cicd\/\n\u2502   \u2514\u2500\u2500 vault\/\n\u2514\u2500\u2500 argocd\/\n    \u2514\u2500\u2500 applications\/<\/code><\/pre><p class=\"wp-block-paragraph\">Kustomize lets you define a base configuration and apply environment-specific patches on top. Example:<\/p><pre class=\"wp-block-code\"><code># apps\/base\/deployment.yaml\nspec:\n  replicas: 1\n  template:\n    spec:\n      containers:\n      - name: myapp\n        image: registry.rezaops.com\/myapp:latest<\/code><\/pre><pre class=\"wp-block-code\"><code># apps\/overlays\/prod\/replica-patch.yaml\nspec:\n  replicas: 3<\/code><\/pre><p class=\"wp-block-paragraph\">ArgoCD applies the base plus the overlay patch, resulting in 3 replicas in production.<\/p><h3 class=\"wp-block-heading\">Role: gitops<\/h3><h3 class=\"wp-block-heading\">Full tasks\/main.yml<\/h3><pre class=\"wp-block-code\"><code>- name: Add Helm repos for GitOps stack\n  shell: |\n    helm repo add argo https:\/\/argoproj.github.io\/argo-helm\n    helm repo add hashicorp https:\/\/helm.releases.hashicorp.com\n    helm repo add external-secrets https:\/\/charts.external-secrets.io\n    helm repo update\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">Three repos added, argo for ArgoCD, hashicorp for Vault, and external-secrets for ESO.<\/p><h3 class=\"wp-block-heading\">Installing ArgoCD<\/h3><pre class=\"wp-block-code\"><code>- name: Install ArgoCD\n  shell: |\n    helm upgrade --install argocd argo\/argo-cd \\\n      --namespace argocd \\\n      --set server.service.type=LoadBalancer \\\n      --set server.nodeSelector.\"workload\"=monitoring \\\n      --set repoServer.nodeSelector.\"workload\"=monitoring \\\n      --set applicationSet.nodeSelector.\"workload\"=monitoring \\\n      --set notifications.nodeSelector.\"workload\"=monitoring\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">Helm values explained:<\/p><ul class=\"wp-block-list\"><li>server.service.type, LoadBalancer, ArgoCD UI gets a real IP from MetalLB<\/li><li>nodeSelector.&#8221;workload&#8221;=monitoring on all components, pins everything to jarvis<\/li><\/ul><p class=\"wp-block-paragraph\">ArgoCD components:<\/p><ul class=\"wp-block-list\"><li>server, the API server and UI<\/li><li>repoServer, clones and processes Git repositories<\/li><li>applicationSet, manages groups of ArgoCD Applications<\/li><li>notifications, sends alerts when deployments succeed or fail<\/li><\/ul><p class=\"wp-block-paragraph\">All pinned to jarvis so they do not compete with app workloads on thor.<\/p><pre class=\"wp-block-code\"><code>- name: Get ArgoCD initial admin password\n  shell: |\n    kubectl -n argocd get secret argocd-initial-admin-secret \\\n      -o jsonpath=\"{.data.password}\" | base64 -d\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml\n  register: argocd_password<\/code><\/pre><pre class=\"wp-block-code\"><code>- name: Print ArgoCD credentials\n  debug:\n    msg: \"ArgoCD admin password: {{ argocd_password.stdout }}\"<\/code><\/pre><p class=\"wp-block-paragraph\">ArgoCD generates a random initial admin password stored in a Kubernetes Secret. We extract it with kubectl get secret and decode it from base64. The -o jsonpath=&#8221;{.data.password}&#8221; flag extracts just the password field. Change it after first login:<\/p><pre class=\"wp-block-code\"><code>argocd login argocd.rezaops.com\nargocd account update-password<\/code><\/pre><h3 class=\"wp-block-heading\">The ArgoCD Application CRD<\/h3><p class=\"wp-block-paragraph\">This is the core of GitOps. An Application resource tells ArgoCD what to watch and where to deploy:<\/p><pre class=\"wp-block-code\"><code>apiVersion: argoproj.io\/v1alpha1\nkind: Application\nmetadata:\n  name: myapp\n  namespace: argocd\nspec:\n  project: default\n\n  source:\n    repoURL: https:\/\/github.com\/mohammadrezachegini\/homelab-gitops\n    targetRevision: main\n    path: apps\/overlays\/prod\n\n  destination:\n    server: https:\/\/kubernetes.default.svc\n    namespace: myapp\n\n  syncPolicy:\n    automated:\n      prune: true\n      selfHeal: true\n    syncOptions:\n    - CreateNamespace=true<\/code><\/pre><p class=\"wp-block-paragraph\">Key fields explained:<\/p><p class=\"wp-block-paragraph\">source tells ArgoCD where to read the desired state. repoURL is the GitHub repository, targetRevision is the branch to watch, and path is which directory contains the manifests.<\/p><p class=\"wp-block-paragraph\">destination tells ArgoCD where to deploy. server: https:\/\/kubernetes.default.svc means the local cluster.<\/p><p class=\"wp-block-paragraph\">syncPolicy.automated makes ArgoCD sync automatically. prune: true deletes resources removed from Git. selfHeal: true reverts manual cluster changes to match Git.<\/p><p class=\"wp-block-paragraph\">Without automated, ArgoCD only shows the diff and waits for you to click Sync in the UI. With it, changes in Git deploy automatically within 3 minutes.<\/p><h3 class=\"wp-block-heading\">Installing Vault<\/h3><pre class=\"wp-block-code\"><code>- name: Install Vault\n  shell: |\n    helm upgrade --install vault hashicorp\/vault \\\n      --namespace vault \\\n      --set server.dev.enabled=true \\\n      --set ui.enabled=true \\\n      --set ui.serviceType=LoadBalancer \\\n      --set server.nodeSelector.\"workload\"=monitoring\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">Helm values explained:<\/p><ul class=\"wp-block-list\"><li>server.dev.enabled, true, run in dev mode, auto-unsealed, no TLS<\/li><li>ui.enabled, true, enable the Vault web UI<\/li><li>ui.serviceType, LoadBalancer, Vault UI gets a real IP<\/li><\/ul><p class=\"wp-block-paragraph\">Dev mode is fine for a home lab. It auto-unseals on startup and uses root as the root token. The trade-off is data is lost if the pod restarts. For production you would use server.ha.enabled=true with Raft storage and proper unseal keys.<\/p><p class=\"wp-block-paragraph\">Storing secrets in Vault:<\/p><pre class=\"wp-block-code\"><code>kubectl port-forward svc\/vault -n vault 8200:8200 &amp;\nexport VAULT_ADDR='http:\/\/localhost:8200'\nvault login root<\/code><\/pre><pre class=\"wp-block-code\"><code>vault secrets enable -path=secret kv-v2\n\nvault kv put secret\/myapp \\\n  db_password=\"supersecretpassword\" \\\n  api_key=\"myapikey123\" \\\n  django_secret_key=\"randomlongsecretkey\"<\/code><\/pre><h3 class=\"wp-block-heading\">Installing External Secrets Operator<\/h3><p class=\"wp-block-paragraph\">Vault stores secrets securely but your pods need those secrets as Kubernetes environment variables. External Secrets Operator automates this by watching Vault and syncing secrets into Kubernetes Secrets.<\/p><pre class=\"wp-block-code\"><code>- name: Install External Secrets Operator\n  shell: |\n    helm upgrade --install external-secrets \\\n      external-secrets\/external-secrets \\\n      --namespace external-secrets \\\n      --set nodeSelector.\"workload\"=monitoring\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">After installing ESO, you create two custom resources.<\/p><p class=\"wp-block-paragraph\">SecretStore connects ESO to Vault:<\/p><pre class=\"wp-block-code\"><code>apiVersion: external-secrets.io\/v1beta1\nkind: SecretStore\nmetadata:\n  name: vault-backend\n  namespace: myapp\nspec:\n  provider:\n    vault:\n      server: \"http:\/\/vault.vault.svc.cluster.local:8200\"\n      path: \"secret\"\n      version: \"v2\"\n      auth:\n        tokenSecretRef:\n          name: vault-token\n          key: token<\/code><\/pre><p class=\"wp-block-paragraph\">ExternalSecret syncs specific secrets:<\/p><pre class=\"wp-block-code\"><code>apiVersion: external-secrets.io\/v1beta1\nkind: ExternalSecret\nmetadata:\n  name: myapp-secrets\n  namespace: myapp\nspec:\n  refreshInterval: 1h\n  secretStoreRef:\n    name: vault-backend\n    kind: SecretStore\n  target:\n    name: myapp-env\n    creationPolicy: Owner\n  data:\n  - secretKey: DB_PASSWORD\n    remoteRef:\n      key: secret\/myapp\n      property: db_password\n  - secretKey: API_KEY\n    remoteRef:\n      key: secret\/myapp\n      property: api_key<\/code><\/pre><p class=\"wp-block-paragraph\">What happens automatically. ESO reads the secrets from Vault, creates a Kubernetes Secret called myapp-env in the myapp namespace, and re-syncs every hour. If you update Vault, Kubernetes gets the new value automatically.<\/p><p class=\"wp-block-paragraph\">Using the synced secret in a pod:<\/p><pre class=\"wp-block-code\"><code>containers:\n- name: myapp\n  image: registry.rezaops.com\/myapp:latest\n  envFrom:\n  - secretRef:\n      name: myapp-env<\/code><\/pre><p class=\"wp-block-paragraph\">No secrets in Git. No secrets hardcoded in Docker images. Vault is the single source of truth.<\/p><h3 class=\"wp-block-heading\">The Full GitOps Flow<\/h3><ol class=\"wp-block-list\"><li>Developer pushes code to GitHub<\/li><li>Jenkins webhook triggers \u00b7 Jenkins pulls code, runs tests, builds image \u00b7 docker push registry.rezaops.com\/myapp:a1b2c3d4<\/li><li>Jenkins updates GitOps repo \u00b7 updates image tag in apps\/overlays\/prod\/deployment.yaml \u00b7 git push to GitHub<\/li><li>ArgoCD detects change (polls every 3 min) \u00b7 cluster has image old1234, Git has a1b2c3d4<\/li><li>ArgoCD syncs \u00b7 applies updated deployment.yaml \u00b7 Kubernetes rolls out new pod<\/li><li>ESO injects secrets \u00b7 new pod starts, ESO-created Secret is mounted \u00b7 pod has all env vars from Vault<\/li><li>Pod is running<\/li><\/ol><h3 class=\"wp-block-heading\">Verifying GitOps is Working<\/h3><pre class=\"wp-block-code\"><code># Check ArgoCD applications\nkubectl get applications -n argocd\n\n# Check Vault is running\nkubectl get pods -n vault\nkubectl exec -n vault vault-0 -- vault status\n\n# Check External Secrets\nkubectl get externalsecrets -A\nkubectl get secretstores -A<\/code><\/pre><p class=\"wp-block-paragraph\">The ArgoCD UI at http:\/\/argocd.rezaops.com shows all applications, their sync status (Synced or OutOfSync), last sync time, and which commit triggered it.<\/p><h3 class=\"wp-block-heading\">What&#8217;s Next<\/h3><p class=\"wp-block-paragraph\">In Part 9 I will cover DNS and networking in depth, how CoreDNS resolves internal hostnames, how Route 53 public hosted zones work with private IPs, and how NGINX Ingress routes all traffic through a single LoadBalancer IP.<\/p>","protected":false},"excerpt":{"rendered":"<p>Overview In Part 7 we built the CI\/CD pipeline. Jenkins builds images and pushes them to the local registry. But how does&hellip;<\/p>\n","protected":false},"author":2,"featured_media":173,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"8","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/part-08-gitops-argocd-vault-external-secrets-reza-chegini-bstmc\/","footnotes":""},"categories":[10,62,9],"tags":[104,8,107,105,66,3,106],"series":[64],"class_list":["post-171","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-home-lab","category-kubernetes","tag-argocd","tag-devops","tag-external-secrets","tag-gitops","tag-home-lab","tag-kubernetes","tag-vault","series-home-lab"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/part-08-gitops-argocd-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/171","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=171"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/171\/revisions"}],"predecessor-version":[{"id":172,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/171\/revisions\/172"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/173"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=171"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=171"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=171"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=171"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}