<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Infraforge</title>
    <description>The latest articles on Forem by Infraforge (@infraforge).</description>
    <link>https://forem.com/infraforge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F13346%2Fde839a31-485a-47dd-92b8-2425002f861b.png</url>
      <title>Forem: Infraforge</title>
      <link>https://forem.com/infraforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/infraforge"/>
    <language>en</language>
    <item>
      <title>When ArgoCD shows Healthy but Keycloak silently strips JWT claims</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Fri, 22 May 2026 22:19:02 +0000</pubDate>
      <link>https://forem.com/infraforge/when-argocd-shows-healthy-but-keycloak-silently-strips-jwt-claims-5f24</link>
      <guid>https://forem.com/infraforge/when-argocd-shows-healthy-but-keycloak-silently-strips-jwt-claims-5f24</guid>
      <description>&lt;p&gt;ArgoCD reported Synced and Healthy. The Keycloak Helm release was green. And the downstream timeline service was returning 401 on every authenticated request. That was the call we got: every dashboard says the platform is fine, and authentication is broken across three services. The JWTs auth-service was issuing had stopped carrying the groups claim and the email_verified claim about 40 minutes earlier, right after an ArgoCD auto-sync rolled out a Keycloak chart bump. Six OIDC clients had silently lost protocol mappers and role mappings during that sync, and we did not yet know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ArgoCD shows Synced and Healthy on the Keycloak application, but downstream services return 401 on tokens they accepted an hour ago&lt;/li&gt;
&lt;li&gt;JWTs decoded at jwt.io are missing claims that production code depends on (groups, email_verified, audience)&lt;/li&gt;
&lt;li&gt;Engineers have been making emergency fixes directly in the Keycloak admin console during recent incidents and not committing them back&lt;/li&gt;
&lt;li&gt;The realm import ConfigMap in git has not been touched in weeks, yet the live realm has clearly changed&lt;/li&gt;
&lt;li&gt;Helm values for the Keycloak chart set realm import strategy to OVERWRITE or leave it unset (which defaults to OVERWRITE on most charts)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The sync that looked clean and quietly stripped six clients
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;ArgoCD said Healthy. Auth said 401.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our first guess was wrong. The team had been staring at auth-service for 25 minutes when we joined the bridge, because the tokens it was issuing were obviously malformed. The groups claim was gone. The email_verified claim was gone on a different client. Surely auth-service had shipped a bad release. Except auth-service had not shipped in nine days, and the failure had started 40 minutes ago, not nine days ago.&lt;/p&gt;

&lt;p&gt;The shape of the failure is what gave it away. Three OIDC clients had each lost a different mapper at the same moment. Auth-service had lost a groups protocol mapper. The profile service had lost an email_verified client scope mapping. The api gateway had lost role mappings for a downstream audience. Three services do not lose three unrelated pieces of OIDC config simultaneously unless something upstream rewrote all of them at once. The only thing that had touched Keycloak in that window was an ArgoCD auto-sync of the Keycloak Helm release.&lt;/p&gt;

&lt;p&gt;We pulled the ArgoCD sync history and found the sync 41 minutes earlier. It was a chart version bump, nothing that should have changed realm content. But the chart ships a realm import ConfigMap, and the realm JSON inside that ConfigMap had not been updated in weeks. Meanwhile the live realm in the Keycloak PostgreSQL database had been edited through the admin console at least a dozen times during recent incidents. None of those console changes had been committed back to git.&lt;/p&gt;

&lt;p&gt;So the chart redeployed the ConfigMap. The Keycloak init container read it. And the realm import ran with the strategy set to OVERWRITE. Every console change made during the previous two weeks of incident response got reverted to the stale git version, silently, with no error and no event surfaced to ArgoCD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diffing live realm state against the ConfigMap before doing anything destructive
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Six clients had drifted and the next sync would make it worse&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first thing we did was not a fix. The first thing we did was freeze. Auto-sync was still enabled on the Keycloak ArgoCD application. If anyone touched a Helm value for any reason in the next hour, another sync would fire and a second OVERWRITE pass would run against whatever state we had managed to reconstruct. We paused auto-sync first and removed the self-heal annotation, then started the diagnosis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Freeze the ArgoCD app so the next sync cannot fire mid-recovery
argocd app set keycloak --sync-policy none
argocd app set keycloak --self-heal=false

# 2. Pull live realm state from the Keycloak Admin REST API
TOKEN=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
  -d "grant_type=password" -d "client_id=admin-cli" \
  -d "username=$ADMIN_USER" -d "password=$ADMIN_PASS" | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" \
  "$KC/admin/realms/primary/clients" | jq . &amp;gt; live-clients.json

curl -s -H "Authorization: Bearer $TOKEN" \
  "$KC/admin/realms/primary/client-scopes" | jq . &amp;gt; live-scopes.json

# 3. Extract the realm JSON ArgoCD just pushed
kubectl -n keycloak get cm keycloak-realm-import -o jsonpath='{.data.realm\.json}' \
  | jq . &amp;gt; configmap-realm.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Snapshot live state before any reconciliation. The live API is now the source of truth, not the ConfigMap.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Diffing live-clients.json against the clients block in configmap-realm.json showed six clients with material differences. Two were missing protocol mappers entirely. Three had client scopes that had been removed. One had role mappings that were present in the ConfigMap but missing in production, which told us that client had also been changed in the console at some point and the change had been overwritten on a previous sync we had not even noticed. That last finding was the one that mattered most: this was not the first time the OVERWRITE strategy had quietly destroyed live config. It was just the first time the destruction had cascaded far enough to break downstream services.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxNkNFOwlAMhu95iv8B5BU0wAZOEXQQMVmIOdm60Xh2ztIehov47mZbot416d-vX1taf8lPRgL20QSYZbGr2BEJqOCgEDK2BjuYomaH3Dv1llCchV0FdjkX5MIR0-kt5tniZFxFsNySwjvb9ZOP1OXWmw88ew2V0O5lfZwAi2wmlV9EMOfgp9q5HL78Cw9SIzfK0tGibrwELLwruXoyDYSmpmksU9EDoyEcfyVjTIOYQFV39z0B4r533b7G6SFN9vEVy2zNLf3ekw_iCmVLLtgOQi1JGMHjcLLabNP4PX5Ldvtks7piNSI0mEBohJSkpeLmnx9XzgsVKL2APllD_zMh9WfJSXv0fHBeToDlUN1nkb84DUKmxsNhPwieaxLt90hAadj2FO9Qs2pf5tZwrccfWZ-cNA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxNkNFOwlAMhu95iv8B5BU0wAZOEXQQMVmIOdm60Xh2ztIehov47mZbot416d-vX1taf8lPRgL20QSYZbGr2BEJqOCgEDK2BjuYomaH3Dv1llCchV0FdjkX5MIR0-kt5tniZFxFsNySwjvb9ZOP1OXWmw88ew2V0O5lfZwAi2wmlV9EMOfgp9q5HL78Cw9SIzfK0tGibrwELLwruXoyDYSmpmksU9EDoyEcfyVjTIOYQFV39z0B4r533b7G6SFN9vEVy2zNLf3ekw_iCmVLLtgOQi1JGMHjcLLabNP4PX5Ldvtks7piNSI0mEBohJSkpeLmnx9XzgsVKL2APllD_zMh9WfJSXv0fHBeToDlUN1nkb84DUKmxsNhPwieaxLt90hAadj2FO9Qs2pf5tZwrccfWZ-cNA" alt="Two write paths to the same realm. OVERWRITE makes one of them silently win." width="679" height="796"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two write paths to the same realm. OVERWRITE makes one of them silently win.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconstructing realm state without invalidating active sessions
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Why we did not re-import the ConfigMap&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The obvious recovery path was to fix the realm JSON in git, commit it, and let ArgoCD re-sync. We did not do that, and the reason matters. A full realm re-import, even with the right content, runs through the Keycloak realm import flow on startup. Depending on the chart and the Keycloak version, that can rotate signing keys, drop active sessions, or invalidate refresh tokens. We had roughly 8,000 active user sessions at that moment. Forcing all of them to re-authenticate at 11pm during an active incident was not a recovery; it was a second outage on top of the first.&lt;/p&gt;

&lt;p&gt;So we split the fix into two phases. Phase one was to restore live realm state using the Admin REST API directly, client by client, mapper by mapper. The REST API can add a protocol mapper or attach a client scope to a client without bouncing anything. Phase two was to update the ConfigMap in git to match the now-correct live state AND change the import strategy, so that the next ArgoCD sync would be a no-op rather than another OVERWRITE pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Phase 1: restore each missing mapper live via Admin REST API
# Example: re-add the groups protocol mapper to auth-service client
CLIENT_ID=$(jq -r '.[] | select(.clientId=="auth-service") | .id' live-clients.json)

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  "$KC/admin/realms/primary/clients/$CLIENT_ID/protocol-mappers/models" \
  -d '{
    "name": "groups",
    "protocol": "openid-connect",
    "protocolMapper": "oidc-group-membership-mapper",
    "config": {
      "claim.name": "groups",
      "full.path": "false",
      "id.token.claim": "true",
      "access.token.claim": "true",
      "userinfo.token.claim": "true"
    }
  }'

# Verify a freshly issued token now carries the claim before moving on
curl -s -X POST "$KC/realms/primary/protocol/openid-connect/token" \
  -d 'grant_type=client_credentials' \
  -d "client_id=auth-service" -d "client_secret=$SECRET" \
  | jq -r .access_token | cut -d. -f2 | base64 -d 2&amp;gt;/dev/null | jq .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Restore each mapper live, then verify the issued token actually carries the claim before moving to the next client.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We worked through the six clients in dependency order: auth-service first because every other service consumed its tokens, then the api gateway, then profile, then the rest. After each client we curl'd a fresh token and base64-decoded the payload to confirm the claim was present. Twenty-two minutes from the start of restoration, timeline-service was returning 200s again. No sessions dropped. No users re-authenticated. The Keycloak pods were never restarted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed so the next sync becomes a no-op
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The one Helm value that should never be OVERWRITE&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With live state correct, the dangerous artifact in the system was still the stale realm JSON in the ConfigMap and the OVERWRITE strategy that would re-apply it on any future sync. We exported the now-correct realm via the Admin API, ran it through a diff against what was in git, and committed the result. We also patched the Keycloak Helm values to set the realm import strategy to IGNORE_EXISTING.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# values.yaml for the Keycloak chart
extraEnv: |
  - name: KEYCLOAK_IMPORT_STRATEGY
    value: IGNORE_EXISTING
  # On Keycloak 22+ via Quarkus distribution:
  - name: KC_SPI_IMPORT_SINGLE_FILE_STRATEGY
    value: IGNORE_EXISTING

# For the operator/CR variant:
# spec:
#   realmImport:
#     strategy: IGNORE_EXISTING   # NOT OVERWRITE_EXISTING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;IGNORE_EXISTING means the ConfigMap seeds a realm on first creation but never overwrites existing resources. This is the correct setting for any realm that humans also edit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We re-enabled ArgoCD auto-sync and watched it run. The sync diffed clean: ConfigMap content matched live realm, import strategy was IGNORE_EXISTING, no resources were touched. Green for the right reason this time.&lt;/p&gt;

&lt;p&gt;We changed two things in the way the team operates going forward. First, we wrote a small drift detector that runs nightly. It pulls the live realm via the Admin API, diffs it against the realm JSON in git, and posts to a Slack channel if they disagree. It is roughly 80 lines and it has caught two console-edits-not-committed in the six weeks since. Second, we now treat OVERWRITE as a forbidden value for any realm that is also editable in the admin console. If you want OVERWRITE semantics, you must also remove admin console write access for everyone except a break-glass account, because otherwise you are building a system where one of two writers silently destroys the other's work. We have written more about this category of GitOps failure in the &lt;a href="https://infraforge.agency/argocd-gitops-recovery/" rel="noopener noreferrer"&gt;ArgoCD and GitOps recovery cluster&lt;/a&gt;, and the same pattern shows up with Grafana dashboards, Argo Workflows templates, and anything else where humans and a controller both have write access to the same object.&lt;/p&gt;

&lt;h2&gt;
  
  
  When GitOps is silently rewriting your identity provider
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your realm config and your cluster disagree&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of this kind of incident is not the Keycloak knowledge. It is recognizing that a green ArgoCD dashboard can coexist with a destroyed production configuration, and knowing which fixes preserve sessions versus which ones lock out every user in the building at midnight. The team we worked with had the Keycloak skills. What they did not have was a recovery sequence that prioritized live state capture over git reconciliation, and a clear rule about when to apply via the Admin API versus when to let ArgoCD do it.&lt;/p&gt;

&lt;p&gt;We run these recovery engagements every week. The OVERWRITE-vs-IGNORE_EXISTING trap has hit two other teams this quarter, both on Keycloak, and we have seen the same shape on Grafana provisioning, Argo Workflows ClusterWorkflowTemplates, and a memorable case with Vault policies. The pattern is always: controller writes, human writes, controller wins on the next reconcile, nobody notices for hours.&lt;/p&gt;

&lt;p&gt;If your identity provider, your dashboards, or any other system with human-editable state is sitting behind ArgoCD and you have ever wondered whether you are quietly losing changes, &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day. The first 30 minutes will tell you whether you have a drift problem, and from there we can scope a recovery that does not require kicking your users out.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/keycloak-realm-overwrite-argocd-sync-drift/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/keycloak-realm-overwrite-argocd-sync-drift/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gitops</category>
      <category>recovery</category>
      <category>gitopsargocd</category>
    </item>
    <item>
      <title>Why a Terraform apply hangs 90 minutes on a custom provider with no timeout</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Fri, 22 May 2026 10:14:24 +0000</pubDate>
      <link>https://forem.com/infraforge/why-a-terraform-apply-hangs-90-minutes-on-a-custom-provider-with-no-timeout-13hh</link>
      <guid>https://forem.com/infraforge/why-a-terraform-apply-hangs-90-minutes-on-a-custom-provider-with-no-timeout-13hh</guid>
      <description>&lt;p&gt;Two hundred destroys that needed 40 seconds of real work hung for 90 minutes. The platform team kicked off a terraform apply to remove stale config entries from an internal service, watched the progress bar stop at minute 12, and then stared at a frozen terminal until someone finally ran kill -9. By that point the state file was half-updated, the DynamoDB lock was still held, and nobody was sure which of the 200 entries had actually been deleted. The custom Terraform provider doing the destroys had a synchronous HTTP call with no context timeout, and the backend behind it was rate-limiting at 5 RPS. Neither side was wrong on its own. The contract between them was broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform apply prints no output for 20+ minutes after destroys begin, no progress, no errors&lt;/li&gt;
