{"id":3521,"date":"2026-06-02T16:12:00","date_gmt":"2026-06-02T22:12:00","guid":{"rendered":"https:\/\/yyc.ninja\/index.php\/2026\/10\/20\/adopting-a-running-kubernetes-cluster-into-gitops-without-restarts\/"},"modified":"2026-10-23T11:26:17","modified_gmt":"2026-10-23T17:26:17","slug":"adopting-a-running-kubernetes-cluster-into-gitops-without-restarts","status":"publish","type":"post","link":"https:\/\/yyc.ninja\/index.php\/2026\/06\/02\/adopting-a-running-kubernetes-cluster-into-gitops-without-restarts\/","title":{"rendered":"Adopting a running cluster into GitOps without restarting a single pod"},"content":{"rendered":"<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"630\" src=\"https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover.png\" alt=\"Adopting a running cluster into GitOps without restarting a single pod\" class=\"wp-image-3520\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" srcset=\"https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover.png 1200w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-300x158.png 300w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-1024x538.png 1024w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-768x403.png 768w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-100x53.png 100w\" \/><\/figure>\n<p class=\"wp-block-paragraph\"><em>Part 3 of the data-e2e series. Issue numbers refer to the deployment log; the full procedure\nis the Argo CD plan \u00a70.<\/em><\/p>\n<p class=\"wp-block-paragraph\">Most Argo CD tutorials start with an empty cluster. Mine had 28 Helm releases and raw manifests, running a lakehouse\nthat took days to get stable on slow hardware. The goal was to make <strong>Git the only way to change the cluster<\/strong>,\nwithout redeploying anything. If Argo CD&#8217;s first sync restarted a database on that disk, I&#8217;d be back to Part 1.<\/p>\n<p class=\"wp-block-paragraph\">It worked: 205 resources adopted, no pod replaced, and automated sync with prune and self-heal turned on at the end.\nHere is how, and the four things that would have gone wrong.<\/p>\n<h3 class=\"wp-block-heading\">The plan in one paragraph<\/h3>\n<ol class=\"wp-block-list\">\n<li>Install Argo CD and Sealed Secrets.<\/li>\n<li>Generate one <code>Application<\/code> per component, pointing at the <em>same<\/em> charts, versions and values files the deploy\n scripts used, and commit them on a branch Argo CD tracks.<\/li>\n<li>Create every Application with <strong>sync disabled<\/strong>. Then for each app:\n &#8211; look at the diff;\n &#8211; make Git match the cluster until the diff is empty, or only Argo CD&#8217;s own tracking annotation;\n &#8211; sync;\n &#8211; check that no pod restarted;\n &#8211; move to the next app.<\/li>\n<li>Only when every app is clean, turn on automation.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\">The important part is that the adoption is driven by the <em>diff<\/em>, not by hope.<\/p>\n<h3 class=\"wp-block-heading\">Problem 1: secrets that only existed on the command line<\/h3>\n<p class=\"wp-block-paragraph\">The deploy scripts passed secrets to Helm with <code>--set<\/code>, so the values files in Git contained placeholders such as\n<code>password: \"set-at-install\"<\/code>. That works for scripts. It is a disaster for Argo CD, which renders <strong>only what is in\nGit<\/strong>. Its first sync would have replaced six real passwords with placeholders.<\/p>\n<p class=\"wp-block-paragraph\">I know because I had already done it once by hand (#90). A <code>helm upgrade --reuse-values -f values.yaml<\/code> let the\nfile&#8217;s placeholder override the reused secret, and JupyterHub&#8217;s password silently became <code>set-at-install<\/code>. Nothing\ncrashed; logins just started failing.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Fix (#103):<\/strong> every secret moved out of Helm values into a Kubernetes Secret that the chart <em>references<\/em>\n(<code>existingSecret<\/code>-style). Each Secret was then sealed with Sealed Secrets and committed.<\/p>\n<ul class=\"wp-block-list\">\n<li>A SealedSecret is encrypted with the cluster&#8217;s public key. It is safe in Git, and only the in-cluster controller\n can decrypt it.<\/li>\n<li>The seal script copies only <code>type<\/code> and <code>data<\/code>, pipes straight into <code>kubeseal<\/code>, and never writes plaintext to disk.<\/li>\n<li>30 Secrets were sealed with byte-identical data, verified by hash before adoption.<\/li>\n<li>The deploy scripts changed from &#8220;create or update secret&#8221; to <strong>&#8220;create only if missing&#8221;<\/strong>, so a script run can no\n longer rotate a password behind Git&#8217;s back.<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\">One thing you must not skip: <strong>back up the sealing key<\/strong>. If the cluster is rebuilt without it, every SealedSecret in\nGit becomes undecryptable.<\/p>\n<h3 class=\"wp-block-heading\">Problem 2: values that are random on purpose<\/h3>\n<p class=\"wp-block-paragraph\">Some charts generate secrets at install time with Helm&#8217;s <code>lookup<\/code> function: &#8220;if the Secret already exists, reuse its\nvalue, otherwise generate one&#8221;. Examples are JupyterHub&#8217;s proxy token and cookie secret, and Bitnami&#8217;s\n<code>postgres-password<\/code>.<\/p>\n<p class=\"wp-block-paragraph\">Argo CD renders charts with <code>helm template<\/code>, which has no cluster access, so <code>lookup<\/code> always returns nothing and the\nchart generates a <strong>new random value on every render<\/strong> (#104). The result is a permanent diff, and with self-heal,\na password change on every sync.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Fix, in order of preference:<\/strong><\/p>\n<ol class=\"wp-block-list\">\n<li>If the chart supports an existing Secret, create one and seal it.<\/li>\n<li>Otherwise, tell Argo CD to ignore exactly those fields with <code>ignoreDifferences<\/code> (the generated keys <em>and<\/em> any\n checksum annotation derived from them), and set <code>RespectIgnoreDifferences=true<\/code> so a sync never writes them\n either.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\">I verified the second option the hard way: JupyterHub&#8217;s keys were unchanged after a sync and the hub did not restart.\nThe cost is that ignored fields are invisible to diffs, which is acceptable for random tokens.<\/p>\n<h3 class=\"wp-block-heading\">Problem 3: Helm features Argo CD doesn&#8217;t have<\/h3>\n<ul class=\"wp-block-list\">\n<li><strong>Post-renderers (#105).<\/strong> Opik needed a Helm post-renderer to add startup probes that its MySQL sub-chart\n hard-codes away (Part 1). Argo CD has no post-renderer hook, so Opik became a <strong>Kustomize<\/strong> app: <code>helmCharts:<\/code>\n inflates the chart, and JSON patches add the probes. The same patches stay in the script path, so the two\n renderings must be kept in step.<\/li>\n<li><strong>Helm hooks (#107).<\/strong> Argo CD understands <code>helm.sh\/hook<\/code> only for Helm sources. A chart inflated by Kustomize\n turned its MinIO bucket job into an ordinary resource that went &#8220;missing&#8221; after its TTL deleted it. That left the\n app permanently OutOfSync. Argo CD&#8217;s own hook annotations (<code>argocd.argoproj.io\/hook: PostSync<\/code>) fixed it for\n Kustomize, and for charts with TTL&#8217;d migration Jobs (Airflow) too.<\/li>\n<li><strong>Huge CRDs (#102).<\/strong> The Spark Operator&#8217;s CRD is bigger than the 256 KB annotation limit that client-side apply\n uses to store <code>last-applied-configuration<\/code>. <code>ServerSideApply=true<\/code> for that app (and for Strimzi, KServe, Opik)\n avoids the annotation entirely.<\/li>\n<\/ul>\n<h3 class=\"wp-block-heading\">Problem 4: the network, again<\/h3>\n<p class=\"wp-block-paragraph\">The repo-server could not fetch the repository: <code>git fetch \u2026 timeout after 1m40s<\/code> (#100). The pods are IPv4-only and\nGitLab answers IPv6 first. An SSH config with <code>AddressFamily inet<\/code> fixed it (more in Part 2).<\/p>\n<h3 class=\"wp-block-heading\">The adoption itself<\/h3>\n<p class=\"wp-block-paragraph\">After all that, the diffs for 205 resources were boring. The only differences were Argo CD&#8217;s tracking annotation and\none Helm label. Each app was synced individually, and after each one <code>kubectl get pods<\/code> was checked for restarts.<\/p>\n<p class=\"wp-block-paragraph\">Then automation was turned on: <code>automated: {prune: true, selfHeal: true}<\/code> on all 28 apps.<\/p>\n<p class=\"wp-block-paragraph\">The self-heal test is the satisfying part. I changed a Deployment by hand, and Argo CD reverted it in <strong>10 seconds<\/strong>.\nFrom that moment, <code>kubectl edit<\/code> is a temporary experiment, and Git is the write path.<\/p>\n<h3 class=\"wp-block-heading\">What changed in day-to-day work<\/h3>\n<ul class=\"wp-block-list\">\n<li><strong>A change is a commit.<\/strong> Values, manifests and Sealed Secrets go to <code>main<\/code>, and Argo CD applies them within 5\n minutes. There&#8217;s no webhook from GitLab into the LAN, so it polls.<\/li>\n<li><strong>The scripts still exist<\/strong>, for bootstrap and break-glass. They never overwrite existing Secrets, so they can&#8217;t\n fight Argo CD.<\/li>\n<li><strong>Drift is visible.<\/strong> &#8220;OutOfSync&#8221; now means someone changed the cluster outside Git. With self-heal, it is also\n already undone.<\/li>\n<\/ul>\n<h3 class=\"wp-block-heading\">If you&#8217;re about to do this<\/h3>\n<ol class=\"wp-block-list\">\n<li><strong>Inventory every <code>--set<\/code>.<\/strong> Each one is a future Argo CD regression.<\/li>\n<li><strong>Find every <code>lookup<\/code><\/strong> in your charts. Grep the rendered templates for random-looking values that change\n between two <code>helm template<\/code> runs.<\/li>\n<li><strong>Adopt with sync off<\/strong> and drive it by diff, one app at a time.<\/li>\n<li><strong>Seal secrets from the live cluster<\/strong> (byte-identical), not from a <code>.env<\/code> file that may have drifted. Back up the\n sealing key somewhere that isn&#8217;t the cluster.<\/li>\n<li>Expect to translate <strong>post-renderers and hooks<\/strong> for Argo CD.<\/li>\n<li><strong>Turn on self-heal last<\/strong>, after an empty diff, and test it.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\"><em>Previous: Part 2 (Networking \u00b7 Next: Part 4) Upgrading with no spare capacity.<\/em><\/p>\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n<!-- \/wp:post-content -->\n\n<!-- wp:heading -->\n<h2 class=\"wp-block-heading\">Get the next one in your inbox<\/h2>\n<!-- \/wp:heading -->\n\n<!-- wp:paragraph -->\n<p class=\"wp-block-paragraph\">Enjoyed this? I write about local AI, self-hosting, and cloud cost ninjutsu. Join the Ninja Dispatch for occasional, high-signal notes. No fluff.<\/p>\n<!-- \/wp:paragraph -->\n\n<!-- wp:shortcode -->\n\n\t\t\t<form id=\"sib_signup_form_2\" method=\"post\" class=\"sib_signup_form\">\n\t\t\t\t<div class=\"sib_loader\" style=\"display:none;\"><img\n\t\t\t\t\t\t\tsrc=\"https:\/\/yyc.ninja\/wp-includes\/images\/spinner.gif\" alt=\"loader\"><\/div>\n\t\t\t\t<input type=\"hidden\" name=\"sib_form_action\" value=\"subscribe_form_submit\">\n\t\t\t\t<input type=\"hidden\" name=\"sib_form_id\" value=\"2\">\n                <input type=\"hidden\" name=\"sib_form_alert_notice\" value=\"Please fill out all required fields.\">\n                <input type=\"hidden\" name=\"sib_form_invalid_email_notice\" value=\"Please enter a valid email address.\">\n                <input type=\"hidden\" name=\"sib_security\" value=\"fb5b5107aa\">\n\t\t\t\t<div class=\"sib_signup_box_inside_2\">\n\t\t\t\t\t<div style=\"\/*display:none*\/\" class=\"sib_msg_disp\">\n\t\t\t\t\t<\/div>\n                    \t\t\t\t\t<p class=\"sib-email-area\">\n    <input type=\"email\" class=\"sib-email-area\" name=\"email\" placeholder=\"you@example.com\" required=\"required\">\n<\/p>\n<p class=\"sib-FIRSTNAME-area\">\n    <input type=\"text\" class=\"sib-FIRSTNAME-area\" name=\"FIRSTNAME\" placeholder=\"First name (optional)\">\n<\/p>\n<input type=\"hidden\" class=\"sib-SOURCE_DOMAIN-area\" name=\"SOURCE_DOMAIN\" value=\"yyc.ninja\">\n<input type=\"hidden\" class=\"sib-SIGNUP_SOURCE-area\" name=\"SIGNUP_SOURCE\" value=\"post\">\n<p>\n    <input type=\"submit\" class=\"sib-default-btn\" value=\"Join the Ninja Dispatch\">\n<\/p>\n<p class=\"ninja-fineprint\">No spam. Unsubscribe anytime.<\/p>\t\t\t\t<\/div>\n\t\t\t<\/form>\n\t\t\t<style>\n\t\t\t\tform#sib_signup_form_2{background:#192a3d;border-radius:10px;padding:16px;font-family:inherit}form#sib_signup_form_2 input[type=text],form#sib_signup_form_2 input[type=email],form#sib_signup_form_2 select{width:100%;box-sizing:border-box;margin:0 0 10px 0;padding:10px 12px;border:1px solid #33465c;border-radius:8px;background:#0f1c29;color:#e8eef5;font-size:14px}form#sib_signup_form_2 input::placeholder{color:#8a9bb0}form#sib_signup_form_2 .sib-default-btn{width:100%;margin:2px 0 0 0;padding:11px 14px;background:#e63946;color:#ffffff;border:0;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer}form#sib_signup_form_2 .sib-default-btn:hover{background:#d02f3c}form#sib_signup_form_2 p{margin:0 0 10px 0}form#sib_signup_form_2 .ninja-fineprint{margin:8px 0 0;font-size:11px;color:#8a9bb0;text-align:center}form#sib_signup_form_2 p.sib-alert-message {\n    padding: 6px 12px;\n    margin-bottom: 20px;\n    border: 1px solid transparent;\n    border-radius: 4px;\n    -webkit-box-sizing: border-box;\n    -moz-box-sizing: border-box;\n    box-sizing: border-box;\n}\nform#sib_signup_form_2 p.sib-alert-message-error {\n    background-color: #f2dede;\n    border-color: #ebccd1;\n    color: #a94442;\n}\nform#sib_signup_form_2 p.sib-alert-message-success {\n    background-color: #dff0d8;\n    border-color: #d6e9c6;\n    color: #3c763d;\n}\nform#sib_signup_form_2 p.sib-alert-message-warning {\n    background-color: #fcf8e3;\n    border-color: #faebcc;\n    color: #8a6d3b;\n}\n\t\t\t<\/style>\n\t\t\t\n<!-- \/wp:shortcode -->","protected":false},"excerpt":{"rendered":"<p>Most Argo CD tutorials start with an empty cluster. Mine had 28 Helm releases and raw manifests, running a lakehouse<\/p>\n","protected":false},"author":1,"featured_media":3520,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[12],"tags":[44,46,45],"class_list":["post-3521","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to","tag-data-engineering","tag-mlops","tag-streaming"],"featured_image_urls_v2":{"full":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover.png",1200,630,false],"thumbnail":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-150x150.png",150,150,true],"medium":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-300x158.png",300,158,true],"medium_large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-768x403.png",768,403,true],"large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-1024x538.png",1024,538,true],"RoboGalleryMansoryImagesCenter":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-600x630.png",600,630,true],"RoboGalleryPreload":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover-100x53.png",100,53,true],"1536x1536":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover.png",1200,630,false],"2048x2048":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/10\/cg-adopting-a-running-kubernetes-cluster-into-gitops-without-re-cover.png",1200,630,false]},"post_excerpt_stackable_v2":"<p>Most Argo CD tutorials start with an empty cluster. Mine had 28 Helm releases and raw manifests, running a lakehouse<\/p>\n","category_list_v2":"<a href=\"https:\/\/yyc.ninja\/index.php\/category\/how-to\/\" rel=\"category tag\">How To Ninja<\/a>","author_info_v2":{"name":"YYC Ninja","url":"https:\/\/yyc.ninja\/index.php\/author\/admin\/"},"comments_num_v2":"0 comments","_links":{"self":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3521","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/comments?post=3521"}],"version-history":[{"count":2,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3521\/revisions"}],"predecessor-version":[{"id":3564,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3521\/revisions\/3564"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media\/3520"}],"wp:attachment":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media?parent=3521"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/categories?post=3521"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/tags?post=3521"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}