{"id":3591,"date":"2026-11-04T09:00:21","date_gmt":"2026-11-04T16:00:21","guid":{"rendered":"https:\/\/yyc.ninja\/?p=3591"},"modified":"2026-11-04T09:00:21","modified_gmt":"2026-11-04T16:00:21","slug":"adopting-running-cluster-into-gitops-without-restarts","status":"publish","type":"post","link":"https:\/\/yyc.ninja\/index.php\/2026\/11\/04\/adopting-running-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\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover.png\" alt=\"Adopting a Running Cluster into GitOps Without Restarting a Single Pod\" class=\"wp-image-3590\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" srcset=\"https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover.png 1200w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-300x158.png 300w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-1024x538.png 1024w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-768x403.png 768w, https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-100x53.png 100w\" \/><\/figure>\n<p class=\"wp-block-paragraph\">Most Argo CD tutorials start with an empty cluster. Real platforms rarely do. The lakehouse from the storage exercise in &#8220;When Storage Is the Bottleneck&#8221; was already running 28 applications as Helm releases and raw manifests, on storage where every restart was expensive.<\/p>\n<p class=\"wp-block-paragraph\">The goal was to make <strong>Git the only way to change the cluster<\/strong>, without redeploying anything. A careless first sync could have restarted databases and reintroduced the failures that exercise had just documented, so the migration needed a strict no-restart standard.<\/p>\n<p class=\"wp-block-paragraph\">The result: 205 resources adopted, no pods replaced, and automated sync with prune and self-heal enabled at the end. Four problems had to be solved first.<\/p>\n<h3 class=\"wp-block-heading\">The approach<\/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, using the same charts, versions and values files as the deploy scripts, and commit them to a branch Argo CD tracks.<\/li>\n<li>Create every Application with <strong>sync disabled<\/strong>. Then, for each one:\n &#8211; review the diff;\n &#8211; change Git until the diff is empty, or shows only Argo CD&#8217;s tracking annotation;\n &#8211; sync;\n &#8211; confirm that no pod restarted;\n &#8211; move to the next application.<\/li>\n<li>Enable automation only when every application is clean.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\">The diff drives the adoption at every step, so nothing depends on assumptions about what the cluster contains.<\/p>\n<h3 class=\"wp-block-heading\">Problem 1: Secrets that existed only 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 held placeholders such as <code>password: \"set-at-install\"<\/code>. That works for scripts, but Argo CD renders <strong>only what is in Git<\/strong>. Its first sync would have replaced six real passwords with placeholders.<\/p>\n<p class=\"wp-block-paragraph\">This risk was already proven. During an earlier manual upgrade, <code>helm upgrade --reuse-values -f values.yaml<\/code> let the file&#8217;s placeholder override the reused secret, and JupyterHub&#8217;s password silently became <code>set-at-install<\/code>. Nothing crashed. Logins simply started failing.<\/p>\n<p class=\"wp-block-paragraph\">Every secret moved out of Helm values into a Kubernetes Secret that the chart references, in the style of <code>existingSecret<\/code>. Each Secret was then sealed and committed:<\/p>\n<ul class=\"wp-block-list\">\n<li>A SealedSecret is encrypted with the cluster&#8217;s public key, so it is safe in Git, and only the in-cluster controller can decrypt it.<\/li>\n<li>The sealing script copies only <code>type<\/code> and <code>data<\/code>, pipes directly 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&#8221; to <strong>&#8220;create only if missing&#8221;<\/strong>, so a script run can no longer rotate a password outside Git.<\/li>\n<\/ul>\n<p class=\"wp-block-paragraph\"><strong>Back up the sealing key.<\/strong> If the cluster is rebuilt without it, every SealedSecret in Git becomes undecryptable.<\/p>\n<h3 class=\"wp-block-heading\">Problem 2: Values that are random by design<\/h3>\n<p class=\"wp-block-paragraph\">Some charts generate secrets at install time with Helm&#8217;s <code>lookup<\/code> function: reuse the existing Secret if there is one, otherwise generate a value. JupyterHub&#8217;s proxy token and cookie secret and Bitnami&#8217;s <code>postgres-password<\/code> both work this way.<\/p>\n<p class=\"wp-block-paragraph\">Argo CD renders charts with <code>helm template<\/code>, which has no cluster access. <code>lookup<\/code> always returns nothing, so the chart generates a <strong>new random value on every render<\/strong>. That creates a permanent diff and, with self-heal enabled, a password change on every sync.<\/p>\n<p class=\"wp-block-paragraph\">The fixes, in order of preference:<\/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, use <code>ignoreDifferences<\/code> for exactly those fields, including any checksum annotation derived from them, and set <code>RespectIgnoreDifferences=true<\/code> so a sync never writes them either.<\/li>\n<\/ol>\n<p class=\"wp-block-paragraph\">The second option was tested directly: JupyterHub&#8217;s keys were unchanged after a sync, and the hub did not restart. The cost is that ignored fields disappear from diffs, which is acceptable for random tokens.<\/p>\n<h3 class=\"wp-block-heading\">Problem 3: Helm features Argo CD does not have<\/h3>\n<ul class=\"wp-block-list\">\n<li><strong>Post-renderers.<\/strong> Opik needed a post-renderer to add startup probes that its MySQL sub-chart does not expose. Argo CD has no post-renderer hook, so Opik became a <strong>Kustomize<\/strong> application: <code>helmCharts:<\/code> inflates the chart and JSON patches add the probes. The script path keeps the same patches, so the two renderings have to stay in step.<\/li>\n<li><strong>Helm hooks.<\/strong> Argo CD honours <code>helm.sh\/hook<\/code> only for Helm sources. A chart inflated through Kustomize turned its MinIO bucket job into an ordinary resource, which went missing once its TTL deleted it and left the application permanently OutOfSync. Argo CD&#8217;s own annotation, <code>argocd.argoproj.io\/hook: PostSync<\/code>, fixed this, and also worked for charts with TTL-managed migration Jobs such as Airflow.<\/li>\n<li><strong>Large CRDs.<\/strong> The Spark Operator&#8217;s CRD exceeds the 256 KB annotation limit that client-side apply uses for <code>last-applied-configuration<\/code>. Setting <code>ServerSideApply=true<\/code> for that application, and for Strimzi, KServe and Opik, avoids the annotation entirely.<\/li>\n<\/ul>\n<h3 class=\"wp-block-heading\">Problem 4: IPv6 on an IPv4-only pod network<\/h3>\n<p class=\"wp-block-paragraph\">The repo-server could not fetch the repository, failing with <code>git fetch ... timeout after 1m40s<\/code>. The pods have IPv4 only, and the Git host answered on IPv6 first. An SSH configuration with <code>AddressFamily inet<\/code> fixed it. The networking article in this series covers the root cause.<\/p>\n<h3 class=\"wp-block-heading\">The adoption<\/h3>\n<p class=\"wp-block-paragraph\">With those problems solved, the diffs across all 205 resources were uneventful. The only differences were Argo CD&#8217;s tracking annotation and one Helm label. Each application was synced individually, with <code>kubectl get pods<\/code> checked for restarts after each one.<\/p>\n<p class=\"wp-block-paragraph\">Automation then went on for all 28 applications: <code>automated: {prune: true, selfHeal: true}<\/code>.<\/p>\n<p class=\"wp-block-paragraph\">The final test was deliberate drift. A Deployment changed by hand was reverted by Argo CD in <strong>10 seconds<\/strong>. From that point, <code>kubectl edit<\/code> is only a temporary experiment, and Git is the write path.<\/p>\n<h3 class=\"wp-block-heading\">What changed in daily operations<\/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 minutes. It polls, because the cluster does not accept inbound webhooks from the Git host.<\/li>\n<li><strong>The scripts remain for bootstrap and break-glass use.<\/strong> They never overwrite existing Secrets, so they cannot conflict with Argo CD.<\/li>\n<li><strong>Drift is visible.<\/strong> OutOfSync now means someone changed the cluster outside Git, and with self-heal that change has already been undone.<\/li>\n<\/ul>\n<h3 class=\"wp-block-heading\">Recommendations<\/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> in your charts<\/strong> by comparing two <code>helm template<\/code> runs for values that change.<\/li>\n<li><strong>Adopt with sync off<\/strong> and work through the diffs one application at a time.<\/li>\n<li><strong>Seal secrets from the live cluster,<\/strong> not from a <code>.env<\/code> file that may have drifted, and keep a backup of the sealing key outside the cluster.<\/li>\n<li><strong>Plan to translate post-renderers and hooks<\/strong> for Argo CD.<\/li>\n<li><strong>Enable self-heal last,<\/strong> after an empty diff, and test it.<\/li>\n<\/ol>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Get the next one in your inbox<\/h2>\n\n\n\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\n\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=\"ea26a616b7\">\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","protected":false},"excerpt":{"rendered":"<p>Most Argo CD tutorials start with an empty cluster. Real platforms rarely do. The lakehouse from the storage exercise in &#8220;When Storage Is the Bottleneck&#8221; was already running 28 applications as Helm releases and raw manifests, on storage where every restart was expensive.<\/p>\n","protected":false},"author":1,"featured_media":3590,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[12],"tags":[44,46,45],"class_list":["post-3591","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\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover.png",1200,630,false],"thumbnail":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-150x150.png",150,150,true],"medium":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-300x158.png",300,158,true],"medium_large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-768x403.png",768,403,true],"large":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-1024x538.png",1024,538,true],"RoboGalleryMansoryImagesCenter":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-600x630.png",600,630,true],"RoboGalleryPreload":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover-100x53.png",100,53,true],"1536x1536":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover.png",1200,630,false],"2048x2048":["https:\/\/yyc.ninja\/wp-content\/uploads\/2026\/11\/cg-adopting-running-cluster-into-gitops-without-restarts-cover.png",1200,630,false]},"post_excerpt_stackable_v2":"<p>Most Argo CD tutorials start with an empty cluster. Real platforms rarely do. The lakehouse from the storage exercise in &#8220;When Storage Is the Bottleneck&#8221; was already running 28 applications as Helm releases and raw manifests, on storage where every restart was expensive.<\/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\/3591","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=3591"}],"version-history":[{"count":1,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3591\/revisions"}],"predecessor-version":[{"id":3592,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/posts\/3591\/revisions\/3592"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media\/3590"}],"wp:attachment":[{"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/media?parent=3591"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/categories?post=3591"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/yyc.ninja\/index.php\/wp-json\/wp\/v2\/tags?post=3591"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}