&lt;li&gt;The backend service is healthy on its dashboard but throttling requests at a low RPS limit&lt;/li&gt;
&lt;li&gt;kill -9 on the terraform process leaves the DynamoDB state lock held forever&lt;/li&gt;
&lt;li&gt;After force-unlock, terraform state list shows resources that no longer exist in the cloud&lt;/li&gt;
&lt;li&gt;The custom provider in use was written internally and has no timeouts {} block support documented&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the team thought was happening, and what was actually happening
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Forty seconds of work, ninety minutes of silence&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first assumption was that the internal config service was hung. It was not. Its dashboard showed it healthy and serving requests, just slowly. The second assumption was that terraform was making progress and just not printing anything. That one was half true. Terraform was making progress, at exactly 5 deletes per second, which is the rate limit the backend was enforcing. With 200 entries that is 40 seconds of real work. The team waited 90 minutes.&lt;/p&gt;

&lt;p&gt;The reason for the gap was a custom Terraform provider written by a previous platform team. Its DeleteResource function looked roughly like the snippet below. No context. No timeout. No retry-with-backoff. No progress emission back to Terraform's UI layer. When the backend returned a 429, the provider's HTTP client did its own internal retry, swallowed the error, and tried again. Forever. Because the provider never returned from Delete, Terraform's supervisor saw a working call and waited.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;func resourceConfigEntryDelete(d *schema.ResourceData, meta interface{}) error {
    client := meta.(*ConfigClient)
    id := d.Id()

    // No context. No timeout. No bound on retries.
    for {
        err := client.DeleteEntry(id)
        if err == nil {
            return nil
        }
        if isRateLimited(err) {
            time.Sleep(1 * time.Second)
            continue
        }
        return err
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The shape of the broken Delete function (reconstructed from the provider source)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What this should have been is below. The schema.ResourceTimeout block lets users set a timeouts {} block on the resource. The context carries that deadline. When the deadline expires, the provider returns an error and Terraform marks the resource as tainted, not as silently in-progress for the rest of human history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;func resourceConfigEntryDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
    client := meta.(*ConfigClient)
    id := d.Id()

    return retry.RetryContext(ctx, d.Timeout(schema.TimeoutDelete), func() *retry.RetryError {
        err := client.DeleteEntryWithContext(ctx, id)
        if err == nil {
            return nil
        }
        if isRateLimited(err) {
            return retry.RetryableError(err)
        }
        return retry.NonRetryableError(err)
    })
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;What the Delete function should look like&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The half-updated state and the stuck DynamoDB lock
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Why kill -9 left us worse off&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When the engineer finally ran kill -9 on the terraform process, two things happened that compounded the problem. First, the DynamoDB lock entry stayed exactly where it was. Terraform releases its lock on graceful shutdown, not on SIGKILL. So the next person who ran terraform plan got the familiar error and assumed someone else was still working on it. They were not. The lock was a ghost.&lt;/p&gt;

&lt;p&gt;Second, because the destroys had been happening serially at 5 RPS for the 12 minutes before the hang became obvious (the team realized later they had actually waited longer than they thought before noticing the silence), roughly 60 of the 200 entries had actually been deleted from the backend. Terraform had updated the state file in memory as each delete returned, but it had not yet flushed state to the remote backend, because in the default terraform workflow state is written at the end of the apply, not after each resource. So all 60 of those successful deletes were lost from the state file. The cloud was missing 60 entries that tfstate still claimed existed.&lt;/p&gt;

&lt;p&gt;Before doing anything else we confirmed the terraform process was actually dead on the operator's machine. ps aux | grep terraform, on the actual machine, not a tmux pane from yesterday. We have force-unlocked locks that turned out to belong to a process still doing useful work, and the damage is worse than a stuck lock. Once confirmed dead, terraform force-unlock with the lock ID from the error message released DynamoDB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Confirm no terraform process is running on the operator's machine
ssh operator-host 'ps aux | grep -v grep | grep terraform'

# 2. Release the lock (lock ID comes from the error message)
terraform force-unlock 7c4a3e22-1b9d-4e8a-b6d7-9f2a8c5e4d11

# 3. See what state thinks vs what the cloud actually has
terraform plan -refresh-only

# 4. Apply the refresh so state matches reality
terraform apply -refresh-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The recovery sequence after confirming the process is dead&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripting state rm and import for 200 entries
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Reconciling state against a half-finished destroy&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the refresh-only apply, state and cloud agreed on what existed. But the original goal, deleting all 200 entries, was still only partially done. We now had two populations to handle: entries that still existed both in tfstate and in cloud (the destroy had not gotten to them), and entries that had been removed from cloud during the hung apply but were no longer in tfstate either (the refresh had cleaned them up). The first group we could destroy normally. The second group needed nothing further.&lt;/p&gt;

&lt;p&gt;Where it got annoying was a third population we discovered later: a handful of entries that had been deleted from cloud by the hung apply, but where the refresh had failed to notice because the provider's Read function had the same no-timeout bug and was returning stale cached data. Those entries were ghosts in tfstate. For each one we had to run terraform state rm by address. With 47 of them, we scripted it from a diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull current tfstate resource list&lt;/span&gt;
terraform state list | &lt;span class="nb"&gt;grep &lt;/span&gt;config_entry &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; tfstate_entries.txt

&lt;span class="c"&gt;# Pull live entries from the backend (after rate-limit-aware fetch)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--rate-limit&lt;/span&gt; 5 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_API&lt;/span&gt;&lt;span class="s2"&gt;/entries"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].id'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; live_entries.txt

&lt;span class="c"&gt;# Entries in tfstate but not in cloud: these are ghosts&lt;/span&gt;
&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort &lt;/span&gt;tfstate_entries.txt&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort &lt;/span&gt;live_entries.txt | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|^|module.config.config_entry.|'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ghosts.txt

&lt;span class="c"&gt;# Remove them from state&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;addr&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;terraform state &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$addr&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; ghosts.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Generating the state rm commands from a diff between tfstate and the live backend&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the inverse case (entry exists in cloud but not in tfstate), the recovery is terraform import. We did not hit this on this incident but we have hit it on similar ones, and the same diff approach works in the other direction. The general pattern for any half-finished Terraform operation against a custom provider is laid out in &lt;a href="https://dev.to/terraform-state-recovery/"&gt;our Terraform state recovery playbook&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract every custom Terraform provider has to honor
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What the provider should have done&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A custom Terraform provider is a contract. Terraform's whole supervision model assumes the provider plays by it. The contract is short: Create, Read, Update, and Delete each accept a context, each respect the user's timeouts {} block, each emit clear errors when something goes wrong, and each return in bounded time. When a provider violates the contract, Terraform's user-facing behavior degrades in ways that look like Terraform bugs but are not.&lt;/p&gt;

&lt;p&gt;Internal providers skip the contract more often than vendor ones, because the team that writes the provider also runs the backend it talks to, and they convince themselves they have full visibility. They do not. terraform-cli is a separate process. It cannot see your retry loop. It cannot see your in-flight HTTP call. All it sees is a function that has not yet returned. The fix for this provider was three changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Accept context on every CRUD function&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Migrate from the legacy schema.CreateFunc signatures to the context-aware schema.CreateContextFunc variants. This is a non-optional change on terraform-plugin-sdk v2.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Declare and honor timeouts on every resource&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Add a Timeouts: &amp;amp;schema.ResourceTimeout{Create: schema.DefaultTimeout(5 * time.Minute), Delete: schema.DefaultTimeout(5 * time.Minute)} block on every resource schema. Use d.Timeout(schema.TimeoutDelete) inside the function.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Replace internal retry loops with retry.RetryContext&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The retry helper respects the context deadline and surfaces retryable vs non-retryable errors cleanly. Hand-rolled for-loops over time.Sleep do not.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4. Pin the fixed version via .terraform.lock.hcl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Release a new patch version of the provider, update the lockfile, and remove the old version from your internal registry so nobody can fall back to it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The apply pattern itself also needed a change. Destroying 200 entries in one shot against a 5 RPS backend is asking for trouble even with a correct provider, because a 5-minute timeout per resource is generous when one resource genuinely takes 200ms but useless when the queue ahead of you is 199 other deletes. We split future bulk operations into batches of 10 using -target, or we push the backend team to expose a bulk delete endpoint. The provider then wraps the bulk endpoint as a single resource operation instead of looping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxNjz1rw0AQRHv_igG3cuEkTVQEbEmQQIoQ1AkXm9OefXh1J1Yrf4B-fJACJu0y782sl3R1J1JDXa6AXWOsSj5pB-p7uR-w2bxNjkQGlCxsjGuwE1yKxjebsG-KcbDUodd0CS3rYQXsF-q9rr9QVp9VXf1BLVMrIfKEovmIxhpJZpMPRwysl-B4pouFfnl6hZIxJHRhLnp4v9n0Tj_ClWpSjNGCwNntn383_7KEU3ycc3SkZxiFaNxmkHQEz4oMysI0MCS584SyKYQpwlOQURmDkS3LBrsLo4QPIvl6659p6zOXJGm-9t7_AsvfcI0" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxNjz1rw0AQRHv_igG3cuEkTVQEbEmQQIoQ1AkXm9OefXh1J1Yrf4B-fJACJu0y782sl3R1J1JDXa6AXWOsSj5pB-p7uR-w2bxNjkQGlCxsjGuwE1yKxjebsG-KcbDUodd0CS3rYQXsF-q9rr9QVp9VXf1BLVMrIfKEovmIxhpJZpMPRwysl-B4pouFfnl6hZIxJHRhLnp4v9n0Tj_ClWpSjNGCwNntn383_7KEU3ycc3SkZxiFaNxmkHQEz4oMysI0MCS584SyKYQpwlOQURmDkS3LBrsLo4QPIvl6659p6zOXJGm-9t7_AsvfcI0" alt="The relationship that broke and what fixes each side" width="641" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The relationship that broke and what fixes each side&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When a custom provider has left your state in an unknown shape
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you are looking at a hung apply right now&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hung Terraform applies against internal providers are the kind of incident that sounds boring in a postmortem and feels terrifying in the moment. You cannot tell if the apply is still doing useful work or stuck forever. You cannot kill it without risking a half-finished state. You cannot force-unlock until you are certain the process is dead. And once you do recover, you do not actually know which resources got modified and which did not, because the provider did not emit progress and the state file was not flushed.&lt;/p&gt;

&lt;p&gt;We run these recovery engagements often enough that the script above is templated. The no-timeout custom provider pattern shows up in maybe one in five of the Terraform recoveries we have done this year, almost always with internal providers written years ago by an engineer who has since left. The fix is mechanical once you know the shape of the failure: confirm process death, force-unlock, refresh-only plan, diff state against cloud, reconcile with state rm and import, then patch the provider so it cannot happen again.&lt;/p&gt;

&lt;p&gt;If you are staring at a hung apply right now and you are not sure whether to kill it, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day. If the apply is already dead and you are sorting through the wreckage, the same engagement covers the state reconciliation and the provider fix together.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/terraform-apply-hung-custom-provider-no-timeout/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/terraform-apply-hung-custom-provider-no-timeout/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>state</category>
      <category>recovery</category>
      <category>terraformstate</category>
    </item>
    <item>
      <title>Grafana 'No Data' after migration: 7 reconcilers we had to kill first</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Thu, 21 May 2026 21:13:36 +0000</pubDate>
      <link>https://forem.com/infraforge/grafana-no-data-after-migration-7-reconcilers-we-had-to-kill-first-4gfc</link>
      <guid>https://forem.com/infraforge/grafana-no-data-after-migration-7-reconcilers-we-had-to-kill-first-4gfc</guid>
      <description>&lt;p&gt;The first fix lasted 90 seconds. We had corrected the Grafana datasource URL from prometheus:9999 back to prometheus:9090, watched the pod roll, refreshed the dashboard, and seen one panel come alive. By the time we opened a second tab, the ConfigMap was back to 9999. That was the real incident. The 'No Data' dashboards were a symptom of an observability stack that someone, or something, was actively re-corrupting from at least seven places we had not yet found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana dashboards show 'No Data' on every panel after a cluster migration, and kubectl edit fixes revert within 1-3 minutes&lt;/li&gt;
&lt;li&gt;Prometheus targets page is empty or stuck on a namespace that does not exist anymore&lt;/li&gt;
&lt;li&gt;ClusterRoleBindings you just recreated reference a ClusterRole name nobody on the team typed&lt;/li&gt;
&lt;li&gt;ps aux shows kworker-looking processes with elevated CPU that hold open file descriptors to a kubeconfig&lt;/li&gt;
&lt;li&gt;kubectl get cronjobs -A shows entries in namespaces nobody on the platform team remembers creating&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why we stopped fixing config and started looking for what was undoing it
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The fix that lasted 90 seconds&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The team that called us had been at this for nine hours. After a cluster migration, every Grafana dashboard was blank. The on-call had walked through the obvious things. The Prometheus datasource in Grafana pointed at port 9999. The Loki datasource pointed at port 3199. The Prometheus scrape config had annotation keys nobody recognized (prometheus_io_metrics_enabled instead of prometheus_io_scrape) and targeted a namespace that did not exist. The Grafana deployment had a config-validator init container running sleep 3600. Each one of those was a real bug. Each one of those, fixed in isolation, would revert before the next pod rolled out.&lt;/p&gt;

&lt;p&gt;The shape of what they were describing was not a botched migration. A botched migration leaves bad state. This was bad state being re-applied. When manual kubectl edits revert in minutes, the question is no longer 'what is wrong with the manifest', it is 'what process has write access and is reconciling against a corrupt source of truth'. We told them to stop fixing config until we had inventoried every actor that could write to the cluster.&lt;/p&gt;

&lt;p&gt;This sounds obvious written down. In the middle of an incident, with executives asking for an ETA on dashboards, the instinct is to keep patching. We have run this play enough times now to know the patching never converges. You burn three more hours and your changes still revert. The only path out is persistence-first triage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven places state was being rewritten from
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;A kworker thread holding a kubeconfig&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We started on the nodes. ps auxf on each worker showed a process named [kworker/u8:2-events_unbound]. Square brackets usually mean a kernel thread, and you learn early not to touch kernel threads. We almost moved on. The thing that snagged our attention was CPU: a real kernel worker thread on an idle-ish node should not be sitting at 12 percent. We pulled its open file descriptors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls -l /proc/$(pgrep -f 'kworker/u8:2')/fd/ 2&amp;gt;/dev/null | head
lr-x------ 1 root root 64 ... 3 -&amp;gt; /root/.kube/config
lrwx------ 1 root root 64 ... 7 -&amp;gt; socket:[884213]
lr-x------ 1 root root 64 ... 9 -&amp;gt; /opt/.reconciler/state.json
$ cat /proc/$(pgrep -f 'kworker/u8:2')/comm
kworker/u8:2-events_unbound
$ readlink /proc/$(pgrep -f 'kworker/u8:2')/exe
/opt/.reconciler/agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Kernel threads do not hold kubeconfigs or have an exe link. This was a userspace binary with a spoofed comm name.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That was reconciler one. The same trick was on every node, with comm names rotating through plausible kworker patterns (flush-dm-0, mm_percpu_wq). We collected the binary, killed every instance, removed the systemd unit that was respawning it, and moved on. Then we did the boring sweep nobody wants to do in the middle of an incident.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;kubectl get cronjobs -A surfaced config-audit in kube-system and prometheus-metrics-federation in cattle-monitoring-system. Neither was ours. Both ran every 60 seconds and wrote ConfigMaps.&lt;/li&gt;
&lt;li&gt;systemctl list-timers on each node showed k8s-health-monitor.timer firing every two minutes against the API server with a node-local kubeconfig.&lt;/li&gt;
&lt;li&gt;ls /etc/cron.d/ had a host cron entry running a script under /opt/.reconciler/ once a minute as a belt-and-braces backup to the systemd timer.&lt;/li&gt;
&lt;li&gt;kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations turned up pod-policy-webhook, namespace-policy-webhook, and the one that hurt us most, rbac-policy-enforcer.&lt;/li&gt;
&lt;li&gt;chattr was set +i on /etc/cron.d/k8s-health and on the corrupted ConfigMap manifests staged on disk. Edits failed silently with 'operation not permitted'.&lt;/li&gt;
&lt;li&gt;Finalizers on the CronJobs prevented kubectl delete from completing until we patched them off.&lt;/li&gt;
&lt;li&gt;PodSecurity labels on cattle-monitoring-system were set to enforce a baseline that blocked our debug pods from running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven places. Any one of them, left running, would have re-corrupted the stack within minutes of our fixes. Some teams have a reconciler. This cluster had a mesh of them, each one a backup for the others. That is not a thing healthy infrastructure does; it is a thing a previous incident or a hostile takeover does. Either way, the response is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order we neutralized things, and why order matters
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Why we deleted the webhooks before touching RBAC&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a trap in this kind of cleanup. If you fix the visible problem before you neutralize the actor reverting it, you have wasted a fix and burned credibility with the room. The worst version of this in our case was the RBAC webhook. The Prometheus ClusterRoleBinding had been deleted entirely, and the deployment had been swapped to the default service account. The obvious move was to recreate the CRB and patch the deployment back to a proper SA.&lt;/p&gt;

&lt;p&gt;We tried it once, in a scratch namespace, just to see. The CRB came back with roleRef pointing at a ClusterRole that did not exist. The mutating webhook was matching anything with 'prometheus' or 'monitoring' in the name and silently rewriting the roleRef. If we had run that against the real CRB in production with the team watching, we would have looked like we did not know what we were doing, and the fix would not have worked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxF0Dtuw0AMBNA-p5gDyECQIkWKALbkTz5FIAdpFi5WK8oiLC0Ncm3FOX2gTZGajwMOu0Gm0HtN-KzugKXbcGyhFCQGHkjtgMXiGStX0yhXQsfRD_xDavCxReh9SooFH-6AVaalq2igRPDtyGYsERM1vcjJZlRmVLl9kjNKlfgqjRVIPJJagV4sIajE2VbZrt0bD8Pf5P8wnFUCmVFOXWe5cV-k3N0QJfUcj5iUExk6UTzeZ7jJcOs2_I0gsePjEz5URko9XazAVn3noy_wLqdcapsXdq6moOQToV4ty9x9T3rlQMsQ5BLTbHfZvriaLM0vPUtrBaQx0ivhASNHWPIND5xumDi2Mh1-AY5GfqY" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxF0Dtuw0AMBNA-p5gDyECQIkWKALbkTz5FIAdpFi5WK8oiLC0Ncm3FOX2gTZGajwMOu0Gm0HtN-KzugKXbcGyhFCQGHkjtgMXiGStX0yhXQsfRD_xDavCxReh9SooFH-6AVaalq2igRPDtyGYsERM1vcjJZlRmVLl9kjNKlfgqjRVIPJJagV4sIajE2VbZrt0bD8Pf5P8wnFUCmVFOXWe5cV-k3N0QJfUcj5iUExk6UTzeZ7jJcOs2_I0gsePjEz5URko9XazAVn3noy_wLqdcapsXdq6moOQToV4ty9x9T3rlQMsQ5BLTbHfZvriaLM0vPUtrBaQx0ivhASNHWPIND5xumDi2Mh1-AY5GfqY" alt="Neutralize first, then fix. RBAC and any 'monitoring'-named resource go last because the webhook would mutate them on creation." width="276" height="1094"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Neutralize first, then fix. RBAC and any 'monitoring'-named resource go last because the webhook would mutate them on creation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the order was: strip finalizers from the CronJobs, chattr -i on the immutable files, delete the three webhook configurations, suspend and delete the CronJobs in kube-system and cattle-monitoring-system, mask the systemd timer, remove the host cron entry, kill the userspace reconciler processes on every node and remove their systemd unit. Then we sat for 60 seconds and watched. No ConfigMap mutations. No Deployment patches. Quiet cluster. That was the first time in nine hours the cluster had been quiet, and you could feel the room exhale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring the observability stack once writes were ours alone
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The order we put it back together&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the reconcilers gone, the config fixes were the easy part. We did them top-down by data flow: scrape config, then service routing, then the consumers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Prometheus ConfigMap: restore annotation keys, fix namespace, drop interval&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring get cm prometheus-config &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prom-cm.yaml
&lt;span class="c"&gt;# edit: prometheus_io_metrics_* -&amp;gt; prometheus.io/scrape, /metrics, port&lt;/span&gt;
&lt;span class="c"&gt;#       namespaces: [bleater-nonexistent] -&amp;gt; the real app namespace&lt;/span&gt;
&lt;span class="c"&gt;#       scrape_interval: 300s -&amp;gt; 30s&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/prom-cm.yaml

&lt;span class="c"&gt;# 2. Prometheus Service: targetPort 9099 -&amp;gt; 9090&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring patch svc prometheus &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'[{"op":"replace","path":"/spec/ports/0/targetPort","value":9090}]'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Service account and RBAC (webhooks already deleted)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring create sa prometheus
kubectl create clusterrolebinding prometheus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--clusterrole&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prometheus &lt;span class="nt"&gt;--serviceaccount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;monitoring:prometheus
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nb"&gt;set &lt;/span&gt;serviceaccount deploy/prometheus prometheus

&lt;span class="c"&gt;# 4. Prometheus readiness probe: port 9099 /-/healthz -&amp;gt; 9090 /-/ready&lt;/span&gt;
&lt;span class="c"&gt;# 5. Loki: drop -server.http-listen-port=3199 arg, fix svc selector loki-server -&amp;gt; loki&lt;/span&gt;
&lt;span class="c"&gt;# 6. Grafana: remove init container, fix probe ports, drop GF_SERVER_HTTP_PORT,&lt;/span&gt;
&lt;span class="c"&gt;#    fix volume refs (-v2 -&amp;gt; base name), reset admin secret&lt;/span&gt;
&lt;span class="c"&gt;# 7. Delete NetworkPolicy grafana-egress-restrict&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring delete networkpolicy grafana-egress-restrict
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;We applied these as separate kubectl operations on purpose, not a single helm rollout, so we could verify each one stuck before moving on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After every step we waited 30 seconds and re-read the resource. Nothing reverted. We rolled the Grafana deployment, watched it come up clean with no init container blocking startup, hit the Prometheus targets page and saw 11 active up series including the application pods, then loaded a dashboard. Data. The two-minute stability window passed with no drift. We held the bridge for another 20 minutes anyway, because the team needed to see it not break more than they needed us to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistence-first triage is now the default for post-migration observability failures
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed in our own playbook&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We have changed how we open any incident where fixes do not stick. The first 15 minutes are no longer spent on config. They are spent on a sabotage sweep: cronjobs in every namespace (not just the obvious ones, cattle-monitoring-system bit us and we have seen it bite others), systemd timers on every node, /etc/cron.d, validating and mutating webhooks, finalizers on resources we expect to delete, immutable file attributes on staged manifests, and a ps auxf on every node with an eye on anything in square brackets that has an exe link.&lt;/p&gt;

&lt;p&gt;We also changed how we think about kubectl edit during a live incident. If a change has to land and the cluster has any chance of having a reconciler we have not yet found, we apply through git and watch the apply, not edit the live object. It is slower by 90 seconds and saves you from spending an hour wondering why your fix evaporated. We have written more on the same instinct in our notes on &lt;a href="https://dev.to/problems/kubernetes-release-failures/"&gt;Kubernetes release failures&lt;/a&gt; and on &lt;a href="https://dev.to/argocd-gitops-recovery/"&gt;ArgoCD self-heal traps&lt;/a&gt;, which is the friendly version of this same pattern.&lt;/p&gt;

&lt;p&gt;The non-obvious lesson from this incident is that hostile or accidental reconcilers do not announce themselves. The kworker spoof was the cleverest piece; it would have survived a casual ps. The cattle-monitoring-system namespace looked legitimate to anyone who had ever run Rancher. The webhook had a name (rbac-policy-enforcer) that sounded like something a security team would install. In each case the move that surfaced it was boring: enumerate the category exhaustively, then ask which entries the team can account for. Anything they cannot account for is the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When fixes revert, the problem is not the fix
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your post-migration monitoring keeps un-fixing itself&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of incidents like this is not the Prometheus annotation key or the Grafana port. Those take 20 minutes once the cluster stops fighting you. The hard part is having the discipline to stop patching and inventory every actor that can write to your cluster, especially when leadership is asking for an ETA and your instinct is to keep typing. The hard part is also knowing what the categories of reconciler are. If you have never had to look for a mutating webhook that rewrites RBAC, or a host process pretending to be a kworker, the search takes hours. If you have seen it before, it takes 15 minutes.&lt;/p&gt;

&lt;p&gt;We run these recovery engagements every week. We have seen the kworker spoof twice this year, the cattle-monitoring-system CronJob trick three times, and the RBAC-mutating webhook in two unrelated post-migration incidents. The playbook is portable; the patience to run it before patching is the part teams in the middle of an outage struggle with, and that is usually why they call us.&lt;/p&gt;

&lt;p&gt;If your dashboards are blank after a migration and your fixes are not sticking, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day. Bring node SSH access, kubectl with cluster-admin, and a list of every namespace you can name. We will handle the rest.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/grafana-no-data-after-migration-reconcilers-reverting-fixes/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/grafana-no-data-after-migration-reconcilers-reverting-fixes/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>k8s</category>
      <category>reliability</category>
      <category>kubernetescicd</category>
    </item>
    <item>
      <title>When MinIO Deny Wins Cause Silent Upload Failure</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Thu, 21 May 2026 01:44:45 +0000</pubDate>
      <link>https://forem.com/infraforge/why-minio-uploads-return-200-and-never-land-a-deny-wins-iam-trap-1eko</link>
      <guid>https://forem.com/infraforge/why-minio-uploads-return-200-and-never-land-a-deny-wins-iam-trap-1eko</guid>
      <description>&lt;p&gt;The dashboards were green. The api-gateway logged 12,400 successful media POSTs over six hours, the storage service SDK reported 200 on every PutObject, and the fanout queue happily processed every notification. The MinIO bucket had gained zero new objects in the same window. Users were seeing broken image tiles in their feeds and the on-call team had spent three hours chasing the fanout service because that was the only place the symptom was visible. The actual problem was an explicit Deny on s3:PutObject sitting inside a bucket policy that had been added during a security hardening sprint two days earlier, and MinIO was doing exactly what S3 IAM semantics say it should do: deny wins, even when the user policy says Allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload endpoints return HTTP 200 but the object never appears in the bucket&lt;/li&gt;
&lt;li&gt;Bucket notification webhooks fire and downstream consumers process phantom events&lt;/li&gt;
&lt;li&gt;Grafana shows upload throughput as healthy because SDK success metrics dominate the panel&lt;/li&gt;
&lt;li&gt;Users report broken image links while every service-level dashboard is green&lt;/li&gt;
&lt;li&gt;A recent IAM or bucket policy change correlates in time with the start of phantom uploads&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The discrepancy that should have been the first alert
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;12,400 successful uploads, zero new objects&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We came in on the third hour of the incident. The team had been chasing the fanout consumer because user reports were all of the form 'my avatar is broken' and the only service touching media after upload was fanout. Their working theory was that fanout was racing the CDN, or that the notification payload was missing a key, or that signed URLs were expiring early. They had three engineers staring at fanout-service logs and finding nothing wrong, because there was nothing wrong with fanout-service.&lt;/p&gt;

&lt;p&gt;The question we asked, which is the question we always ask first when an upload pipeline misbehaves: how many objects has the bucket actually gained in the last hour? Not how many uploads the API recorded. Not how many notifications fanout received. How many real objects exist now that did not exist sixty minutes ago. We ran the listing against the MinIO admin API and the answer was zero. The bucket had not gained a single object since 02:14 that morning, which lined up almost exactly with the merge time of a security hardening PR the platform team had landed two days prior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# count objects added in the last hour
mc find local/bleater-media --newer-than 1h | wc -l
# 0

# meanwhile the storage-service success counter
curl -s http://prometheus/api/v1/query \
  --data-urlencode 'query=sum(increase(storage_service_put_object_success_total[1h]))'
# {"status":"success","data":{"result":[{"value":[..., "2074"]}]}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Two views of the same hour. The SDK was confident. The bucket was not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once we had that gap on a shared screen the room changed. The fanout investigation got paused. The new question was: why is the SDK reporting success for writes that never persisted?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 200 came from when the object never landed
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What the SDK thought, and what the server actually did&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the part of the story that is worth understanding even if you never touch MinIO. The storage service was using a streaming PutObject path. The client opens a connection, the server accepts headers and begins reading the body, and the bucket notification configuration is wired to fire on the API receipt of the PutObject call. In a healthy run, the server then writes the object, the response is 200, and the notification correctly reflects a real write. In our broken run, the server accepted the headers, fired the notification, evaluated the IAM policies, hit the explicit Deny, and closed the stream. The client SDK saw the connection close after headers were ack'd and treated it as success because the response framing looked clean enough at the transport layer. The notification had already gone out. The audit log recorded the deny. Nobody was reading the audit log.&lt;/p&gt;

&lt;p&gt;Enabling the MinIO audit target was the diagnostic turn. Two commands and the lie unwound itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mc admin config set local audit_webhook:1 \
  endpoint="http://collector:8080/minio-audit" enable=on
mc admin service restart local

# tail the collector for a few seconds
# {"api":{"name":"PutObject","bucket":"bleater-media",
#        "object":"avatars/u-83421.jpg","status":"AccessDenied",
#        "statusCode":403},
#  "requestClaims":{"accessKey":"storage-service"},
#  "error":{"message":"Access Denied.",
#           "source":["cmd/auth-handler.go:checkRequestAuthTypeCredential"]}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Audit log showed 403 AccessDenied on every PutObject from the storage-service identity. The client never saw it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The storage-service identity had a user policy that explicitly granted s3:PutObject on arn:aws:s3:::bleater-media/*. We confirmed this in two seconds. Which meant the deny had to be coming from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bucket policy nobody had read since the hardening PR
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where the explicit Deny was hiding&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MinIO, like S3, evaluates IAM in two layers. The user (or service account) policy attached to the identity is one layer. The bucket policy attached to the resource is the other. An explicit Deny in either layer overrides any Allow in either layer. The hardening PR had added a bucket policy intended to lock down a different identity, an analytics reader that had been overprovisioned, and the author had used a wildcard Principal with a NotPrincipal exception that was wrong. The effective rule said: deny s3:PutObject on this bucket for everyone who is not the analytics-reader identity. Which of course included the storage service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s -u $ADMIN:$SECRET \
  http://minio:9000/minio/admin/v3/get-bucket-policy?bucket=bleater-media \
  | jq .

# {
#   "Version": "2012-10-17",
#   "Statement": [
#     {
#       "Sid": "RestrictWritesToAnalyticsReader",
#       "Effect": "Deny",
#       "NotPrincipal": { "AWS": ["arn:aws:iam:::user/analytics-reader"] },
#       "Action": ["s3:PutObject"],
#       "Resource": ["arn:aws:s3:::bleater-media/*"]
#     }
#   ]
# }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The bucket policy that swallowed every write. NotPrincipal with Deny is a footgun in any S3-compatible IAM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We have seen NotPrincipal misused in three separate engagements this year. It reads as if it means 'apply this rule to everyone except this principal' the same way a NotAction would, but the semantics interact badly with cross-account and service-account identities. If you are writing a Deny that you want scoped to a specific identity, write the Deny with Principal naming the identity you mean to block. Do not invert it. The blast radius of a wrong inversion is the entire bucket.&lt;/p&gt;

&lt;p&gt;Before we touched anything we wanted to rule out the obvious adjacent causes, because removing a security-hardening policy at 06:00 without confirmation is the kind of fix that becomes its own incident. We checked credential expiry on the storage-service service account (valid for another 47 days), checked network policy for any new egress restrictions from the storage-service namespace (none), and confirmed bucket versioning was off so we were not chasing delete markers. The audit log had already told us the answer; we just wanted the rollback to be unambiguous when we wrote it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-minute patch and the queue we had to reconcile
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Removing the Deny without re-opening the bucket&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two questions before patching. First, did we want to fix the bucket policy in place, or revert the hardening PR entirely? We chose patch in place. The hardening PR had also tightened three other identities correctly, and reverting would have undone work that was real. Second, did we want to leave the analytics-reader restriction in some form? Yes, but written correctly. We rewrote the statement as an explicit Deny on the analytics-reader principal for write actions, which is what the author had intended.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat &amp;gt; /tmp/bleater-media-policy.json &amp;lt;&amp;lt;'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BlockAnalyticsReaderWrites",
      "Effect": "Deny",
      "Principal": { "AWS": ["arn:aws:iam:::user/analytics-reader"] },
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::bleater-media/*"]
    }
  ]
}
EOF

curl -s -u $ADMIN:$SECRET \
  -X PUT \
  --data-binary @/tmp/bleater-media-policy.json \
  "http://minio:9000/minio/admin/v3/set-bucket-policy?bucket=bleater-media"

# validate with a real write from the storage-service identity
curl -s -X PUT -T /tmp/canary.bin \
  -H "Authorization: ...storage-service-sigv4..." \
  http://minio:9000/bleater-media/canary/$(date +%s).bin

mc ls local/bleater-media/canary/ | tail -1
# [2024-...] 4.0KiB STANDARD 1717420831.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Replace the inverted NotPrincipal with an explicit Principal Deny, then prove with a canary that the storage-service identity can write.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The canary landed. Real uploads from the application resumed within the next minute as new requests came in. That fixed the forward path. It did not fix the past six hours.&lt;/p&gt;

&lt;p&gt;The phantom notification problem was harder to bound. The fanout service had processed roughly 12,400 notification events for objects that did not exist, which meant 12,400 user timelines contained references to media that would 404 forever. We pulled the notification log from the RabbitMQ stream and diffed against the actual object listing in the bucket. The count of phantom references came in at 12,387. We pushed a one-shot reconciliation job that re-emitted upload prompts to the affected users for any media uploaded in that window, because we had no way to recover the original bytes; the storage service had streamed them to a connection that was closed before persistence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxdj81uwjAQhO88xRxTqVFR1VMOlipoKlQFaMMLbJwNbCF2am-QePvK_EgtR_v7NLMT-WdkZ3kutA3UT4CBgoqVgZxiBoqYHYSd3pG6TiiqD7TlPHI4iuU7p0pKJW6xugPLBL6oaUSrzztWJtaR86P-iZ3lxtR1gfWq3uCp51YImfS0ZTQn5fgwAeo6N6YqsB511XyzVWRRA1OfYJUbsyzQjHbPCudVOrGk4h2y1_UCgS3LoEld5saUxcU5wV-ibGBSbq9JVQE-0mEkZSxeq0fsRDFndzrz663WO8f2XGEPPnKLrJ5_IDC1MY18nk6vZ-fGzIr0xupjApSXhve3za298wFD8JZjFLe9lZQFXqYvyK6S4yMHDByiROX2PMUrw6fvssCwI6e-_zf-EU3we3Y4iNtDHDrm9hee86q0" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxdj81uwjAQhO88xRxTqVFR1VMOlipoKlQFaMMLbJwNbCF2am-QePvK_EgtR_v7NLMT-WdkZ3kutA3UT4CBgoqVgZxiBoqYHYSd3pG6TiiqD7TlPHI4iuU7p0pKJW6xugPLBL6oaUSrzztWJtaR86P-iZ3lxtR1gfWq3uCp51YImfS0ZTQn5fgwAeo6N6YqsB511XyzVWRRA1OfYJUbsyzQjHbPCudVOrGk4h2y1_UCgS3LoEld5saUxcU5wV-ibGBSbq9JVQE-0mEkZSxeq0fsRDFndzrz663WO8f2XGEPPnKLrJ5_IDC1MY18nk6vZ-fGzIr0xupjApSXhve3za298wFD8JZjFLe9lZQFXqYvyK6S4yMHDByiROX2PMUrw6fvssCwI6e-_zf-EU3we3Y4iNtDHDrm9hee86q0" alt="The notification fires before the deny evaluation completes. Every layer below MinIO sees success." width="1460" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The notification fires before the deny evaluation completes. Every layer below MinIO sees success.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed so the next deny-wins conflict is not silent
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The synthetic that would have caught this in 90 seconds&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The deeper lesson here is not about MinIO. It is that SDK success and server persistence are different facts, and most observability stacks conflate them. Every metric on the storage service dashboard came from the SDK return code. Every metric on the fanout dashboard came from notification receipt. Nothing in the stack was sourced from the only ground truth that mattered, which was the count of objects actually present in the bucket. The hardening PR could have done much worse than this and we would still have been blind.&lt;/p&gt;

&lt;p&gt;We made three changes after this incident. First, a synthetic that writes a canary object every 60 seconds and then lists the bucket to confirm the canary is there. The metric is the gap between writes and confirmed reads, and it alerts at gap greater than two intervals. This is the kind of probe we now build into every object-storage path we touch. Second, the MinIO audit webhook now ships to the log aggregation pipeline with a Loki alert rule on any sustained rate of statusCode 403 for PutObject, scoped per identity. Third, we wrote a pre-merge check for bucket policy changes that flags any statement using NotPrincipal with Effect Deny and requires an explicit reviewer sign-off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Loki alert: deny-wins on PutObject for any service identity
- alert: MinioPutObjectDenied
  expr: |
    sum by (accessKey) (
      rate({job="minio-audit"}
        | json
        | api_name = "PutObject"
        | api_statusCode = "403"
        [5m])
    ) &amp;gt; 0
  for: 2m
  labels:
    severity: page
  annotations:
    summary: "MinIO denying PutObject for {{ $labels.accessKey }}"
    runbook: "Check bucket policy and user policy for explicit Deny statements."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The alert that would have paged the on-call within five minutes of the hardening PR rolling out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your bucket notifications drive downstream business logic, you have the same shape of risk we did. The notification path and the persistence path are not the same path, and the IAM evaluation sits between them. Assume nothing about server persistence based on SDK return codes. Read the audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a hardening PR silently revokes write access in production
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your object store is quietly lying to your monitors&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This class of incident is hard for a specific reason: every monitoring surface a normal team has built reports healthy, because every normal monitoring surface reads from the layer above the failure. The teams we work with that have hit this pattern were not careless. They had dashboards, they had alerts, they had error budgets. None of those instruments were positioned to see a server-side deny that the SDK swallowed. The fix is a small synthetic and an audit log alert, and they take an afternoon to build. Getting to the point of knowing you need them usually takes one bad incident.&lt;/p&gt;

&lt;p&gt;We run object-storage and IAM recovery engagements often enough that this exact shape, a hardening PR introducing a deny-wins conflict against a service account, has come up three times this year on three different stacks (MinIO, Ceph RGW, and AWS S3 with a SCP). The mechanics are the same in all three. If your team is staring at green dashboards and broken user reports, the gap between SDK success and ground-truth persistence is the first place to look. If you want a second set of eyes on a hardening rollout before it lands, or you are inside one of these incidents right now, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day. We also document the audit-log and synthetic patterns in more depth on the &lt;a href="https://dev.to/infrastructure-audit-readiness/"&gt;infrastructure audit readiness&lt;/a&gt; page if you want to read ahead.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/minio-deny-wins-silent-upload-failure/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/minio-deny-wins-silent-upload-failure/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>object</category>
      <category>storage</category>
      <category>recovery</category>
      <category>auditreadiness</category>
    </item>
    <item>
      <title>ArgoCD Drift: Three Namespaces, One JWT Hotfix</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Wed, 20 May 2026 22:09:53 +0000</pubDate>
      <link>https://forem.com/infraforge/argocd-drift-across-3-namespaces-after-a-jwt-hotfix-how-we-reconciled-without-breaking-auth-3g4l</link>
      <guid>https://forem.com/infraforge/argocd-drift-across-3-namespaces-after-a-jwt-hotfix-how-we-reconciled-without-breaking-auth-3g4l</guid>
      <description>&lt;p&gt;The on-call team had been chasing a 30% 401 rate on profile-service for two hours when we got pulled in. Only profile-service, only some pods, only authenticated requests. The shape of that number is what gave it away: a 30% failure rate on a service backed by a 3-pod deployment is what you see when one pod out of three is running with a different config. Except it was not a config rollout in flight. It was a week-old JWT key rotation hotfix that had landed in the live cluster, never made it to Git, and ArgoCD auto-sync had been disabled across three applications and quietly left off. By the time we opened a terminal there were four versions of the same ConfigMap floating around: one in Git, three in three namespaces, none of them in agreement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A service is returning 401s on a fraction of requests that matches a pod count ratio (30% on 3 pods, 25% on 4 pods)&lt;/li&gt;
&lt;li&gt;ArgoCD shows applications as OutOfSync but auto-sync is disabled and nobody remembers turning it off&lt;/li&gt;
&lt;li&gt;kubectl diff against the rendered Helm or Kustomize output shows changes nobody can attribute to a recent PR&lt;/li&gt;
&lt;li&gt;Multiple namespaces have a propagated copy of the same ConfigMap and the copies disagree&lt;/li&gt;
&lt;li&gt;A recent incident postmortem mentions a manual kubectl edit or kubectl patch that was never followed by a Git commit&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The first 20 minutes: mapping how far the drift had spread
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Four ConfigMaps, four different values&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The initial theory from the on-call lead was that a pod had missed the last restart and was still holding the pre-rotation JWT public key. Reasonable theory. It was wrong, but only because it was incomplete.&lt;/p&gt;

&lt;p&gt;We ran the obvious diff first. Pull the ConfigMap from each of the three namespaces, pull the manifest from the Git repo at HEAD, compare. What we expected to find was two values: a correct one in the cluster and a stale one in Git, or the reverse. What we actually found was four.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# auth-service namespace
$ kubectl -n auth get cm auth-config -o jsonpath='{.data.JWT_ALGORITHM} {.data.JWT_PUBLIC_KEY_ID}'
RS256 key-2024-11-rot

# like-service namespace (propagated copy)
$ kubectl -n like get cm auth-config -o jsonpath='{.data.JWT_ALGORITHM} {.data.JWT_PUBLIC_KEY_ID}'
RS256 key-2024-09

# profile-service namespace (propagated copy)
$ kubectl -n profile get cm auth-config -o jsonpath='{.data.JWT_ALGORITHM} {.data.JWT_PUBLIC_KEY_ID}'
HS256 key-2024-09

# Git, main branch
$ grep -E 'JWT_(ALGORITHM|PUBLIC_KEY_ID)' deploy/*/auth-config.yaml
deploy/auth/auth-config.yaml:  JWT_ALGORITHM: HS256
deploy/auth/auth-config.yaml:  JWT_PUBLIC_KEY_ID: key-2024-09
# (and the same stale pair in like and profile manifests)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;What the diff actually showed. Four states of the same ConfigMap.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The story behind the four states reconstructed quickly from the previous week's incident channel. During the rotation, an SRE had patched auth-service's ConfigMap directly with the new RS256 key. They then walked the change into the like-service namespace and got the algorithm right but typo'd the key ID, leaving the old one. They ran out of focus before reaching profile-service, intended to come back to it, and did not. ArgoCD auto-sync had been disabled across all three applications during the incident as a guardrail and never re-enabled, which is the only reason the cluster state had survived a week without ArgoCD reverting it back to the stale Git values.&lt;/p&gt;

&lt;p&gt;So the 30% 401 rate had a clean explanation. profile-service's pods had been restarted at some point and picked up the HS256 config from the unpatched ConfigMap. The auth-service was now issuing RS256-signed tokens. profile-service was trying to validate them as HS256 with the wrong key ID. The only requests that did not 401 were the ones that happened to skip the auth path entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that almost broke production a second time
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Why Git was the wrong source of truth&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The instinct, when you find drift between Git and a cluster, is to trust Git. That is the whole point of GitOps. The pull request is the source of truth and the cluster is downstream. Run an ArgoCD sync, let it overwrite the live state, move on.&lt;/p&gt;

&lt;p&gt;That instinct would have broken auth-service inside of 30 seconds. Git held the pre-rotation HS256 values. The new private key that auth-service was signing tokens with did not match the public key Git was about to push into the ConfigMap. A sync from Git would have invalidated every token in flight across all three services, not just 30% of them.&lt;/p&gt;

&lt;p&gt;We had to invert the model. For this one incident, the auth-service namespace's live ConfigMap was the canonical truth, and Git was stale. The recovery had to flow live-to-Git first, then Git-to-cluster for the other two namespaces, and only then could auto-sync be turned back on. The order mattered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxl0EFPwkAQBeA7v-Ld7SIt4MFEk9KiIVFM0HhpOazbKWzY7m62C9KkP96UoClynGTyvTdTKvMtttx5fKQDIM743m9ZTe4gBUHJAyExupSbV25zvXqPpncBdtSwaBRNWBgyZ3yuk3j5tlwk8csajD224RDzozXOg-sCwlSV9C1m2bP09yjIKtPcdjnB76DkjoJcnyfrTCkVoeJallT7GntbcE_FegDMTgnRELHbmCRF3WiBfulcQxtmbACuHPGigTDOkfAtksvr3hst-uj4Eu1K9dBSHqlG7bmi7gFYpC3SrL90BU4uwfNdVyZXG-Ok31a4-ZPn2b_tHj4_4dMhPsnJssFkFMJxT3jAqMVTtiJGmn8p6v5i2CnbaHClMAa3tl7_AMw_qTk" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkroki.io%2Fmermaid%2Fpng%2FeJxl0EFPwkAQBeA7v-Ld7SIt4MFEk9KiIVFM0HhpOazbKWzY7m62C9KkP96UoClynGTyvTdTKvMtttx5fKQDIM743m9ZTe4gBUHJAyExupSbV25zvXqPpncBdtSwaBRNWBgyZ3yuk3j5tlwk8csajD224RDzozXOg-sCwlSV9C1m2bP09yjIKtPcdjnB76DkjoJcnyfrTCkVoeJallT7GntbcE_FegDMTgnRELHbmCRF3WiBfulcQxtmbACuHPGigTDOkfAtksvr3hst-uj4Eu1K9dBSHqlG7bmi7gFYpC3SrL90BU4uwfNdVyZXG-Ok31a4-ZPn2b_tHj4_4dMhPsnJssFkFMJxT3jAqMVTtiJGmn8p6v5i2CnbaHClMAa3tl7_AMw_qTk" alt="Recovery flow. Live state was canonical for one application, Git was canonical after the commit for the other two." width="761" height="622"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Recovery flow. Live state was canonical for one application, Git was canonical after the commit for the other two.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How we got the canonical values into Git and synced the stragglers
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Committing a live hotfix back to Git without breaking auth&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The commit itself was unremarkable once we had a clear model. We pulled the auth-service ConfigMap, extracted the two fields, and updated all three manifests in the deploy repo in a single PR with a postmortem link in the description. The PR title was 'Hotfix reconcile: commit post-rotation JWT values from live state (incident #INC-441)' because future-us was going to want to know why these values arrived without an upstream change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Export canonical values from auth-service namespace
KID=$(kubectl -n auth get cm auth-config -o jsonpath='{.data.JWT_PUBLIC_KEY_ID}')
ALG=$(kubectl -n auth get cm auth-config -o jsonpath='{.data.JWT_ALGORITHM}')

# 2. Patch the three manifests in the Git checkout, commit, push
for d in deploy/auth deploy/like deploy/profile; do
  yq -i ".data.JWT_PUBLIC_KEY_ID = \"$KID\" | .data.JWT_ALGORITHM = \"$ALG\"" "$d/auth-config.yaml"
done
git add deploy/auth deploy/like deploy/profile
git commit -m 'Reconcile JWT config from live auth-service (post-rotation hotfix, INC-441)'
git push

# 3. Trigger ArgoCD sync per application, in order
for app in auth-service like-service profile-service; do
  argocd app sync $app --prune=false
  argocd app wait $app --health --timeout 180
done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The commit and the sync sequence. auth-service syncs first as a no-op safety check before we touch the broken ones.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We synced auth-service first deliberately. It was already correct, so the sync should be a no-op. If it had shown a diff we did not expect, that was our signal to stop and re-audit before touching like-service or profile-service. It came back clean, which told us our commit matched the live state exactly. Then like-service synced and went healthy. Then profile-service synced and within 40 seconds the 401 rate in Prometheus went from 31% to 0.&lt;/p&gt;

&lt;p&gt;Auto-sync we left off until the 401 rate had been at zero for ten minutes and we had eyes on the Jaeger traces showing fresh successful auth flows end to end. Only then did we re-enable auto-sync on all three applications, in the same order as the sync. We have written more about the order-of-operations on multi-app reconciles in &lt;a href="https://dev.to/argocd-gitops-recovery/"&gt;the ArgoCD and GitOps recovery playbook&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two cheap controls that prevent the next split-state week
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed about hotfix discipline after this one&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The technical recovery was straightforward once the model was right. The interesting part of this incident was how a one-hour rotation hotfix turned into a week of latent drift. Two things had to go wrong together: a manual change that did not get committed, and an auto-sync toggle that did not get turned back on. Either one of those failing alone would have been caught within an hour by ArgoCD's reconciliation loop.&lt;/p&gt;

&lt;p&gt;We made two changes to the platform after this. The first was a scheduled job that lists ArgoCD applications with auto-sync disabled and posts to a channel if any of them have been in that state for more than four hours. It is twelve lines of bash around argocd app list -o json. It has caught the same pattern twice in the last quarter, both times within the same incident as the original change instead of a week later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Posted to platform-alerts when auto-sync has been off for &amp;gt;4h on any app
argocd app list -o json \
  | jq -r '.[] | select(.spec.syncPolicy.automated == null)
            | [.metadata.name, .status.operationState.finishedAt] | @tsv' \
  | awk -v cutoff="$(date -u -d '4 hours ago' +%FT%TZ)" '$2 &amp;lt; cutoff'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The auto-sync watchdog. The cheapest control with the highest ROI we shipped this year.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second change was a rule we now apply to every incident we run: if a hotfix lands in the cluster via kubectl, the same incident does not close until the change is in a merged PR. Not the next day. Not 'we'll get to it'. The incident commander treats the Git commit as a recovery step, not a follow-up. That sounds like a process rule, and it is, but it has a sharp version: the on-call's runbook for manual ConfigMap patches now includes the export-and-PR commands at the bottom of the same page. The friction to do it right is now lower than the friction to defer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the cluster and Git disagree and you cannot just sync your way out
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your GitOps is in a split state right now&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of this kind of incident is not the kubectl or the argocd CLI. The hard part is figuring out which system is the source of truth for which field right now, when the answer is not 'Git, always'. Get that wrong and an ArgoCD sync will take production down a second time on top of whatever is already broken. We have seen the same shape of failure four times this year: a rotation, a migration, an emergency schema change, and a CRD upgrade, each of which left some subset of clusters carrying values that Git did not yet know about.&lt;/p&gt;

&lt;p&gt;InfraForge runs these reconciles every week. We know the order to commit, the order to sync, the checks that catch a propagated copy you forgot about, and the questions to ask before you trust Git over the live state. If your auto-sync has been off for a week and you are not sure what would happen when you turn it back on, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day to walk the drift before you touch anything.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/argocd-drift-three-namespaces-jwt-configmap-hotfix/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/argocd-drift-three-namespaces-jwt-configmap-hotfix/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gitops</category>
      <category>recovery</category>
      <category>gitopsargocd</category>
    </item>
    <item>
      <title>How we recovered tfstate after force-unlock raced a CI apply</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 22:37:05 +0000</pubDate>
      <link>https://forem.com/infraforge/how-we-recovered-tfstate-after-force-unlock-raced-a-ci-apply-52mj</link>
      <guid>https://forem.com/infraforge/how-we-recovered-tfstate-after-force-unlock-raced-a-ci-apply-52mj</guid>
      <description>&lt;p&gt;The engineer pinged us at 4:48 pm on a Thursday. They had been trying to push a small IAM change to staging, terraform apply had failed with Error acquiring the state lock, and they did what most of us have done at least once: they ran terraform force-unlock with the ID from the error message and re-ran apply. The apply went through. Ten minutes later a teammate on a different branch ran terraform plan and the plan output wanted to destroy and recreate 38 resources that were sitting healthy in AWS, returning 200s, serving traffic. By the time we joined the bridge, the original engineer was halfway convinced they needed to let Terraform rebuild the whole staging environment. They did not. The cloud was fine. The state file was the thing that was broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform plan shows -/+ destroy and recreate for resources nobody touched and that are healthy in the cloud&lt;/li&gt;
&lt;li&gt;Teammates see Error: state snapshot was created by Terraform v1.5.7, which is newer than current v1.5.4&lt;/li&gt;
&lt;li&gt;S3 bucket versioning shows two or three tfstate writes inside a 60 to 90 second window&lt;/li&gt;
&lt;li&gt;The DynamoDB lock table is empty but the state file timestamps do not line up with anyone's apply log&lt;/li&gt;
&lt;li&gt;Someone on the team ran terraform force-unlock in the last hour&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A stale lock from a dead CI job
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What the engineer thought it was&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first wrong model was reasonable. The engineer saw Error acquiring the state lock, looked at the lock ID, did not recognize it, and assumed it was a leftover from a CI job that had crashed earlier in the week. They had seen stale locks before. The fix last time was force-unlock. So they ran it again.&lt;/p&gt;

&lt;p&gt;What they did not check was whether the lock holder was actually still alive. The CI job that held the lock was a scheduled terraform plan cycle running on a 15-minute cadence, and that particular run was on the slow side because the workspace had grown to about 600 resources. It was not stuck. It was just working. The force-unlock removed the lock entry from DynamoDB while the CI process was still very much holding an in-memory version of the state file, mid-refresh. Two writers, no coordination.&lt;/p&gt;

&lt;p&gt;When the engineer's apply finished, it wrote its version of the state to S3. About forty seconds later, the CI run finished its refresh and wrote its version of the state to S3 on top of that. Two non-linear writes, each thinking it had the latest state, each clobbering parts of the other. S3 versioning preserved both, but the live state pointer was pointing at a Frankenstein.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three S3 versions in 90 seconds, and a plan that wanted to destroy healthy infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The moment the real cause became visible&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We pulled the S3 object versions for the state file first. That is the single most useful command in a Terraform state incident, and most teams do not run it until someone external suggests it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api list-object-versions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Versions[?LastModified&amp;gt;=`2024-01-18T16:45:00Z`].[VersionId,LastModified,Size]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Output (abridged):&lt;/span&gt;
&lt;span class="c"&gt;# VersionId                          LastModified               Size&lt;/span&gt;
&lt;span class="c"&gt;# 9f3aV2.JqL...                      2024-01-18T16:51:12Z       412847&lt;/span&gt;
&lt;span class="c"&gt;# 8h2nB1.KpM...                      2024-01-18T16:50:31Z       408992&lt;/span&gt;
&lt;span class="c"&gt;# 7g1mA0.LoN...                      2024-01-18T16:49:48Z       411203&lt;/span&gt;
&lt;span class="c"&gt;# 6f0lZ9.MnO...                      2024-01-18T16:42:15Z       411198   &amp;lt;-- last known good&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Three writes inside 84 seconds. The 16:42 version was the last clean write before the collision.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three writes in 84 seconds was the smoking gun. A healthy workspace writes state once per apply, and the next write is usually hours away. Three writes that close together meant at least two processes had been racing. We cross-checked against the CI logs and the engineer's shell history and confirmed: the CI plan cycle had been refreshing state from 16:49:48 onwards, the engineer's force-unlock landed at 16:50:18, the engineer's apply wrote state at 16:50:31, and the CI refresh wrote its stale view back at 16:51:12. The 16:51 write was the one Terraform was now reading, and it had been built from a refresh that started before half the engineer's changes existed.&lt;/p&gt;

&lt;p&gt;That explained the plan output. The state Terraform was reading said the resources had attributes that did not match reality. Plan diffed state against the cloud, saw the mismatch, and proposed the only thing it knows how to propose: destroy and recreate. The cloud was correct. The state was lying. If we had let the apply run, we would have taken a healthy staging environment offline for somewhere between 40 minutes and two hours to rebuild things that did not need rebuilding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore the pre-collision state version, then import only what actually drifted
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;How we worked through it&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The recovery had two parts and an order that mattered. First, replace the corrupted live state with the last clean S3 version. Second, figure out which resources genuinely changed during the collision window and re-import only those. Skipping the second step is how teams end up with the same incident a week later, because real changes from the engineer's apply have been silently rolled back.&lt;/p&gt;

&lt;p&gt;Before touching anything we pulled a local backup of the current (broken) state. If our restore went wrong, we wanted a way back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Backup the current broken state to local disk&lt;/span&gt;
aws s3api get-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  ./tfstate.broken.&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;.json

&lt;span class="c"&gt;# 2. Restore the last known good version in place&lt;/span&gt;
aws s3api copy-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--copy-source&lt;/span&gt; &lt;span class="s1"&gt;'acme-tfstate-staging/env/staging/terraform.tfstate?versionId=6f0lZ9.MnO...'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata-directive&lt;/span&gt; REPLACE

&lt;span class="c"&gt;# 3. Confirm the active version is now the restored one&lt;/span&gt;
aws s3api head-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'VersionId'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The copy-object call writes the old version as a new current version. Do not delete versions; you want the audit trail intact.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the state restored, we ran terraform plan. The output was much shorter, around six resources, and they were the ones the engineer had actually changed in their apply. That was the divergence window: changes that had been made for real in AWS but that the restored state did not know about. Each of those needed a terraform import to reattach the live resource to the state. We did them one at a time, ran plan between each, and watched the diff shrink.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example: the engineer had created a new IAM role during their apply.&lt;/span&gt;
&lt;span class="c"&gt;# The restored state predates it, but the role exists in AWS.&lt;/span&gt;

terraform import &lt;span class="se"&gt;\&lt;/span&gt;
  module.platform.aws_iam_role.svc_runner &lt;span class="se"&gt;\&lt;/span&gt;
  acme-staging-svc-runner

&lt;span class="c"&gt;# After each import, re-run plan and confirm the resource is no longer in the diff.&lt;/span&gt;
terraform plan &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/plan.out

&lt;span class="c"&gt;# Repeat for each resource genuinely changed during the divergence window:&lt;/span&gt;
&lt;span class="c"&gt;# - 1 IAM role&lt;/span&gt;
&lt;span class="c"&gt;# - 1 IAM role policy attachment&lt;/span&gt;
&lt;span class="c"&gt;# - 2 security group rules&lt;/span&gt;
&lt;span class="c"&gt;# - 1 SSM parameter&lt;/span&gt;
&lt;span class="c"&gt;# - 1 Lambda permission&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Import surgically. Do not bulk-import; you want a clean plan after each step so you can spot collateral damage.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the sixth import, terraform plan returned No changes. That was the success signal. The state matched the cloud, the engineer's intended changes were preserved, and nothing healthy had been destroyed. Total time on the bridge from first page to clean plan was 2 hours 40 minutes. About 45 minutes of that was the investigation; the rest was careful, slow imports with verification between each one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[terraform plan shows mass destroy/recreate] --&amp;gt; B{Are the resources actually broken in cloud?}
  B -- No, healthy --&amp;gt; C[State file is the problem, not cloud]
  B -- Yes, broken --&amp;gt; Z[Different incident; investigate cloud-side]
  C --&amp;gt; D[list-object-versions on tfstate]
  D --&amp;gt; E{Multiple writes in short window?}
  E -- Yes --&amp;gt; F[Identify last clean version pre-collision]
  E -- No --&amp;gt; Y[Investigate other corruption causes]
  F --&amp;gt; G[Backup current broken state locally]
  G --&amp;gt; H[copy-object to restore clean version]
  H --&amp;gt; I[terraform plan: short diff = divergence window]
  I --&amp;gt; J[terraform import each drifted resource]
  J --&amp;gt; K{Plan empty?}
  K -- No --&amp;gt; J
  K -- Yes --&amp;gt; L[Recovery complete; write postmortem]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Decision flow we use for any state-collision incident. The first branch matters most: confirm the cloud is healthy before touching state.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tempting shortcuts that would have made it worse
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we tried that we will not try again&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two shortcuts came up on the bridge that we ruled out. They are worth naming because both of them sound reasonable when you are tired.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1. Let terraform apply rebuild everything&lt;/strong&gt;, The plan was already there. Just type yes. This would have caused 30 to 90 minutes of staging downtime for resources that did not need rebuilding, broken any data-layer resources with state of their own, and lost the audit trail of what had actually changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2. terraform refresh to fix the state&lt;/strong&gt;, Refresh updates state from the live infrastructure for known resources. It does not learn about resources the state has forgotten, and it cannot undo a structurally corrupted state. Refresh on a Frankenstein state can deepen the damage by writing the merged view back as the new truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We have written about the broader pattern in &lt;a href="https://dev.to/terraform-state-recovery/"&gt;the Terraform state recovery playbook&lt;/a&gt;, specifically the rule we now apply on every state incident: the state file is the suspect until proven otherwise. Cloud is healthy until you have evidence it is not. That ordering keeps you from running destructive applies under time pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-apply lock check that prints the holder's age
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The team made two changes the week after the incident. Both are small. Both have already paid for themselves.&lt;/p&gt;

&lt;p&gt;The first change is a pre-apply wrapper script that reads the DynamoDB lock table before terraform apply runs. If a lock exists, the script prints the lock holder, when the lock was acquired, and how long ago that was. If the lock is younger than the workspace's typical apply duration plus a safety margin, the script refuses to run and tells the engineer to wait. If the lock is genuinely old (older than any plausible live process), the script still does not force-unlock automatically; it prints the exact force-unlock command and makes the engineer paste it. The friction is the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# pre-apply-lock-check.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;WORKSPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?workspace&lt;span class="p"&gt; name required&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;LOCK_TABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"acme-tfstate-locks"&lt;/span&gt;
&lt;span class="nv"&gt;MAX_PLAUSIBLE_APPLY_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1800  &lt;span class="c"&gt;# 30 minutes&lt;/span&gt;

&lt;span class="nv"&gt;LOCK_ITEM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws dynamodb get-item &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_TABLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;LockID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;S&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;acme-tfstate-staging/env/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WORKSPACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/terraform.tfstate-md5&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; json 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Item // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No lock. Safe to proceed."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;HOLDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Who + " @ " + .Operation'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CREATED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Created'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CREATED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Lock present."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Holder:  &lt;/span&gt;&lt;span class="nv"&gt;$HOLDER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Created: &lt;/span&gt;&lt;span class="nv"&gt;$CREATED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Age:     &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; AGE &amp;lt; MAX_PLAUSIBLE_APPLY_SECONDS &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo
  echo&lt;/span&gt; &lt;span class="s2"&gt;"REFUSING TO PROCEED. Lock is younger than max plausible apply duration."&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Wait for the current holder to finish, or confirm out-of-band that it is dead."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo
echo&lt;/span&gt; &lt;span class="s2"&gt;"Lock is older than &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_PLAUSIBLE_APPLY_SECONDS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s. It may be stale."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"To force-unlock, run manually (do NOT automate this):"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  terraform force-unlock &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.ID'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;We run this from CI and from a pre-apply git hook on engineer laptops. Same script, same rules, both places.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second change is operational. The team's runbook now says: if you ever run force-unlock, page the on-call channel immediately with the lock ID and the reason. That single message would have caught this incident before it became one. The CI job would have replied within seconds that it was still running, and the engineer would have known to wait the eight minutes instead of clobbering the state.&lt;/p&gt;

&lt;p&gt;We have stopped recommending that teams treat force-unlock as a routine command. It is a recovery command. It belongs in the same mental category as DROP TABLE: technically available, occasionally necessary, never the first thing you reach for. The TTL on the lock is generous on purpose. Wait it out, or confirm the holder is dead. Those are the only two paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the state file is the suspect and the clock is running
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you are looking at a destroy plan you do not trust&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of state-collision incidents is not the recovery commands. The commands are mechanical once you know the shape of the problem. The hard part is the 20 minutes before that, when an apply plan is sitting in your terminal showing 30+ destroys, someone senior is asking on Slack whether you can just run it, and you have to decide whether the cloud is broken or the state is. Get that wrong under pressure and you cause the outage you were trying to prevent.&lt;/p&gt;

&lt;p&gt;We run these recovery engagements every week. The force-unlock-collision pattern has shown up four times this quarter alone, in three different shapes: a CI plan racing an engineer apply (this one), two engineers applying simultaneously after a Slack misunderstanding, and a long-running import operation that an engineer killed because they thought it had hung. The recovery shape is the same. The diagnostic discipline of confirming the cloud is healthy before touching state is the same. The thing that changes is which version of state is the right one to restore to, and that takes practice to spot quickly.&lt;/p&gt;

&lt;p&gt;If you are staring at a terraform plan that wants to destroy resources you know are healthy, do not run apply. &lt;a href="https://dev.to/review/"&gt;Book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day to work through the state restore and the surgical imports. We have done this enough times that we can usually have you back to an empty plan inside three hours.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>state</category>
      <category>recovery</category>
      <category>terraformstate</category>
    </item>
    <item>
      <title>Why a forgotten RDS replica added $8,600 to one AWS bill</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 17:23:31 +0000</pubDate>
      <link>https://forem.com/infraforge/why-a-forgotten-rds-replica-added-8600-to-one-aws-bill-2k4d</link>
      <guid>https://forem.com/infraforge/why-a-forgotten-rds-replica-added-8600-to-one-aws-bill-2k4d</guid>
      <description>&lt;p&gt;The finance lead forwarded the AWS bill on a Monday morning with three question marks in the subject line. The number had gone from a steady $3,200/month to $11,800 in six days. The on-call engineer's first guess, sensible enough, was that a data scientist had left a cross-region Athena job running over the weekend. It was not. It was an RDS read replica in a different AZ from its primary, provisioned a month earlier for a one-off load test, never decommissioned, retrying a replication-stream write every 50 milliseconds because somebody had flipped the primary's binlog format mid-stream. Nobody had read from the replica in three weeks. It had been quietly burning cross-AZ data transfer the whole time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS bill jumped 2-4x in under a week with no traffic or feature change&lt;/li&gt;
&lt;li&gt;Cost Explorer concentrates the spike on DataTransfer-Regional-Bytes and RDSInstance line items&lt;/li&gt;
&lt;li&gt;An RDS read replica sits in a different AZ than its primary and shows jagged ReplicaLag (spikes to 30s, drops to 0.5s, repeats)&lt;/li&gt;
&lt;li&gt;No application config or BI tool actually points at the replica's endpoint&lt;/li&gt;
&lt;li&gt;Recent schema or replication change on the primary that nobody coordinated with replica owners&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chasing the analytics query that did not exist
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we thought it was first&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost every cost spike I have seen in the last three years gets blamed on analytics first. There is usually a junior data person, a notebook, a forgotten SELECT *, and a story everyone tells themselves. So we did the natural thing. We pulled the Athena query history for the previous ten days. Nothing unusual. We checked Redshift, which the team barely uses. Idle. We checked the data warehouse cluster's autoscaling history. Flat.&lt;/p&gt;

&lt;p&gt;The clue was in Cost Explorer, but only when we grouped by usage type instead of by service. The RDS line item was up, sure, but the line item that had really moved was DataTransfer-Regional-Bytes. That is the meter for cross-AZ traffic inside a single region. Analytics queries do not typically light that meter up unless somebody has put a compute node in one AZ and the data in another, which would have been a much weirder problem.&lt;/p&gt;

&lt;p&gt;Cross-AZ data transfer at that volume meant something was constantly shipping bytes between two availability zones. The shape of the bill said: find the thing that talks to itself across AZs at high frequency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we found the orphan replica
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The diagnostic turn&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We listed every RDS instance in the account and compared the AZ of each replica to its primary. One read replica was in us-east-1b while its primary was in us-east-1a. That alone is not a problem; cross-AZ replicas exist for legitimate HA reasons. What was odd was that this replica was tagged with nothing. No Owner. No Purpose. No Environment. Just the default Name tag, which read load-test-replica-temp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List replicas with their AZ and their primary's AZ&lt;/span&gt;
aws rds describe-db-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBInstances[?ReadReplicaSourceDBInstanceIdentifier!=`null`].[DBInstanceIdentifier,AvailabilityZone,ReadReplicaSourceDBInstanceIdentifier,DBInstanceStatus]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Then for each primary, get its AZ&lt;/span&gt;
aws rds describe-db-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; &amp;lt;primary-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBInstances[0].AvailabilityZone'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The two commands that surfaced the orphan in about 30 seconds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The replica's CloudWatch ReplicaLag metric was the giveaway that this was not a healthy idle replica. It would spike to 30 seconds, drop to 0.5 seconds, spike again, every minute or so. That sawtooth pattern means the replication thread is failing and retrying. We pulled the replica's error log and found the same line repeating roughly every 50 milliseconds: a binlog format mismatch. Someone had changed the primary from MIXED to ROW format three weeks earlier, and the replica had been retrying the broken stream ever since.&lt;/p&gt;

&lt;p&gt;Every retry shipped a chunk of binlog across the AZ boundary. At 50ms intervals, 24 hours a day, for three weeks. That was the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute check that prevents the worse outcome
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we did before deleting anything&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The instinct, when you have found the thing burning money, is to kill it immediately. We did not. The worse outcome here is not 'replica costs another hour of cross-AZ transfer'. The worse outcome is 'replica gets deleted, a quarterly BI dashboard breaks on Friday, and finance is back in your inbox with a different question'.&lt;/p&gt;

&lt;p&gt;So we did the cheap verification first. We grepped the application monorepo for the replica's endpoint hostname. Zero hits. We checked the BI tool's data sources (Metabase in this case). Nothing pointed at it. We checked the data team's Airflow DAGs. Clean. We checked Terraform state to see how it had been created. It was in a workspace tagged load-test that had not been touched in a month, and the engineer who created it had left the company three weeks earlier.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If something had pointed at it&lt;/strong&gt;, The right move would have been to keep the replica, fix the binlog format, and decide whether the read pattern actually justified cross-AZ. Deletion would have caused a worse incident than the cost spike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing pointed at it&lt;/strong&gt;, Delete with --skip-final-snapshot. The replica was already corrupted by the binlog mismatch; a final snapshot was worthless. Cost stopped accruing within minutes.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds delete-db-instance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; load-test-replica-temp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-final-snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The actual delete, once we were confident nothing depended on the replica.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tag hygiene, expiration sweeps, and an anomaly budget that would have caught this on day 2
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Forgotten resources are the largest single category of cloud waste I see in client accounts. Bigger than oversized instances. Bigger than reserved-instance gaps. The fix is mechanical. Every cost-generating resource needs three tags: Owner, Purpose, ExpiresAt. ExpiresAt is the one most teams skip and the one that does the work.&lt;/p&gt;

&lt;p&gt;We deployed a small Lambda on a weekly schedule that walks RDS, EC2, ELB, ElastiCache, and OpenSearch, finds resources past their ExpiresAt date or missing tags entirely, and posts to a Slack channel pinging the Owner. The owner has two weeks to either re-tag with a new ExpiresAt or delete. Resources with no Owner go to the platform team's queue. The first sweep flagged 47 resources across the account. Six of them were costing real money.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[Weekly Lambda runs] --&amp;gt; B{Resource has&amp;lt;br/&amp;gt;Owner, Purpose,&amp;lt;br/&amp;gt;ExpiresAt tags?}
  B -- no --&amp;gt; C[Post to platform team queue]
  B -- yes --&amp;gt; D{ExpiresAt&amp;lt;br/&amp;gt;in past?}
  D -- no --&amp;gt; E[Skip]
  D -- yes --&amp;gt; F[DM the Owner in Slack]
  F --&amp;gt; G{Owner responds&amp;lt;br/&amp;gt;within 14 days?}
  G -- extends --&amp;gt; H[Update ExpiresAt]
  G -- no response --&amp;gt; I[Auto-tag for deletion&amp;lt;br/&amp;gt;review next sweep]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The sweep logic. About 180 lines of Python in practice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second change was AWS Budgets with anomaly detection scoped per service. The team had a single account-wide budget set at $5,000/month, which is useless for catching this kind of incident because the spike was concentrated in one service and the account total only crossed $5,000 on day five. A per-service budget on RDS set at $4,000 with a 20% variance threshold would have fired on day 2. The alert that matters is the one that fires before you have spent the money, not after.&lt;/p&gt;

&lt;p&gt;The third change was a process one. The original binlog format change had been an uncoordinated database tweak from a senior engineer who had not realized a replica existed. Schema and replication changes now require a checklist that includes 'list all replicas of this primary and confirm they support the new config' as a pre-flight step. It is not glamorous. It would have prevented the entire incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where cost spike triage gets stuck
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your AWS bill just jumped and you do not know why&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of a cost spike is not finding the resource. It is being confident enough to delete it. Most teams we work with have at least one orphan RDS, ElastiCache, or NAT gateway they are afraid to touch because nobody remembers what depends on it. The triage takes a day; the courage to act takes a week of meetings. By then the bill has run another $2,000.&lt;/p&gt;

&lt;p&gt;We run cost spike triage engagements every month. We have seen the orphan-replica case four times this year, the NAT-gateway-in-the-wrong-AZ case more often than that, and a half dozen variants of 'load test that never got cleaned up' across CloudWatch Logs, OpenSearch, and Aurora Serverless. The pattern is almost always the same: a resource that nobody owns, a tag policy that was never enforced, and a budget alert tuned too coarse to catch concentration in a single service. We have written more on the underlying patterns in &lt;a href="https://dev.to/problems/cloud-cost-spikes/"&gt;the cloud cost spikes problem brief&lt;/a&gt; and across &lt;a href="https://dev.to/services/"&gt;our services&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your AWS bill jumped this month and you cannot point at the resource with confidence, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will start with a 30-minute diagnostic call this week. Cost stops accruing the day we find the orphan.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cost</category>
      <category>spike</category>
      <category>triage</category>
      <category>costspikes</category>
    </item>
    <item>
      <title>Why terraform apply fails when plan passes: the map(any) trap</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 17:14:53 +0000</pubDate>
      <link>https://forem.com/infraforge/why-terraform-apply-fails-when-plan-passes-the-mapany-trap-50dg</link>
      <guid>https://forem.com/infraforge/why-terraform-apply-fails-when-plan-passes-the-mapany-trap-50dg</guid>
      <description>&lt;p&gt;The on-call engineer pinged me at 4:42pm on a Friday with the release window open until 5:30. terraform apply against the staging workspace had failed with &lt;code&gt;Error: Unsupported argument&lt;/code&gt; deep inside a child module nobody on the team had touched in seven months. terraform plan against the same workspace ran clean. They had already re-run plan twice and got fresh no-op output both times. The shape of the failure was off. plan and apply diverging is rare in the way they were describing, and you mostly see it on data sources that resolve at apply time, not on a static &lt;code&gt;merge()&lt;/code&gt; call inside a module whose code had not changed in six months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform plan succeeds locally but terraform apply fails on a specific environment&lt;/li&gt;
&lt;li&gt;The error is &lt;code&gt;Error: Unsupported argument&lt;/code&gt; or &lt;code&gt;Inappropriate value&lt;/code&gt; deep inside a child module&lt;/li&gt;
&lt;li&gt;The traceback points at a &lt;code&gt;merge()&lt;/code&gt; or &lt;code&gt;lookup()&lt;/code&gt; call inside a module that has not been edited in months&lt;/li&gt;
&lt;li&gt;Your root module input list has crossed 20 variables and several are typed &lt;code&gt;any&lt;/code&gt; or &lt;code&gt;map(any)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;There is no CI job that runs terraform plan against every environment on every PR&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three hypotheses, three dead ends, twenty-two minutes left in the release window
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we ruled out in the first 18 minutes&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first thing the on-call lead suggested was state drift. Someone, somewhere, had &lt;code&gt;terraform import&lt;/code&gt;-ed a resource by hand. We checked the audit log. No &lt;code&gt;import&lt;/code&gt; events in the past 30 days. We checked the lock table in DynamoDB. The lock had been released cleanly by the previous successful apply at 2:11pm.&lt;/p&gt;

&lt;p&gt;The second hypothesis was provider version drift. The team had recently bumped &lt;code&gt;hashicorp/aws&lt;/code&gt; from 5.62 to 5.71 in &lt;code&gt;versions.tf&lt;/code&gt;. A breaking change in a resource schema can absolutely cause an &lt;code&gt;Unsupported argument&lt;/code&gt; error if apply pulls a newer provider than plan resolved against. We pinned both runs to 5.71 explicitly, deleted &lt;code&gt;.terraform/&lt;/code&gt;, re-ran &lt;code&gt;init&lt;/code&gt;, then &lt;code&gt;plan&lt;/code&gt;, then &lt;code&gt;apply&lt;/code&gt;. Same error, same module, same line.&lt;/p&gt;

&lt;p&gt;The third hypothesis was a stale workspace. terraform workspaces sometimes diverge from the configuration if &lt;code&gt;workspace select&lt;/code&gt; was bypassed by an engineer who exported &lt;code&gt;TF_WORKSPACE&lt;/code&gt; and forgot. We ran &lt;code&gt;terraform workspace show&lt;/code&gt; and verified it matched the intended target. The plan output even confirmed the right resource addresses.&lt;/p&gt;

&lt;p&gt;Three explanations, three dead ends, twenty-eight minutes burned. The release window was now twenty-two minutes wide and shrinking. The on-call lead asked whether we should just roll back the deploy and figure it out Monday. I asked one more question first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15th map(any) input that had been silently incubating for three weeks
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where the collision actually lived&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I asked the on-call lead to walk me through what had merged into the workspace in the past two weeks. There were six commits. Five were obvious changes (image tags, a new IAM policy, a security group port). The sixth was a feature flag, added as a 15th &lt;code&gt;map(any)&lt;/code&gt; input on the root module by an engineer who had joined six weeks earlier.&lt;/p&gt;

&lt;p&gt;That was the lead.&lt;/p&gt;

&lt;p&gt;The root module had 28 input variables. 14 of them were &lt;code&gt;any&lt;/code&gt;-typed or &lt;code&gt;map(any)&lt;/code&gt; to absorb per-environment overrides accumulated over six years of feature additions. The new feature flag added a 15th &lt;code&gt;map(any)&lt;/code&gt; input named &lt;code&gt;feature_overrides&lt;/code&gt;. Its values flowed through a &lt;code&gt;merge()&lt;/code&gt; chain down to the database child module, which did its own &lt;code&gt;merge(var.feature_overrides, local.legacy_db_flags)&lt;/code&gt; inside &lt;code&gt;modules/services/database/locals.tf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two maps had a key collision. Both contained a key named &lt;code&gt;read_replica_routing&lt;/code&gt;. The new input's value was a &lt;code&gt;string&lt;/code&gt;. The legacy local's value was a &lt;code&gt;map(object({ host = string, weight = number }))&lt;/code&gt;. &lt;code&gt;merge()&lt;/code&gt; resolves collisions by taking the last argument's value, but the argument order in this case depended on which input was non-empty at apply time, and the new feature flag was only non-empty in staging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
  participant Op as Operator
  participant Plan as terraform plan
  participant Apply as terraform apply
  participant Child as child module
  Op-&amp;gt;&amp;gt;Plan: feature_overrides (map(any))
  Plan-&amp;gt;&amp;gt;Child: merge(map(any), map(any))
  Child--&amp;gt;&amp;gt;Plan: any (type-check deferred)
  Plan--&amp;gt;&amp;gt;Op: 0 to add, 0 to change (PASS)
  Op-&amp;gt;&amp;gt;Apply: same input
  Apply-&amp;gt;&amp;gt;Child: merge resolved to concrete value
  Child--&amp;gt;&amp;gt;Apply: Error: Unsupported argument
  Apply--&amp;gt;&amp;gt;Op: FAIL at 4:42pm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;How &lt;code&gt;map(any)&lt;/code&gt; defers type-checking past plan and surfaces it at apply&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The collision had been latent for three weeks. plan succeeded because terraform's planner walked the call graph with both maps' element types collapsed to &lt;code&gt;any&lt;/code&gt;. The merged value passed type-check as &lt;code&gt;any&lt;/code&gt;, which type-checks against anything. apply, which actually constructs the resource, evaluated the merged value against the receiving attribute's concrete type signature and discovered the value was a string where an object was required.&lt;/p&gt;

&lt;p&gt;That is the part that hurts. Terraform's &lt;code&gt;any&lt;/code&gt; type defers all type-checking until apply. Every &lt;code&gt;map(any)&lt;/code&gt; input on a root module is a future apply-time failure waiting on a contributor who does not know the implicit shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three options, one open release window, seven minutes to pick
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we did before running apply again&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We had three options and one open release window. I walked the on-call lead through them on the bridge call.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1. Delete the legacy key&lt;/strong&gt;, Fastest. Also the riskiest: the legacy &lt;code&gt;read_replica_routing&lt;/code&gt; key was referenced by three modules-of-modules three layers down. Deleting it would have moved the failure from staging to production an hour later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2. Rename the new key&lt;/strong&gt;, Safe-feeling. Left the underlying &lt;code&gt;any&lt;/code&gt;-typed contract intact. Two months later a different contributor would add another &lt;code&gt;map(any)&lt;/code&gt; input and we would be back on a Friday afternoon with the same shape of failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3. Rename plus add validation&lt;/strong&gt;, Slower. Renamed the new key to &lt;code&gt;feature_routing_overrides&lt;/code&gt; AND added a &lt;code&gt;validation&lt;/code&gt; block on the input that explicitly rejected the colliding shape at plan time going forward. Stopped the immediate reoccurrence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Option three carried the day. The rename took seven minutes. The validation block took twelve. apply succeeded at 5:14pm with sixteen minutes to spare on the release window. The release shipped on time.&lt;/p&gt;

&lt;p&gt;The audit work behind option one (the one we did NOT take) is what stuck with me. The next morning, we grep-ed the entire &lt;code&gt;terraform/&lt;/code&gt; tree for &lt;code&gt;read_replica_routing&lt;/code&gt; to map every consumer. Seven references across four modules. Three in &lt;code&gt;modules/services/database/locals.tf&lt;/code&gt; itself. One in &lt;code&gt;modules/monitoring/cloudwatch.tf&lt;/code&gt;. One in &lt;code&gt;modules/services/cache/lookups.tf&lt;/code&gt;, which read the value to construct its own routing decision and would have broken silently if we had deleted the legacy key the night before. The remaining two were in a state-recovery helper module the team had forgotten existed. We had nearly fired the second shot of our own foot.&lt;/p&gt;

&lt;p&gt;We left a tombstone comment on the legacy key and an open PR that would, the following week, replace its &lt;code&gt;map(any)&lt;/code&gt; type with a proper &lt;code&gt;object({ ... })&lt;/code&gt; schema. That work landed five days later. The downstream consumers caught the change at plan time, and three of them needed minor patches before the type tightening could merge. None of those patches would have caught the original collision. They all caught real existing bugs the &lt;code&gt;any&lt;/code&gt; type had been hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two policy changes and one structural fix
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two policy changes came out of that night, and one structural fix took longer.&lt;/p&gt;

&lt;p&gt;The first policy: no new &lt;code&gt;map(any)&lt;/code&gt; or &lt;code&gt;any&lt;/code&gt;-typed inputs on root modules. The team's &lt;code&gt;terraform/&lt;/code&gt; directory has a pre-commit hook (8 lines of grep) that fails the commit if any new &lt;code&gt;variable&lt;/code&gt; block contains &lt;code&gt;type = any&lt;/code&gt; or &lt;code&gt;type = map(any)&lt;/code&gt;. Existing instances are grandfathered, with a TODO list tracked against each module. Three of the original 14 have been converted to typed objects so far. The hook has fired four times in the six weeks since.&lt;/p&gt;

&lt;p&gt;The second policy: every PR runs &lt;code&gt;terraform plan&lt;/code&gt; against every environment, not just the one the contributor cares about. A matrix job in CI runs &lt;code&gt;plan -var-file=envs/&amp;lt;env&amp;gt;.tfvars&lt;/code&gt; across all four environments and fails the PR if any of them errors. This would not have caught the original collision (plan succeeded everywhere), but it catches a different class of failure where one environment's tfvars hits an unwritten code path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: latent any-typed input&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"feature_overrides"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Per-environment feature flag overrides"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# In modules/services/database/locals.tf&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;merged_flags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legacy_db_flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feature_overrides&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Above passes plan even when the two maps have a key&lt;/span&gt;
&lt;span class="c1"&gt;# whose value types disagree. The mismatch surfaces only&lt;/span&gt;
&lt;span class="c1"&gt;# at apply, when the receiving attribute is evaluated.&lt;/span&gt;

&lt;span class="c1"&gt;# After: typed, explicit, errors at plan time&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"feature_overrides"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;enabled&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bool&lt;/span&gt;
    &lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;routing&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Per-environment feature flag overrides"&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feature_overrides&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rollout_pct must be between 0 and 100."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The same variable, before and after. The lower form fails plan, not apply, when a contributor passes the wrong shape.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The structural fix took longer. A 28-input root module is not a configuration problem, it is a service-boundary problem. The team running the database stack should own a &lt;code&gt;database/&lt;/code&gt; root module with four inputs, not a 14-input subtree of a shared 28-input root. We split the original root into three roots along ownership boundaries (network, services, observability) using a thin terragrunt overlay for the cross-cutting variables. The split took six weeks of careful state-mv work to land without downtime. We have written more on the structural fix in &lt;a href="https://dev.to/terraform-iac-debt/"&gt;the Terraform and IaC debt playbook&lt;/a&gt;, which covers when a shared root module starts costing more than the consistency it buys.&lt;/p&gt;

&lt;p&gt;What we tell every team now: strong types in Terraform are not bureaucracy, they are the documentation. The half-day cost to write &lt;code&gt;object({ name = string, enabled = bool, ... })&lt;/code&gt; instead of &lt;code&gt;map(any)&lt;/code&gt; buys you a plan-time failure instead of an apply-time failure, and apply-time failures land at 4:42pm on Fridays. We have stopped accepting &lt;code&gt;map(any)&lt;/code&gt; inputs in any client engagement that involves an IaC audit, and we have not had a single contributor push back once they saw the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are looking at a 28-input root with map(any) sprinkled through it
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;When your own root module is past 20 inputs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are reading this and your &lt;code&gt;terraform/&lt;/code&gt; directory has a root module past 20 inputs with several &lt;code&gt;map(any)&lt;/code&gt; types in the input list, the failure you are heading toward is not a surprise. It is a scheduled event. The trigger will be a new contributor who does not know the implicit contract, plus one bad-enough Friday. The hardest part of cleaning it up is not the typing work itself; it is the audit of downstream consumers that have been silently depending on the loose contract for years. Two layers of modules-of-modules can hide a reference that breaks the moment you tighten the type, and your CI will not warn you because plan will keep passing right up to the apply that surfaces it.&lt;/p&gt;

&lt;p&gt;We run these recovery and audit engagements every week. The &lt;code&gt;map(any)&lt;/code&gt; collision pattern is the third-most-common shape we see in seed-to-Series-B SaaS Terraform repos, right after stale state lock holders and provider-version-drift cascades. It is one variant of the broader &lt;a href="https://dev.to/problems/terraform-apply-fear/"&gt;terraform apply fear&lt;/a&gt; problem we engage on most weeks. On a typical engagement we map every &lt;code&gt;any&lt;/code&gt;-typed input in your root modules within the first day, prioritize them by blast radius, and either convert them in-place or split the root if the input count is the real problem. If you are looking at a Terraform root with &lt;code&gt;map(any)&lt;/code&gt; sprinkled through it and a release window that does not forgive a 4pm apply failure, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will start with a 30-minute diagnostic call this week.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>iac</category>
      <category>recovery</category>
      <category>terraformiacdebt</category>
    </item>
    <item>
      <title>Init container cascade when every kubectl patch reverts in 10 seconds</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Fri, 15 May 2026 20:15:23 +0000</pubDate>
      <link>https://forem.com/infraforge/init-container-cascade-when-every-kubectl-patch-reverts-in-10-seconds-3ibl</link>
      <guid>https://forem.com/infraforge/init-container-cascade-when-every-kubectl-patch-reverts-in-10-seconds-3ibl</guid>
      <description>&lt;p&gt;The Slack ping came in at 2:14 am. Two replicas of the fanout service were stuck in Init:1/3 and the deploy queue behind them had grown to seven changes. The on-call engineer had already tried the obvious move, kubectl edit deployment, and the changes had reverted within ten seconds. By the time we joined the bridge, they had patched the same field four times in twenty minutes and were starting to wonder if etcd was corrupted. The shape of the failure was wrong though. Init containers do not normally cascade across three different upstream dependencies at once; either something upstream was common, or the spec was being rewritten under us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pods stuck in Init:0/3 or Init:1/3 with no forward progress and no clear log story&lt;/li&gt;
&lt;li&gt;kubectl edit deployment changes revert within ten to fifteen seconds, every time&lt;/li&gt;
&lt;li&gt;Three init containers each failing in a different protocol layer (TCP dial timeout, NXDOMAIN, AMQP ACCESS_REFUSED)&lt;/li&gt;
&lt;li&gt;A topology or schema ConfigMap claims state that the live broker or database disagrees with&lt;/li&gt;
&lt;li&gt;No activeDeadlineSeconds set on init containers, so transient failures wedge the Pod indefinitely&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two replicas wedged, seven changes queued, four failed patches
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The 2 am page&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When we joined the bridge, the on-call engineer had already burned forty minutes on what looked like a config drift bug. The fanout service in the platform namespace had two replicas, both stuck in Init:1/3. The init container chain had three steps (wait-for-redis, wait-for-mongodb, wait-for-rabbitmq) and the redis step was failing on a hardcoded IPv4 address that did not match the live Service. They patched the env var on the Deployment. The init container restarted. Ten seconds later the IP was back. They patched it again. Same thing.&lt;/p&gt;

&lt;p&gt;Their working hypothesis was etcd corruption or a faulty kube-apiserver caching layer. We have seen both before, but neither matches the symptom shape here. Etcd corruption surfaces as 5xx responses to kubectl, not as silent successful PATCHes that revert. We needed to find what was doing the reverting before we wasted any more time on the symptoms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two wrong guesses before the real culprit became visible
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we thought it was first&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first guess was a GitOps controller with self-heal enabled. ArgoCD does this with syncPolicy.automated.selfHeal: true. Flux does this with its Kustomization controller. Both will revert a kubectl patch within seconds if the live spec drifts from the source of truth in git. We checked the cluster for both. No Argo Application referenced the fanout namespace. Flux was not installed at all.&lt;/p&gt;

&lt;p&gt;The second guess was a mutating admission webhook. A custom webhook that rewrites init container specs at admission time could in theory produce this pattern, except admission webhooks fire on create and update, not on a ten-second timer. We ran kubectl get mutatingwebhookconfigurations and the output was empty. That ruled it out.&lt;/p&gt;

&lt;p&gt;The reverting was not coming from inside the cluster. It had to be coming from the node itself. We SSHed to the node where one of the fanout pods was scheduled and went looking. Within two minutes we had it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh node-01 &lt;span class="s1"&gt;'ps -ef | grep admission'&lt;/span&gt;
&lt;span class="go"&gt;root  1842  ... /usr/bin/supervisord -c /etc/supervisor/conf.d/admission.conf
root  2104  ... /bin/bash /var/lib/apex/admission.sh

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh node-01 &lt;span class="s1"&gt;'cat /etc/supervisor/conf.d/admission.conf'&lt;/span&gt;
&lt;span class="go"&gt;[program:admission]
command=/var/lib/apex/admission.sh
autorestart=true
startsecs=5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A supervisord-managed script on the node was the reverter. autorestart=true meant killing it bought us at most a few seconds.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stored ConfigMap was the source of truth, not the live Deployment
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What was actually overwriting our patches&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The script at /var/lib/apex/admission.sh ran every ten seconds. It read three fields (redis-host, mongodb-host, amqp-uri) from a ConfigMap called fanout-init-config and patched them straight into the init container env vars on the live Deployment. The ConfigMap was the source of truth. The Deployment was a downstream artifact. Patching the Deployment was about as durable as writing in pencil.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
  participant Engineer
  participant Deployment
  participant Admission as node script
  participant ConfigMap as fanout-init-config
  Engineer-&amp;gt;&amp;gt;Deployment: kubectl edit (fix redis-host)
  Deployment--&amp;gt;&amp;gt;Engineer: spec updated
  Note over Admission: tick every 10s
  Admission-&amp;gt;&amp;gt;ConfigMap: read fields
  ConfigMap--&amp;gt;&amp;gt;Admission: stale values
  Admission-&amp;gt;&amp;gt;Deployment: patch init container env
  Deployment--&amp;gt;&amp;gt;Engineer: changes reverted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The reverting loop. Edit the ConfigMap, not the Deployment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/init-container-cascade-reverting-patches/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This pattern shows up in places where the original GitOps story had gaps and someone wrote a node-side enforcer as a stopgap. Then the team rotated, the wiki page got out of date, and the enforcer kept running. We have seen this exact shape three times in the last year. Twice with supervisord scripts. Once with a systemd timer. The fix is always the same: find the source of truth before patching anything, and if you cannot find it in under fifteen minutes, stop and look on the nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each failure actually told us, and the fourth fix that did not show in any log
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Three init containers, three different protocols&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once we knew to edit the ConfigMap, we still had three concurrent faults to diagnose. Each init container was failing in a different layer of the network stack, and each one had its own diagnostic signature.&lt;/p&gt;

&lt;p&gt;The redis init container was dialing 10.43.181.44 on port 6379 and getting i/o timeout after thirty seconds. We compared against the live Service and got back a different ClusterIP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get svc redis &lt;span class="nt"&gt;-n&lt;/span&gt; platform &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.clusterIP}'&lt;/span&gt;
&lt;span class="go"&gt;10.43.218.92

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl logs fanout-7d4b9c-xx &lt;span class="nt"&gt;-c&lt;/span&gt; wait-for-redis &lt;span class="nt"&gt;-n&lt;/span&gt; platform | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;span class="go"&gt;dial tcp 10.43.181.44:6379: i/o timeout
dial tcp 10.43.181.44:6379: i/o timeout
dial tcp 10.43.181.44:6379: i/o timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The hardcoded IP had no relationship to the live Service. ClusterIPs are not stable across Service recreation. Hardcoding one is a time bomb.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The mongodb init container was logging 'lookup mongo.platform.svc.cluster.local on 10.43.0.10:53: no such host'. The live Service was named mongodb, not mongo. One character off, NXDOMAIN. We caught it by running kubectl get svc -n platform and reading the actual Service name out loud. The hostname in the ConfigMap had been typed from memory by someone who remembered the team's old naming convention.&lt;/p&gt;

&lt;p&gt;The rabbitmq init container was the most interesting of the three. The TCP connection succeeded. The AMQP frame negotiation succeeded. Authentication succeeded. The vhost open returned ACCESS_REFUSED. The URI was amqp://app:app@rabbitmq:5672/fanout-internal. We port-forwarded to the management API and listed valid vhosts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; platform svc/rabbitmq 15672:15672 &amp;amp;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; app:app http://localhost:15672/api/vhosts | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt;
&lt;span class="go"&gt;/
/platform

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;fanout-internal does not exist on this broker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The URI parsed cleanly and authenticated cleanly. The failure was at vhost open. Always enumerate vhosts before assuming auth or credentials.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There was a fourth fix that did not show up in any log. None of the init containers had activeDeadlineSeconds set, and neither did the Pod spec. Even after the three protocol bugs were resolved, a transient DNS hiccup or broker restart would have hung an init container indefinitely instead of failing fast and letting the kubelet retry the Pod. We added activeDeadlineSeconds: 120 on every init container and 600 at the Pod level. Defense in depth, because init container deadlines do not always catch the case where the kubelet keeps reconciling a stuck container.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second ConfigMap with the same shape, intentionally broken, was a load-bearing canary
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The look-alike ConfigMap we almost broke&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before we patched fanout-init-config, we almost made one more mistake. There was a second ConfigMap in the same namespace called fanout-init-config-canary. Same shape, same broken-looking IP, same broken-looking AMQP URI. It was labeled role: protected and annotated with purpose: chaos-canary. A drift-detection job in the cluster read it every fifteen minutes to confirm its own detection logic still fired on broken inputs. If we had run a sed-style global replace across all matching ConfigMaps (which is exactly what a tired engineer at 3 am tends to do) we would have silenced the canary and the team would have learned about the next round of real drift only when a customer noticed.&lt;/p&gt;

&lt;p&gt;When you patch infrastructure under pressure, target the named resource, not the pattern. Read the labels and annotations of every resource you are about to touch. A surprising number of clusters have load-bearing decoys you do not know about until you break them. We have written more on this in &lt;a href="https://dev.to/kubernetes-cicd/"&gt;the Kubernetes and CI/CD stabilization pillar&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source-of-truth guard, deadline defense, a validation Job, and convergence checks
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fanout service was the visible failure, but the recovery exposed five underlying gaps in the team's release flow. We left four durable changes in place before disconnecting from the bridge.&lt;/p&gt;

&lt;p&gt;The fanout-init-config ConfigMap is now committed in git and synced via a real GitOps controller, and the node-side admission script was rewritten to refuse to overwrite a Deployment if the ConfigMap's content hash does not match a known-good baseline annotation. The script can still enforce, but it cannot enforce a broken state.&lt;/p&gt;

&lt;p&gt;Every Deployment in the platform namespace now has activeDeadlineSeconds set at both the init container level (120 seconds) and the Pod level (600 seconds). The pair matters. Init container deadlines fail-fast the individual container; the Pod-level deadline prevents the kubelet from looping retries on a Pod that is structurally wrong.&lt;/p&gt;

&lt;p&gt;A pre-deployment validation Job runs as part of the release flow. It carries label validation: predeploy, restartPolicy: OnFailure, activeDeadlineSeconds: 120, and a validator that does three real checks: redis, mongodb, and rabbitmq Services each have non-empty Endpoints, AND the broker reports every binding the topology ConfigMap claims to have declared. Topology drift was the other half of this incident; the binding count had silently dropped from five to three after a partial migration three weeks earlier, and nobody had noticed because the topology-version annotation still said 5.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Snippet from the topology-reconcile Job that fixed the broker drift&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;topology-reconcile-2026-05-15&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;predeploy&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;activeDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reconcile&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq:3.13-management&lt;/span&gt;
        &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/bash"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
            &lt;span class="s"&gt;EXPECTED=$(yq '.bindings | length' /config/topology.yaml)&lt;/span&gt;
            &lt;span class="s"&gt;for b in $(yq -o=json '.bindings[]' /config/topology.yaml | jq -c .); do&lt;/span&gt;
              &lt;span class="s"&gt;EX=$(echo $b | jq -r .exchange)&lt;/span&gt;
              &lt;span class="s"&gt;QU=$(echo $b | jq -r .queue)&lt;/span&gt;
              &lt;span class="s"&gt;RK=$(echo $b | jq -r ."routing-key")&lt;/span&gt;
              &lt;span class="s"&gt;rabbitmqadmin declare binding source=$EX destination=$QU routing_key=$RK&lt;/span&gt;
            &lt;span class="s"&gt;done&lt;/span&gt;
            &lt;span class="s"&gt;ACTUAL=$(curl -s -u $USER:$PASS http://rabbitmq:15672/api/bindings | jq 'length')&lt;/span&gt;
            &lt;span class="s"&gt;[ "$ACTUAL" -ge "$EXPECTED" ] || exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Reconcile via Job, not via kubectl exec. The Job is observable, retryable, and leaves an audit record.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The team's rollback runbook now requires two consecutive green health observations twenty seconds apart before a rollout is declared finished. Single-shot green is not enough on a cluster that has a ten-second admission tick, because you can catch the Pod between reverts and declare victory ninety seconds before the next failure cascade. We learned to distrust single-shot green the hard way on a different engagement, and that is now the default in every recovery handover we ship.&lt;/p&gt;

&lt;p&gt;If you are looking at a cluster where every patch reverts within seconds, do not patch faster. Stop patching and find what is doing the reverting. The fix itself is usually ten minutes once you know where the source of truth lives. Finding the source of truth is what takes the hour. If you want a second pair of eyes on a system that is in this state, &lt;a href="https://dev.to/review/"&gt;request an infrastructure review&lt;/a&gt; and we will be on a bridge with you the same day.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/init-container-cascade-reverting-patches/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/init-container-cascade-reverting-patches/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>recovery</category>
      <category>kubernetescicd</category>
    </item>
  </channel>
</rss>
