<?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>DEV Community: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>OAuth client_credentials in Spring Boot for Prometheus Scrapes</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:00:33 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/oauth-clientcredentials-in-spring-boot-for-prometheus-scrapes-27gh</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/oauth-clientcredentials-in-spring-boot-for-prometheus-scrapes-27gh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/oauth-client-credentials-spring-prometheus-scrape/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We stood up Prometheus on a self-hosted DigitalOcean droplet and pointed it at our Spring Boot API's &lt;code&gt;/actuator/prometheus&lt;/code&gt; endpoint. The obvious question came next: how does Prometheus authenticate?&lt;/p&gt;

&lt;p&gt;Until that moment, our OAuth server had spoken exactly two grants — &lt;code&gt;authorization_code&lt;/code&gt; (with PKCE) and &lt;code&gt;refresh_token&lt;/code&gt;. Both assume a user behind the request. Zapier, Make, Pipedream, the Chrome extension, the MCP clients — every existing integration represents a &lt;em&gt;person&lt;/em&gt; who clicked "Connect". Prometheus represents nobody. It's a process on a host scraping a metrics endpoint every 30 seconds. There is no consenting user.&lt;/p&gt;

&lt;p&gt;The RFC has a grant for exactly this — &lt;code&gt;client_credentials&lt;/code&gt;, §4.4. The client authenticates itself to the token endpoint with its credentials, gets back a bearer, and presents that bearer on subsequent calls. No user, no consent screen, no refresh token. Here's how we slotted it into an existing Spring Security stack without regressing a single user-bound integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client_credentials Fits
&lt;/h2&gt;

&lt;p&gt;Three properties of the Prometheus scrape are worth being explicit about, because each maps directly to a §4.4 design choice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no user.&lt;/strong&gt; Prometheus is a daemon. It has no Auth0 identity, no email, no role assignments. Forcing it through &lt;code&gt;authorization_code&lt;/code&gt; would mean creating a synthetic "Prometheus user" — a pattern that's been a security footgun every time we've seen it (service accounts that drift into having too many permissions because they're shaped like real users).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The credentials live on the server, rotatably.&lt;/strong&gt; Prometheus reads its OAuth &lt;code&gt;client_secret&lt;/code&gt; from a file on disk that the deployment script writes. Rotating means redeploying the secret file and restarting Prometheus — same operational model as any other server-side credential. No browser, no consent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The blast radius is one endpoint.&lt;/strong&gt; Prometheus has exactly one thing it's allowed to do: GET &lt;code&gt;/actuator/prometheus&lt;/code&gt;. Not list URLs, not read analytics, not touch user data. The credential should be incapable of doing anything else even if it leaks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;client_credentials&lt;/code&gt; is the RFC-defined shape for all three. The wire flow is two HTTP calls: &lt;code&gt;POST /oauth/token&lt;/code&gt; with HTTP Basic-Auth-encoded client credentials returns an &lt;code&gt;access_token&lt;/code&gt;; every subsequent scrape carries &lt;code&gt;Authorization: Bearer &amp;lt;access_token&amp;gt;&lt;/code&gt; until the token expires. Per §4.4.3 there is no refresh token — when the access token expires, the client re-authenticates by hitting &lt;code&gt;/oauth/token&lt;/code&gt; again. Prometheus's built-in &lt;code&gt;oauth2:&lt;/code&gt; block does this transparently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Schema Relaxations
&lt;/h2&gt;

&lt;p&gt;Before any of the Java could change, the database had to admit the shape of a userless token and the shape of a redirect-URI-less client. Liquibase changeset 356 made three modifications:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;changeSet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;356-add-oauth-client-credentials-grant&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anand&lt;/span&gt;
    &lt;span class="na"&gt;changes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_clients&lt;/span&gt;
          &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;column&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;grant_types&lt;/span&gt;
                &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VARCHAR(100)&lt;/span&gt;
                &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization_code,refresh_token"&lt;/span&gt;
                &lt;span class="na"&gt;constraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
                &lt;span class="na"&gt;remarks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Comma-separated&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;RFC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6749&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;client&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;permitted&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;use."&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dropNotNullConstraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_clients&lt;/span&gt;
          &lt;span class="na"&gt;columnName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redirect_uris&lt;/span&gt;
          &lt;span class="na"&gt;columnDataType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TEXT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dropNotNullConstraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_access_tokens&lt;/span&gt;
          &lt;span class="na"&gt;columnName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_id&lt;/span&gt;
          &lt;span class="na"&gt;columnDataType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BIGINT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each change has a specific reason:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_clients.grant_types&lt;/code&gt; (NEW column).&lt;/strong&gt; Previously, the grant type set was implicit — every client got AC + RT. Now each client carries the exact list of grants it's allowed to request. Existing rows get backfilled to &lt;code&gt;"authorization_code,refresh_token"&lt;/code&gt; via the column default during the &lt;code&gt;addColumn&lt;/code&gt; step, which is the property that keeps every existing integration green. Zapier's row, Make's row, both Pipedream rows, the Chrome extension's row — all get the same string they were implicitly assuming. The new CC-only Prometheus client gets &lt;code&gt;"client_credentials"&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_clients.redirect_uris&lt;/code&gt; nullable.&lt;/strong&gt; A CC-only client legitimately has no browser callback. Without this relaxation, every CC client registration would have to invent a fake URL just to satisfy a &lt;code&gt;NOT NULL&lt;/code&gt; constraint that was modeling the wrong invariant. Enforcement now moves to the service layer — it's still required for &lt;code&gt;authorization_code&lt;/code&gt; clients, but the rule is "required when grant_types includes AC", not "required always".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_access_tokens.user_id&lt;/code&gt; nullable.&lt;/strong&gt; A CC token represents the client, not a user. The foreign key to &lt;code&gt;users(id) ON DELETE CASCADE&lt;/code&gt; stays — &lt;code&gt;NULL&lt;/code&gt; becomes the signal for "service-principal token, no user lookup needed". Token validation branches on this: if &lt;code&gt;userId&lt;/code&gt; is null, skip the user-load step.&lt;/p&gt;

&lt;p&gt;The changeset uses &lt;code&gt;addColumn&lt;/code&gt; and &lt;code&gt;dropNotNullConstraint&lt;/code&gt; rather than raw SQL, which sidesteps the Liquibase PL/pgSQL footgun (folded-block YAML corrupting dollar quotes). All three changes are reversible and the rollback is provided explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token Issuance
&lt;/h2&gt;

&lt;p&gt;The token endpoint is a switch on &lt;code&gt;grant_type&lt;/code&gt;. We added a third branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;issueClientCredentialsToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                                 &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestedResource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validateTokenEndpointClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPublicClient&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_INVALID_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"client_credentials grant requires a confidential client"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supportsGrant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_credentials"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_CLIENT_DISABLED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Client is not authorized to use client_credentials grant"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Default to the client's full allowed scope set if none requested; otherwise&lt;/span&gt;
    &lt;span class="c1"&gt;// intersect requested scopes with allowed (RFC 6749 §3.3 narrowing).&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopesSet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedScope&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseScopes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_INVALID_SCOPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"One or more requested scopes are not granted to this client"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;grantedScopeString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Mint access token only — no refresh token per RFC 6749 §4.4.3.&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generateToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TOKEN_LENGTH&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accessTokenHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;accessExpiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAccessTokenLifetimeSeconds&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessTokenEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tokenHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessTokenHash&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// CC tokens have no user&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grantedScopeString&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessExpiresAt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTenantId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedResource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;accessTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessTokenEntity&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...returns TokenResponse with no refresh_token&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this is doing right:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public-client rejection.&lt;/strong&gt; A public client (&lt;code&gt;token_endpoint_auth_method = "none"&lt;/code&gt;) is one that authenticates via PKCE only. CC has no PKCE story — its security model is "the client knows the secret". Public clients can never use CC, full stop. We reject before issuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-client grant gating.&lt;/strong&gt; &lt;code&gt;supportsGrant("client_credentials")&lt;/code&gt; checks the row's &lt;code&gt;grant_types&lt;/code&gt; column. A client registered for AC + RT cannot suddenly switch grants on the wire. The grant set is part of the client identity, not a request-time choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope narrowing.&lt;/strong&gt; RFC 6749 §3.3 says the authorization server MAY issue a narrower scope than requested, MUST NOT issue a wider one. We default to "narrowest of allowed and requested", and reject if the request asks for anything outside &lt;code&gt;allowed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The token gets persisted with &lt;code&gt;userId = null&lt;/code&gt; — the column relaxation from migration 356 is what allows that row to exist at all. No refresh token row is created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication Subclass
&lt;/h2&gt;

&lt;p&gt;The hardest design decision wasn't on the token endpoint. It was on the validation side. When a bearer comes in on &lt;code&gt;/actuator/prometheus&lt;/code&gt;, the existing filter chain inspects the token and constructs a Spring Security &lt;code&gt;Authentication&lt;/code&gt; object to attach to the request. The existing code expects every authentication to have a user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// OAuthTokenAuthentication.java (existing — unchanged)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OAuthTokenAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildAuthorities&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// would NPE for CC tokens&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive fix is to make &lt;code&gt;user&lt;/code&gt; nullable on &lt;code&gt;OAuthTokenAuthentication&lt;/code&gt; and sprinkle null-checks across every consumer. We considered that and rejected it — the codebase has dozens of &lt;code&gt;instanceof OAuthTokenAuthentication&lt;/code&gt; checks across &lt;code&gt;AuthContext&lt;/code&gt;, &lt;code&gt;OAuthScopeEnforcementFilter&lt;/code&gt;, &lt;code&gt;Jo4McpTools&lt;/code&gt;, and elsewhere. Making &lt;code&gt;user&lt;/code&gt; nullable on the existing type would mean auditing every single one of them and adding null guards, with the constant risk of missing one and getting an NPE in production six weeks later.&lt;/p&gt;

&lt;p&gt;We introduced a separate authentication type instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Getter&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractAuthenticationToken&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildAuthorities&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopesSet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;setAuthenticated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * The "principal" of a client_credentials token is the OAuth client itself.
     */&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;getPrincipal&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys us is &lt;em&gt;type-driven correctness&lt;/em&gt;. Every existing &lt;code&gt;instanceof OAuthTokenAuthentication&lt;/code&gt; check naturally evaluates false for a CC token. Code that asks "who is the user behind this request?" gets &lt;code&gt;Optional.empty()&lt;/code&gt; from &lt;code&gt;AuthContext.getActualUserOptional()&lt;/code&gt;. Controllers that call &lt;code&gt;authContext.getCurrentUser()&lt;/code&gt; throw &lt;code&gt;UNAUTHORIZED&lt;/code&gt; automatically. The MCP tools that filter by &lt;code&gt;userId&lt;/code&gt; never even see CC requests. We didn't have to audit dozens of sites; the type system did it for us.&lt;/p&gt;

&lt;p&gt;The validation filter branches once, at the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside OAuthTokenAuthenticationFilter, paraphrased&lt;/span&gt;
&lt;span class="nc"&gt;ValidatedToken&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oauthService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validateAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bearer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuthTokenAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validateAccessToken&lt;/code&gt; itself has exactly one change — the user lookup becomes conditional on &lt;code&gt;userId != null&lt;/code&gt;. That's the entire blast radius on the validation side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Path Allowlist
&lt;/h2&gt;

&lt;p&gt;Scope-based authorization gets us most of the way. The Prometheus client has &lt;code&gt;metrics:read&lt;/code&gt; scope only — not &lt;code&gt;read&lt;/code&gt;, not &lt;code&gt;write&lt;/code&gt;. Endpoints requiring &lt;code&gt;read&lt;/code&gt;/&lt;code&gt;write&lt;/code&gt; reject it because the bearer lacks those scopes.&lt;/p&gt;

&lt;p&gt;But "lacks the right scope" is one layer. We wanted defense-in-depth, because scope sets get edited in admin UIs by humans and a future "let's give the metrics client &lt;code&gt;read&lt;/code&gt; to test something" five-second mistake should not silently grant CC tokens access to user data. So we added a second filter that runs immediately after the token filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClientCredentialsTokenGateFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AntPathMatcher&lt;/span&gt; &lt;span class="n"&gt;pathMatcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AntPathMatcher&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.security.cc-token-paths:/actuator/prometheus}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;ccTokenPaths&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;doFilterInternal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpServletResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FilterChain&lt;/span&gt; &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;ServletException&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// Only act on CC tokens. Everything else (JWT, API key, user-bound OAuth, anonymous) passes through.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt; &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestURI&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ccTokenPaths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;String:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;pathAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;anyMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pathMatcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pathAllowed&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_credentials token attempted to access non-allowlisted path: clientId={}, path={}, scopes={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClientId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopes&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// RFC 6750 §3.1: insufficient_scope on a Bearer-protected resource.&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FORBIDDEN&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WWW-Authenticate"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Bearer error=\"insufficient_scope\", error_description=\"client_credentials tokens are not valid for this endpoint\""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWriter&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"{\"error\":\"insufficient_scope\",\"error_description\":\"client_credentials tokens are not valid for this endpoint\"}"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The instanceof guard is the no-op short-circuit.&lt;/strong&gt; JWT requests, API-key requests, user-bound OAuth requests, anonymous requests — all four pass through the filter with one type check and zero allocations. The cost is paid only on the small set of CC requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config-driven allowlist.&lt;/strong&gt; &lt;code&gt;app.security.cc-token-paths&lt;/code&gt; is comma-separated and &lt;code&gt;AntPathMatcher&lt;/code&gt;-evaluated. Adding a future endpoint (say, an alertmanager receiver) is a config edit, not a code change. The default — &lt;code&gt;/actuator/prometheus&lt;/code&gt; — is the only thing CC tokens can reach out of the box:&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;# application-security.yaml&lt;/span&gt;
&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Paths where client_credentials OAuth tokens are accepted. CSV; AntPathMatcher syntax.&lt;/span&gt;
    &lt;span class="na"&gt;cc-token-paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/actuator/prometheus"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;WWW-Authenticate: Bearer error="insufficient_scope"&lt;/code&gt;&lt;/strong&gt; is the RFC 6750 §3.1 shape. Prometheus and other OAuth-aware clients can read this and surface a meaningful error, rather than getting a generic 403 with no clue what went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never throws.&lt;/strong&gt; Filters that throw can mask the underlying error or tear down the chain in surprising ways. This one always writes a response or delegates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the Scrape
&lt;/h2&gt;

&lt;p&gt;On the Prometheus side, the entire setup is one block in &lt;code&gt;prometheus.yml&lt;/code&gt;:&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="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-api&lt;/span&gt;
    &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
    &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;oauth2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__PROMETHEUS_OAUTH_CLIENT_ID__&lt;/span&gt;
      &lt;span class="na"&gt;client_secret_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/prometheus/oauth-client-secret&lt;/span&gt;
      &lt;span class="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://10.108.0.3:8080/oauth/token&lt;/span&gt;
      &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;metrics:read&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.108.0.3:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&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;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alertstage&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from setting this up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;client_id&lt;/code&gt; inline, &lt;code&gt;client_secret_file&lt;/code&gt; from disk.&lt;/strong&gt; Per OAuth 2.0, the client ID is public — fine to bake into the config and sed-substitute at deploy time. The secret stays in a separate file with restrictive permissions, written by the bootstrap script from a GitHub Secret. Never co-located, never logged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private VPC URLs.&lt;/strong&gt; &lt;code&gt;token_url&lt;/code&gt; and the scrape target both use the DigitalOcean private IP &lt;code&gt;10.108.0.3&lt;/code&gt;. Traffic never leaves the VPC, which is the primary security boundary; the OAuth check is defense-in-depth on top of that. The token endpoint accepts plain HTTP only because it's on a private subnet; this would be HTTPS for any path that crosses the public internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;scopes: ["metrics:read"]&lt;/code&gt;.&lt;/strong&gt; Prometheus's &lt;code&gt;oauth2:&lt;/code&gt; block converts this into the &lt;code&gt;scope&lt;/code&gt; form field of the &lt;code&gt;/oauth/token&lt;/code&gt; request. Our &lt;code&gt;issueClientCredentialsToken&lt;/code&gt; narrows it against the client's allowed set — which happens to be just &lt;code&gt;metrics:read&lt;/code&gt; — and stamps the result on the issued access token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;refresh_token&lt;/code&gt; config.&lt;/strong&gt; Prometheus knows §4.4.3 — when it sees a token response with no &lt;code&gt;refresh_token&lt;/code&gt;, it just re-runs the token flow when the current access token nears expiry. No refresh logic on either side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing It End-To-End
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;curl&lt;/code&gt; sequence to verify the whole loop, against a freshly registered Prometheus client (client ID &lt;code&gt;jo4_Xxxx&lt;/code&gt;, client secret captured at registration):&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. Token exchange — HTTP Basic-Auth-encoded client credentials.&lt;/span&gt;
&lt;span class="nv"&gt;ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jo4-api.jo4.io/oauth/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"jo4_Xxxx:secret_XXXXXXXX"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=client_credentials"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"scope=metrics:read"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .access_token&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Scrape — bearer on the request.&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://jo4-api.jo4.io/actuator/prometheus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# → HTTP/2 200&lt;/span&gt;
&lt;span class="c"&gt;# → # HELP http_server_requests_seconds ...&lt;/span&gt;

&lt;span class="c"&gt;# 3. Negative test — same token against a user endpoint.&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://jo4-api.jo4.io/api/v1/urls/me &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# → HTTP/2 403&lt;/span&gt;
&lt;span class="c"&gt;# → WWW-Authenticate: Bearer error="insufficient_scope", ...&lt;/span&gt;
&lt;span class="c"&gt;# → {"error":"insufficient_scope","error_description":"client_credentials tokens are not valid for this endpoint"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third call is the critical one to verify. The same bearer, presented at a non-allowlisted path, must be rejected — even though the bearer is technically valid. If you see anything other than 403 + &lt;code&gt;insufficient_scope&lt;/code&gt; there, the gate filter isn't wired correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new grant type is mostly a schema problem.&lt;/strong&gt; The Java for &lt;code&gt;client_credentials&lt;/code&gt; is small. The work was three column relaxations and an audit of every codepath that assumed &lt;code&gt;userId&lt;/code&gt; was non-null.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate Authentication subclass beats nullable fields on the existing one.&lt;/strong&gt; &lt;code&gt;instanceof&lt;/code&gt; checks across the codebase become the type-system's audit for you. Sprinkling null-checks would have been an ongoing tax forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always backfill new NOT NULL columns via a default.&lt;/strong&gt; Adding &lt;code&gt;grant_types NOT NULL&lt;/code&gt; would have failed on every existing row without &lt;code&gt;defaultValue: "authorization_code,refresh_token"&lt;/code&gt;. The default is what makes the migration zero-downtime and the rollout silent for every existing client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense-in-depth means two independent layers, not one layer twice.&lt;/strong&gt; Scope-based authorization handles "the client wasn't granted this scope". The path-allowlist filter handles "even if someone misconfigures the scope set tomorrow, this token still can't hit user data". Both layers must agree before access is granted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WWW-Authenticate&lt;/code&gt; on 401/403 from a bearer-protected resource is RFC 6750 §3.1.&lt;/strong&gt; Clients can react to &lt;code&gt;error="insufficient_scope"&lt;/code&gt; programmatically. A naked 403 with no header forces humans into the loop for every failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No refresh token for CC, period.&lt;/strong&gt; RFC 6749 §4.4.3. Prometheus and every other OAuth-aware client know how to re-run the token flow on expiry. Issuing a refresh token would be inventing capability the spec explicitly forbids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate every existing integration before shipping.&lt;/strong&gt; We scanned ten risk categories across the codebase before touching a line — Zapier, Make, Pipedream, Chrome extension, MCP clients, OIDC discovery, &lt;code&gt;/oauth/userinfo&lt;/code&gt;, the scope filter, public endpoints, the previously-public &lt;code&gt;/actuator/prometheus&lt;/code&gt; path. The migration changed default-value-backfilled columns; behavior changed for exactly one path, and it was the one we intended.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Adding CC grant to an existing AC flow?&lt;/strong&gt; What schema migration bit you? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>springboot</category>
      <category>security</category>
      <category>prometheus</category>
    </item>
    <item>
      <title>Bound Spring MVC's Async Executor or Pay for It Under Load</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:43:18 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/bound-spring-mvcs-async-executor-or-pay-for-it-under-load-49ck</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/bound-spring-mvcs-async-executor-or-pay-for-it-under-load-49ck</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/mvc-async-executor-saturation-service-saturated/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Spring MVC's default async task executor spawns a brand new thread for every async request. No queue. No ceiling. No back-pressure. Spring itself logs a WARN on first use: &lt;em&gt;"This executor is not suitable for production use under load."&lt;/em&gt; That's a load-bearing warning, and most teams scroll past it.&lt;/p&gt;

&lt;p&gt;We learned the hard way and ended up wiring a bounded &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;, a typed rejection path, and a Micrometer saturation gauge. Here's the whole arc.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spring MVC Defaults To
&lt;/h2&gt;

&lt;p&gt;When a controller method returns a &lt;code&gt;DeferredResult&lt;/code&gt;, &lt;code&gt;Callable&lt;/code&gt;, &lt;code&gt;WebAsyncTask&lt;/code&gt;, &lt;code&gt;Flux&lt;/code&gt;, or &lt;code&gt;Mono&lt;/code&gt;, Spring MVC needs an executor to run the async dispatch. If you don't configure one, you get &lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt; does exactly what its name says: every submission spawns a fresh thread. There is no pool. There is no queue. There is no rejection policy, because there is no ceiling to reject against. Send it a thousand concurrent requests and it will cheerfully start a thousand threads, each with its own ~1 MB stack, and watch your container OOM.&lt;/p&gt;

&lt;p&gt;Spring 6 logs this WARN on first use:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This executor is not suitable for production use under load. Consider configuring a ThreadPoolTaskExecutor via WebMvcConfigurer.configureAsyncSupport.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you ignore the warning, the failure mode is exotic: latency stays fine until traffic crosses some invisible threshold, at which point the JVM either runs out of native threads (Linux &lt;code&gt;pthread_create&lt;/code&gt; returns EAGAIN) or runs out of heap because every parked thread is holding stack frames and a Tomcat request scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why That's Wrong For Real Apps
&lt;/h2&gt;

&lt;p&gt;Three patterns push you onto this executor whether you realize it or not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DeferredResult&lt;/code&gt; / &lt;code&gt;Callable&lt;/code&gt; / &lt;code&gt;WebAsyncTask&lt;/code&gt;&lt;/strong&gt; returns. Anyone using the classic async-servlet pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Flux&lt;/code&gt; / &lt;code&gt;Mono&lt;/code&gt; returns from a Spring MVC controller.&lt;/strong&gt; Spring MVC (not WebFlux) bridges reactive types onto the async-dispatch path. Every chunk emission round-trips through the executor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE endpoints (&lt;code&gt;SseEmitter&lt;/code&gt;).&lt;/strong&gt; Long-lived streams that hold a dispatch thread for the entire lifetime of the connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We ship all three. Our analytics SSE stream in particular pins one async thread per subscriber for up to five minutes (our &lt;code&gt;ASYNC_TIMEOUT&lt;/code&gt;). With &lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt;, 500 concurrent dashboard users would mean 500 unbounded threads with no warning sign before the container falls over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded Pool Setup
&lt;/h2&gt;

&lt;p&gt;The fix is a real &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; with explicit core, max, and queue settings. Here's the actual bean from &lt;code&gt;WebMvcAsyncConfig.java&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mvcAsyncExecutor"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destroyMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"shutdown"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="nf"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCorePoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// queueCapacity = 0 → Spring uses SynchronousQueue: direct hand-off, no buffering.&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueueCapacity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jo4-mvc-async-"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRejectedExecutionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AbortPolicy&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWaitForTasksToCompleteOnShutdown&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAwaitTerminationSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureAsyncSupport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AsyncSupportConfigurer&lt;/span&gt; &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDefaultTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ASYNC_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth calling out, because the obvious choices are wrong for long-lived async:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;queueCapacity = 0&lt;/code&gt;.&lt;/strong&gt; Spring's &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; interprets &lt;code&gt;queueCapacity = 0&lt;/code&gt; as a &lt;code&gt;SynchronousQueue&lt;/code&gt; — a direct hand-off with no buffering. The instinct is to "set a big queue, it's free." For short-lived tasks that's right. For SSE streams (which hold the thread for up to 5 minutes), a non-zero queue would &lt;em&gt;park&lt;/em&gt; new connections waiting for an existing core thread to free — effectively "hang the client until someone else disconnects." With a 0-capacity queue, the pool grows from &lt;code&gt;corePoolSize=8&lt;/code&gt; toward &lt;code&gt;maxPoolSize=200&lt;/code&gt; as new subscribers arrive, and only true saturation hits the rejection handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AbortPolicy&lt;/code&gt;, not &lt;code&gt;CallerRunsPolicy&lt;/code&gt;.&lt;/strong&gt; This one we learned by pain. Our first attempt used &lt;code&gt;CallerRunsPolicy&lt;/code&gt; (the seemingly-graceful "if the pool is full, run inline on the caller's thread"). Under load that pins the Tomcat request thread executing the task synchronously — which turned multi-minute Playwright teardowns into multi-minute hangs because the test runner's "close all contexts" call was racing pool saturation. Fail-fast with &lt;code&gt;AbortPolicy&lt;/code&gt; is preferable: back-pressure surfaces as a &lt;code&gt;RejectedExecutionException&lt;/code&gt; that the caller can translate into an HTTP 503, and the Tomcat thread is freed immediately for the next request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;waitForTasksToCompleteOnShutdown = true&lt;/code&gt; + &lt;code&gt;awaitTerminationSeconds = 30&lt;/code&gt;.&lt;/strong&gt; SSE streams need time to flush their final event and let the client see the close cleanly. Hard-killing the pool on shutdown means subscribers get a TCP reset instead of a proper stream-end signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;destroyMethod = "shutdown"&lt;/code&gt;.&lt;/strong&gt; Pairs the bean lifecycle with the executor lifecycle so Spring's context shutdown drains the pool. Without it you leak threads on every context refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejection With Meaning
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbortPolicy&lt;/code&gt; throws &lt;code&gt;RejectedExecutionException&lt;/code&gt;. By default that propagates out of the async-dispatch machinery, hits the generic exception handler, and becomes a &lt;code&gt;500 Internal Server Error&lt;/code&gt; — which is a lie. It's not an internal error; it's capacity. Calling clients, CDN edges, and retry-aware HTTP libraries all behave differently against 500 vs 503: 500 says "give up, this is broken"; 503 says "back off and try again."&lt;/p&gt;

&lt;p&gt;We give it a typed home in our &lt;code&gt;ErrorCode&lt;/code&gt; enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="no"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SERVICE_SATURATED"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Server is temporarily at capacity. Please retry."&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we catch it explicitly in &lt;code&gt;GlobalExceptionHandler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RejectedExecutionException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleRejectedExecution&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RejectedExecutionException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getRequestUri&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Async executor saturated on {}: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestUri&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Fault&lt;/span&gt; &lt;span class="n"&gt;fault&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fault&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCode&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;faults&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fault&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RETRY_AFTER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"5"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here. First, the response is &lt;code&gt;503 SERVICE_UNAVAILABLE&lt;/code&gt; — semantically correct for "we're at capacity," not "we're broken." Second, the &lt;code&gt;Retry-After: 5&lt;/code&gt; header is honored by &lt;code&gt;fetch&lt;/code&gt; retry middlewares, the AWS SDK, and most CDN edges, which means callers self-throttle without us writing client code. Third, the structured fault carries &lt;code&gt;SERVICE_SATURATED&lt;/code&gt; so the SPA can render a friendly "we're at capacity, try again" toast instead of a generic 5xx splash.&lt;/p&gt;

&lt;p&gt;The mapping back to HTTP status lives in &lt;code&gt;mapErrorCodeToStatus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;SERVICE_SATURATED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that explicit handler, the rejection would fall through to the catch-all &lt;code&gt;Exception&lt;/code&gt; handler and become a 500. Same root cause, completely different operational signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make It Observable
&lt;/h2&gt;

&lt;p&gt;Bounded pools are useless if you can't see how close you are to the wall. We expose five gauges via a &lt;code&gt;MeterBinder&lt;/code&gt;, scraped from &lt;code&gt;/actuator/prometheus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;MeterBinder&lt;/span&gt; &lt;span class="nf"&gt;mvcAsyncExecutorMetrics&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.active"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ThreadPoolTaskExecutor:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getActiveCount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Threads currently executing tasks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.pool.size"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ThreadPoolTaskExecutor:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getPoolSize&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Current pool size (grows from core toward max under load)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.pool.max"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Configured maxPoolSize ceiling"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.queue.size"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="o"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Queued tasks waiting for a thread"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// The headline metric: active / max. 0.8 = alert; 1.0 = saturated.&lt;/span&gt;
        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.saturation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActiveCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="o"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Active-to-max ratio (0..1). &amp;gt;0.8 = capacity warning, 1.0 = rejecting"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;};&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three (&lt;code&gt;active&lt;/code&gt;, &lt;code&gt;pool.size&lt;/code&gt;, &lt;code&gt;pool.max&lt;/code&gt;) are diagnostic — useful in a Grafana dashboard to see how the pool actually grows. &lt;code&gt;queue.size&lt;/code&gt; is included for forward compatibility; with &lt;code&gt;queueCapacity = 0&lt;/code&gt; it's always zero, but if we ever tune the queue we don't want to be the team that forgot to publish the gauge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mvc.async.executor.saturation&lt;/code&gt; is the headline. It's a single derived ratio (active / max) bounded between 0 and 1, which means one alert threshold works regardless of how we resize the pool over time. Cardinality of one, semantics of "how close are we to rejecting" — exactly what a pager wants to wake you up about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerts That Actually Mean Something
&lt;/h2&gt;

&lt;p&gt;Two thresholds, both honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WARN&lt;/strong&gt; when &lt;code&gt;mvc_async_executor_saturation &amp;gt; 0.8&lt;/code&gt; for 5 minutes. We're at 80% of the ceiling for long enough to mean it's not a spike. Likely time to bump &lt;code&gt;maxPoolSize&lt;/code&gt;, investigate slow async handlers, or check if SSE clients are leaking connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRIT&lt;/strong&gt; when &lt;code&gt;mvc_async_executor_saturation == 1.0&lt;/code&gt;. We're rejecting. &lt;code&gt;SERVICE_SATURATED&lt;/code&gt; 503s are going out the door. Page on-call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The warning level matters because saturation is a leading indicator. Once you're rejecting, customers have already seen 503s. Once you're at 80%, you have minutes to act before that happens — assuming you alert on it. The corollary: a Grafana dashboard with saturation as the headline tile (not request rate, not latency) tells you at a glance whether you're about to have a bad day.&lt;/p&gt;

&lt;p&gt;We pair saturation with the &lt;code&gt;Retry-After&lt;/code&gt; header on the 503 so even when we &lt;em&gt;are&lt;/em&gt; rejecting, well-behaved clients smooth it out and we recover without a thundering herd hammering us the moment the pool drains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt; is a development convenience, not a production executor.&lt;/strong&gt; If you return &lt;code&gt;DeferredResult&lt;/code&gt;, &lt;code&gt;Flux&lt;/code&gt;, &lt;code&gt;Mono&lt;/code&gt;, or use &lt;code&gt;SseEmitter&lt;/code&gt; from a Spring MVC controller, configure &lt;code&gt;AsyncSupportConfigurer.setTaskExecutor&lt;/code&gt; with a real bounded pool. The default's WARN log message is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue capacity is a function of task duration, not of "more is better."&lt;/strong&gt; For sub-second tasks, a generous queue smooths spikes. For long-lived tasks like SSE, a queue silently hangs new connections behind old ones. Use &lt;code&gt;queueCapacity = 0&lt;/code&gt; + grow the pool to &lt;code&gt;maxPoolSize&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AbortPolicy&lt;/code&gt; over &lt;code&gt;CallerRunsPolicy&lt;/code&gt; for HTTP work.&lt;/strong&gt; &lt;code&gt;CallerRunsPolicy&lt;/code&gt; pins your Tomcat request threads under load and turns a capacity problem into a hung-process problem. Fail fast, return 503.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map rejection to a typed error code, not a generic 500.&lt;/strong&gt; &lt;code&gt;RejectedExecutionException → SERVICE_SATURATED → HTTP 503 + Retry-After&lt;/code&gt; tells callers something actionable. 500 tells them nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saturation is the gauge worth alerting on.&lt;/strong&gt; A derived &lt;code&gt;active/max&lt;/code&gt; ratio is dimensionless, bounded 0..1, survives pool resizing, and gives you a single threshold to define "we're about to have a bad day."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The WARN threshold is the one that prevents pages.&lt;/strong&gt; Alert at 0.8 with 5-minute hysteresis to get warned &lt;em&gt;before&lt;/em&gt; you reject. Alert at 1.0 to know you're already rejecting. Both, not either.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What's your async-executor war story?&lt;/strong&gt; Tail-latency, thread leaks, OOM? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>performance</category>
      <category>observability</category>
    </item>
    <item>
      <title>How One Spring Boot Default Killed Our SSE Endpoint Under Load</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:02:31 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-one-spring-boot-default-killed-our-sse-endpoint-under-load-1681</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-one-spring-boot-default-killed-our-sse-endpoint-under-load-1681</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/sse-osiv-pool-leak-spring/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our staging environment had ten browser tabs open with the notification bell. The eleventh person to open the app got an API that just sat there. Not a 500. Not a timeout (yet). Just a silent stall on every endpoint, not only the SSE one. The pod looked healthy. CPU flat, memory fine, logs quiet.&lt;/p&gt;

&lt;p&gt;We'd turned our notification bell into a Server-Sent Events stream while leaving one Spring Boot default in place. For a request-response API, that default is fine. For a 30-minute SSE connection, it's lethal: &lt;code&gt;spring.jpa.open-in-view=true&lt;/code&gt; pins a Hikari connection per open tab for the entire lifetime of the stream.&lt;/p&gt;

&lt;p&gt;Here's the bug, the math behind the symptom, and the one-line fix — plus the precondition the endpoint has to satisfy for the fix to be safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Endpoint
&lt;/h2&gt;

&lt;p&gt;The SSE endpoint lives in &lt;code&gt;NotificationController.streamNotifications()&lt;/code&gt;. It returns a &lt;code&gt;Flux&amp;lt;ServerSentEvent&amp;lt;NotificationStreamEvent&amp;gt;&amp;gt;&lt;/code&gt; to the client and stays open for 30 minutes, emitting the initial unread count, live updates from the event bus, and a heartbeat every 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_VALUE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;streamNotifications&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCurrentUser&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;
        &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unread-count"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;notificationEventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;HEARTBEAT_INTERVAL&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ping"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;concat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;just&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;STREAM_LIFETIME&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers matter: &lt;code&gt;STREAM_LIFETIME = Duration.ofMinutes(30)&lt;/code&gt; and &lt;code&gt;HEARTBEAT_INTERVAL = Duration.ofSeconds(30)&lt;/code&gt;. The stream stays open for half an hour. The client reconnects on close with a fresh JWT. The heartbeat keeps it alive through any proxy idle-timeout in between.&lt;/p&gt;

&lt;p&gt;From the controller's perspective this looks innocuous. It's a Flux. It's reactive. It touches JPA &lt;em&gt;once&lt;/em&gt;, synchronously, at the top, to seed the initial count. The Flux body that streams for the next 30 minutes never touches the database.&lt;/p&gt;

&lt;p&gt;That's what we &lt;em&gt;thought&lt;/em&gt; the runtime behavior was. The runtime had other ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  open-in-view: Spring's Helpful Default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;spring.jpa.open-in-view=true&lt;/code&gt; is Spring Boot's default. It enables OSIV — Open Session In View. The mechanism is an interceptor that opens a Hibernate &lt;code&gt;Session&lt;/code&gt; (and, through it, borrows a database connection from the Hikari pool) at the &lt;em&gt;start&lt;/em&gt; of an HTTP request, and holds it open until the response is fully written.&lt;/p&gt;

&lt;p&gt;For traditional MVC this is convenient. Lazy-loaded JPA associations still resolve when your Thymeleaf template or JSON serializer touches them after the controller returns. No &lt;code&gt;LazyInitializationException&lt;/code&gt; — at the cost of holding a DB connection for the lifetime of the HTTP response.&lt;/p&gt;

&lt;p&gt;For a request-response endpoint serving JSON in 50ms, "lifetime of the HTTP response" is 50ms. The connection round-trips back to the pool before the next request even arrives. Pool pressure is essentially zero and the convenience is large enough that the Spring team made it default-on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It's Lethal for SSE
&lt;/h2&gt;

&lt;p&gt;Server-Sent Events flips the lifetime assumption on its head.&lt;/p&gt;

&lt;p&gt;The HTTP response on an SSE endpoint is &lt;em&gt;not&lt;/em&gt; 50ms. It's the entire duration of the open stream — 30 minutes in our case. As long as the tab is open and the stream is live, the response is still being written one event at a time, and the servlet container hasn't released the request or the response.&lt;/p&gt;

&lt;p&gt;OSIV doesn't know any of that. It just sees a request that's still in flight, so the session must still be open, so the connection it borrowed from Hikari must still be held.&lt;/p&gt;

&lt;p&gt;One open SSE stream = one Hikari connection pinned for 30 minutes. The connection isn't being &lt;em&gt;used&lt;/em&gt;. Nothing is running queries on it. It's reserved, sitting idle, unavailable to anyone else, until the response finally ends — at which point the user reconnects with a fresh JWT and the pinned-connection clock resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom Math
&lt;/h2&gt;

&lt;p&gt;Hikari's default &lt;code&gt;maximumPoolSize&lt;/code&gt; is 10. Spring Boot doesn't override it. Most apps don't either, because for a normal API the default is more than enough — connections cycle back in tens of milliseconds and you'd need hundreds of concurrent requests to feel any pressure.&lt;/p&gt;

&lt;p&gt;Now run the math with OSIV + SSE:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Open SSE tabs&lt;/th&gt;
&lt;th&gt;Hikari connections held&lt;/th&gt;
&lt;th&gt;Connections free for other work&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0 (request 11 waits)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 11th SSE subscriber's request asks OSIV for a connection. There are none. Hikari blocks the request thread on the pool, waiting for one to free up. Nothing will free up for the next 29 minutes and 50 seconds. Eventually Hikari hits its connection-timeout (default 30 seconds) and throws &lt;code&gt;SQLTransientConnectionException: HikariPool-1 - Connection is not available&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cruel part: it's not just the SSE endpoint that hangs. &lt;em&gt;Every&lt;/em&gt; endpoint that needs a DB connection — login, the click handler, the dashboard fetch — competes for the same pool. Ten pinned SSE tabs from a single demo session, and the whole API is wedged. The JVM is alive, nothing is crashing, everything is just queuing on an empty pool. You stare at flat CPU graphs for an hour and feel personally betrayed by your observability stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;One line in &lt;code&gt;application-database.yaml&lt;/code&gt;:&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="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;open-in-view&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That disables the OSIV interceptor. Connections are no longer held for the lifetime of the HTTP response — they're borrowed when a transaction starts, returned when it ends. The 30-minute SSE stream now holds &lt;em&gt;zero&lt;/em&gt; connections for the 30 minutes it's open. Hikari pool is unblocked. The 11th tab works. The 1000th tab works.&lt;/p&gt;

&lt;p&gt;Spring's startup log even warns about OSIV being on by default, but it's easy to scroll past in a noisy log. On a normal MVC app it's not a problem worth acting on. On an app with long-lived reactive endpoints, it absolutely is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Precondition That Makes the Fix Safe
&lt;/h2&gt;

&lt;p&gt;You can't just flip &lt;code&gt;open-in-view: false&lt;/code&gt; and call it done. Turning OSIV off changes a semantic: lazy-loaded JPA associations outside an explicit transaction now throw &lt;code&gt;LazyInitializationException&lt;/code&gt;. The endpoint has to be structured so it doesn't depend on that lazy-load convenience.&lt;/p&gt;

&lt;p&gt;Our SSE endpoint already is, intentionally. Look at where it touches JPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;
    &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unread-count"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- only JPA touch&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one call. It runs synchronously at subscribe-time inside the &lt;code&gt;@Transactional&lt;/code&gt; boundary of &lt;code&gt;NotificationService.getUnreadCount(...)&lt;/code&gt;. The transaction opens, borrows a connection, runs the count query, returns a &lt;code&gt;long&lt;/code&gt;, commits, releases the connection. Total time: a few milliseconds.&lt;/p&gt;

&lt;p&gt;After that, every event flowing through the Flux body — live events from &lt;code&gt;notificationEventBus.subscribe(userId)&lt;/code&gt;, the heartbeat pings, the close after 30 minutes — emits a plain in-memory &lt;code&gt;NotificationStreamEvent&lt;/code&gt; record. No JPA. No lazy proxies. Nothing the persistence context needs to resolve.&lt;/p&gt;

&lt;p&gt;The Javadoc on the endpoint spells out this contract explicitly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pool contract:&lt;/strong&gt; this endpoint relies on &lt;code&gt;spring.jpa.open-in-view=false&lt;/code&gt; (set in &lt;code&gt;application-database.yaml&lt;/code&gt;). All JPA access happens at subscribe time via &lt;code&gt;notificationService.getUnreadCount(...)&lt;/code&gt; inside its own transaction. The Flux body emits the in-memory &lt;code&gt;NotificationStreamEvent&lt;/code&gt; record only — no JPA touch — so no Hikari connection is held for the &lt;code&gt;STREAM_LIFETIME&lt;/code&gt; window. If OSIV is ever re-enabled, every open browser tab pins a connection until the stream closes, exhausting the pool at low concurrency. Do not re-enable OSIV.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That paragraph exists because the fix is one config flag away from being silently undone by a well-meaning future contributor who reads "Spring Boot default" and decides to align. The Javadoc is the load-bearing comment for the entire endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OSIV is a request-response convenience.&lt;/strong&gt; It pays for itself in a 50ms JSON endpoint. It bankrupts you in a 30-minute SSE stream. The cost is "connection held for the lifetime of the HTTP response," and on a long-lived response that's the whole stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hikari's default pool of 10 is generous for request-response and tiny for anything that pins connections.&lt;/strong&gt; The default implicitly assumes you're returning connections quickly. If you're not, you usually only learn this in production at the eleventh concurrent subscriber.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool exhaustion makes &lt;em&gt;everything&lt;/em&gt; hang, not just the slow endpoint.&lt;/strong&gt; The DB pool is shared. SSE pinning it starves every other endpoint. The trail leads back to SSE only after you check pool stats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;spring.jpa.open-in-view: false&lt;/code&gt; is right for any app with long-lived reactive endpoints,&lt;/strong&gt; but only safe if those endpoints don't depend on lazy-loading outside an explicit transaction. Audit first; don't flip the flag blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the precondition where the bug would re-enter.&lt;/strong&gt; A one-paragraph "Pool contract" Javadoc on the endpoint is the difference between a fix that holds and one someone undoes in six months while "cleaning up config to match Spring defaults."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactive controllers on a servlet stack are a special kind of trap.&lt;/strong&gt; Reactive types make you &lt;em&gt;think&lt;/em&gt; in non-blocking lifetimes; the underlying servlet container is still tracking the HTTP request as in-flight for the full duration, and request-scoped infrastructure like OSIV happily plays along.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Has OSIV bitten you somewhere unexpected?&lt;/strong&gt; Drop the symptom in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Grafana as Code: Surviving Destroy-Recreate With Provisioning Files</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:43:16 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/grafana-as-code-surviving-destroy-recreate-with-provisioning-files-406c</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/grafana-as-code-surviving-destroy-recreate-with-provisioning-files-406c</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>grafana</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>automation</category>
    </item>
    <item>
      <title>Watchtower Tried to Upgrade Itself. It Didn't Come Back.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:50:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/watchtower-tried-to-upgrade-itself-it-didnt-come-back-15pk</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/watchtower-tried-to-upgrade-itself-it-didnt-come-back-15pk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/watchtower-self-stop-deadlock-pin-versions/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On May 19 we pushed a fix to &lt;code&gt;main&lt;/code&gt;. GitHub Actions built the image, tagged it &lt;code&gt;:latest&lt;/code&gt;, shoved it into GHCR, and went home. Our droplet was supposed to pick that up within sixty seconds. It didn't. Not in sixty minutes, not in two days.&lt;/p&gt;

&lt;p&gt;When we finally SSH'd in to ask why, the answer was in &lt;code&gt;docker ps&lt;/code&gt;: our auto-update tool wasn't running. It had tried to update &lt;em&gt;itself&lt;/em&gt;, succeeded at the "stop" half, and never made it to the "start" half. Nobody noticed for forty-eight hours because the API was up; only the deploy pipeline was silently dead.&lt;/p&gt;

&lt;p&gt;This is the story of that incident and the two-line fix that makes it structurally impossible to repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Watchtower Is For
&lt;/h2&gt;

&lt;p&gt;If you've never used &lt;a href="https://github.com/nicholas-fedor/watchtower" rel="noopener noreferrer"&gt;Watchtower&lt;/a&gt;, it solves a small, useful problem: you push a new image to a registry, and Watchtower running on your host detects the new digest, pulls it, gracefully stops the old container, and starts a fresh one. No SSH, no &lt;code&gt;docker compose pull &amp;amp;&amp;amp; up -d&lt;/code&gt;, no human in the loop. For a tiny team on a single droplet, it's a five-line replacement for an entire CD pipeline. You point it at GHCR, give it the docker socket, and forget it exists.&lt;/p&gt;

&lt;p&gt;Forgetting it exists is exactly the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Incident
&lt;/h2&gt;

&lt;p&gt;Here's what we pieced together after the fact.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;watchtower&lt;/code&gt; service was running &lt;code&gt;:latest&lt;/code&gt; — the version everyone copy-pastes from blog posts. On May 19, upstream pushed a new &lt;code&gt;:latest&lt;/code&gt;. Watchtower, doing its job, polled the registry, saw the new digest, and decided to update &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;To update a container, Watchtower stops it, then starts the replacement. The replacement is launched by Watchtower. Which is the container being stopped.&lt;/p&gt;

&lt;p&gt;You can see the deadlock from orbit. Watchtower issued &lt;code&gt;docker stop&lt;/code&gt; against its own container. The Docker socket connection it was using to issue the "now start the new one" command got severed the instant the container died. The new container never launched. The host was left with no Watchtower at all — just the old API container, happily serving stale code, and no process polling the registry for new builds.&lt;/p&gt;

&lt;p&gt;The comment we eventually added to &lt;code&gt;docker-compose.yml&lt;/code&gt; captures it best:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;image is PINNED, not :latest. On 2026-05-19 Watchtower self-detected an updated :latest, tried to stop itself to upgrade, cut its own Docker-socket connection mid-stop, and never restarted — leaving this droplet on stale code for 2 days until manual intervention. Bump this tag explicitly to upgrade Watchtower itself; do NOT track :latest.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two days. We only caught it because someone noticed a bug fix that was definitely in &lt;code&gt;main&lt;/code&gt; was definitely &lt;em&gt;not&lt;/em&gt; in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Couldn't Recover
&lt;/h2&gt;

&lt;p&gt;Natural question: why didn't &lt;code&gt;restart: unless-stopped&lt;/code&gt; save us? It's right there in the compose file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;restart&lt;/code&gt; policies fire when a container &lt;em&gt;crashes&lt;/em&gt; — exits with a non-zero code, or the daemon restarts. They do not fire when a container is &lt;em&gt;intentionally stopped&lt;/em&gt; via the API. From Docker's perspective, somebody asked Watchtower to stop. It stopped. Nothing to restart. The fact that the "somebody" was Watchtower itself — and that the next command in the sequence never ran because the issuer evaporated — is invisible to the daemon.&lt;/p&gt;

&lt;p&gt;The deeper problem is that nothing was watching the watcher. Every other container on the host had Watchtower as its safety net. Watchtower had nothing. It was the bottom turtle, and when the bottom turtle quietly walks off, the whole stack just sits there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Layer Fix
&lt;/h2&gt;

&lt;p&gt;We fixed it twice, on purpose. The full Watchtower service now looks like this:&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;# Watchtower - Auto-updates containers when new images are pushed to GHCR&lt;/span&gt;
  &lt;span class="c1"&gt;# Eliminates need for SSH-based deployments&lt;/span&gt;
  &lt;span class="c1"&gt;# NOTE: Using nickfedor/watchtower fork (containrrr/watchtower is archived&lt;/span&gt;
  &lt;span class="c1"&gt;# and incompatible with Docker 24+)&lt;/span&gt;
  &lt;span class="c1"&gt;#&lt;/span&gt;
  &lt;span class="c1"&gt;# IMPORTANT: image is PINNED, not :latest. On 2026-05-19 Watchtower&lt;/span&gt;
  &lt;span class="c1"&gt;# self-detected an updated :latest, tried to stop itself to upgrade, cut&lt;/span&gt;
  &lt;span class="c1"&gt;# its own Docker-socket connection mid-stop, and never restarted —&lt;/span&gt;
  &lt;span class="c1"&gt;# leaving this droplet on stale code for 2 days until manual intervention.&lt;/span&gt;
  &lt;span class="c1"&gt;# Bump this tag explicitly to upgrade Watchtower itself; do NOT track :latest.&lt;/span&gt;
  &lt;span class="na"&gt;watchtower&lt;/span&gt;&lt;span class="pi"&gt;:&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;nickfedor/watchtower:1.17.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-watchtower&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/root/.docker/config.json:/config.json:ro&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_CLEANUP=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_POLL_INTERVAL=60&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_INCLUDE_STOPPED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_REVIVE_STOPPED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_ROLLING_RESTART=false&lt;/span&gt;
      &lt;span class="c1"&gt;# Opt-in mode: only watch containers labeled&lt;/span&gt;
      &lt;span class="c1"&gt;# com.centurylinklabs.watchtower.enable=true (i.e. jo4-api).&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_LABEL_ENABLE=true&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the app service we want it to actually watch:&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="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&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;ghcr.io/${GITHUB_REPOSITORY:-rathnasorg/jo4}/alertstage-api:${APP_VERSION:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;labels&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;com.centurylinklabs.watchtower.enable=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Pin Watchtower's own image
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;image: nickfedor/watchtower:1.17.0&lt;/code&gt;. Not &lt;code&gt;:latest&lt;/code&gt;. Not &lt;code&gt;:stable&lt;/code&gt;. A specific tag we wrote down on purpose.&lt;/p&gt;

&lt;p&gt;This single change makes the May 19 sequence impossible. When Watchtower asks "is there a newer image for &lt;code&gt;nickfedor/watchtower:1.17.0&lt;/code&gt;?" the answer is, by definition, no. The only way Watchtower gets a new image is when &lt;em&gt;we&lt;/em&gt; edit this file and bump the version — a human in the loop who can verify the new container actually came up.&lt;/p&gt;

&lt;p&gt;(We also moved off &lt;code&gt;containrrr/watchtower&lt;/code&gt; while here — that repo is archived and broke on Docker 24+. &lt;code&gt;nickfedor/watchtower&lt;/code&gt; is the maintained fork.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Opt-in label mode
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;WATCHTOWER_LABEL_ENABLE=true&lt;/code&gt; flips Watchtower from "watch every container on the host" to "watch only containers carrying the label &lt;code&gt;com.centurylinklabs.watchtower.enable=true&lt;/code&gt;." We add that label to &lt;code&gt;jo4-api&lt;/code&gt;. We do &lt;em&gt;not&lt;/em&gt; add it to &lt;code&gt;jo4-watchtower&lt;/code&gt;. The Watchtower container is now structurally invisible to itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Both Layers Matter
&lt;/h2&gt;

&lt;p&gt;You could argue either layer alone is enough. Pin the image, no more updates, no more deadlock. Or use label-mode, Watchtower can't pick itself up regardless of tag. Pick one and move on.&lt;/p&gt;

&lt;p&gt;We didn't, because each layer protects against a different human error six months from now.&lt;/p&gt;

&lt;p&gt;Layer 1 fails the day somebody — maybe one of us, on autopilot — edits the compose file and changes &lt;code&gt;1.17.0&lt;/code&gt; back to &lt;code&gt;:latest&lt;/code&gt;. It's the kind of "cleanup" that looks reasonable in a PR diff. If layer 2 isn't there, that edit reintroduces the exact bug.&lt;/p&gt;

&lt;p&gt;Layer 2 fails the day somebody adds the &lt;code&gt;watchtower.enable=true&lt;/code&gt; label to the Watchtower container itself — maybe they're labelling everything for an inventory script, maybe a tutorial says "label all your containers." If layer 1 isn't there, Watchtower starts watching itself again.&lt;/p&gt;

&lt;p&gt;Either failure alone is a foot. Both simultaneously, on the same PR, by the same engineer, is a foot wrapped in a kevlar boot. Defense in depth is cheap when the depth is two YAML lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons For Any Auto-Update Tool
&lt;/h2&gt;

&lt;p&gt;The Watchtower-specific story generalizes. Anything that auto-updates itself — your dependency bot, your CI runner image, your sidecar agent, your secrets-rotator — has the same shape of bug latent in it. Some patterns we now apply across the board:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin the version of anything that can mutate itself.&lt;/strong&gt; Auto-update tools should not auto-update. Their version belongs in source control where a human has to type the new digit. &lt;code&gt;:latest&lt;/code&gt; is for things being watched, not for things doing the watching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer opt-in over opt-out for blast radius.&lt;/strong&gt; Default-watch-everything tools sweep up the watcher itself, your debug containers, your one-off &lt;code&gt;docker run&lt;/code&gt; experiments. Opt-in lists are slightly more verbose and dramatically harder to misfire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt; is not a safety net for orchestration bugs.&lt;/strong&gt; It catches crashes. It does not catch "I stopped myself on purpose and then died." If something in your stack has the authority to stop itself, give it a watchdog or accept it will walk off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent CD failure is the worst kind.&lt;/strong&gt; The API was up the whole time. Health checks green. Monitoring happy. The only symptom was "fixes don't reach production," which nobody catches until they go looking. Add an alert on "no successful deploy in N hours" — it would have paged us at hour 13 instead of hour 48.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the comment you'd want six months from now.&lt;/strong&gt; The block we added to &lt;code&gt;docker-compose.yml&lt;/code&gt; is the entire postmortem in five sentences. The next operator who looks at that file knows exactly why the version is pinned and what happens if they unpin it. More durable than any wiki page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footgun isn't gone — we still trust an external tool to mutate our running containers. But the specific path where the trust loops back on itself is closed off twice. That's enough.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by a self-updating tool?&lt;/strong&gt; What recovered you? Drop the war story in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>Scrape Prometheus Over the VPC, Not Through Cloudflare</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 14 Sep 2026 05:58:35 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/scrape-prometheus-over-the-vpc-not-through-cloudflare-1c7i</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/scrape-prometheus-over-the-vpc-not-through-cloudflare-1c7i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/prometheus-vpc-vs-cloudflare-scrape/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We built three layers of defense for our Prometheus scrape. Two of them were unnecessary.&lt;/p&gt;

&lt;p&gt;This isn't a "we found a bug" post. It's a "we designed the wrong architecture, then corrected it" post. The system worked. It was just doing far more than it needed to. Sharing it because the wrong path was the obvious-looking one, and the right path was sitting right there in our DO project the entire time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Original Setup
&lt;/h2&gt;

&lt;p&gt;We have two droplets in production: &lt;code&gt;jo4-server&lt;/code&gt; (Spring Boot API behind nginx) and &lt;code&gt;jo4-impress&lt;/code&gt; (Prometheus + Grafana + Loki, our internal observability box). When we stood up Prometheus, the question was: how should it scrape &lt;code&gt;/actuator/prometheus&lt;/code&gt; on the API?&lt;/p&gt;

&lt;p&gt;The obvious answer — and the one we went with — was "the same way every other request reaches the API." Which means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jo4-impress → Cloudflare edge → DO firewall → nginx → Spring Boot
              (WAF rule)        (CF CIDRs only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three defense layers stacked on the public path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Cloudflare WAF rule&lt;/strong&gt; restricting &lt;code&gt;/actuator/*&lt;/code&gt; to a tiny allowlist (our impress droplet's egress IP plus a couple of operator IPs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An OAuth2 client_credentials grant&lt;/strong&gt; (RFC 6749 §4.4) on the actuator endpoints, scope &lt;code&gt;metrics:read&lt;/code&gt;, enforced at the Spring Security layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A DigitalOcean cloud firewall&lt;/strong&gt; on &lt;code&gt;jo4-server&lt;/code&gt; restricting inbound &lt;code&gt;:443&lt;/code&gt; to Cloudflare's published CIDR ranges, so nobody can bypass the WAF by hitting the origin directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It worked. The scrape ran every 30 seconds, dashboards lit up, alerts fired. We patted ourselves on the back for being thorough.&lt;/p&gt;

&lt;p&gt;But every scrape was: TLS handshake with Cloudflare, WAF evaluation, second TLS handshake with nginx, OAuth token mint (cached, but still), HTTP round-trip back through the same chain. All to move ~80 KB of metrics between two boxes that were already neighbors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Realization
&lt;/h2&gt;

&lt;p&gt;About a month in, I was running unrelated &lt;code&gt;doctl&lt;/code&gt; commands and noticed both droplets in the same row of output:&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="nv"&gt;$ &lt;/span&gt;doctl compute droplet list &lt;span class="nt"&gt;--format&lt;/span&gt; Name,PrivateIPv4,Region
ID           Name           Private IPv4    Region
123456789    jo4-server     10.108.0.3      nyc3
987654321    jo4-impress    10.108.0.5      nyc3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same region. Adjacent private IPs in the same &lt;code&gt;/16&lt;/code&gt;. They had been in the same VPC the entire time — &lt;code&gt;10.108.0.0/16&lt;/code&gt;, the default DO VPC for nyc3.&lt;/p&gt;

&lt;p&gt;DigitalOcean droplets in the same VPC can reach each other on private interfaces with no public exposure, no firewall hops, no encryption-in-transit requirement (the VPC is a private L2 segment). Cloudflare didn't need to be on the path at all. The DO firewall's CF-CIDR rule was protecting traffic that didn't have to traverse the public internet in the first place.&lt;/p&gt;

&lt;p&gt;I had built a public path to talk between two machines on the same private network. That's the kind of thing you laugh at in someone else's design review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Simpler Architecture
&lt;/h2&gt;

&lt;p&gt;The corrected topology:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jo4-impress (10.108.0.5) ──VPC──► jo4-server (10.108.0.3:8080)
                                  └─► OAuth still enforced at app layer
                                  └─► DO firewall: tcp/8080 from tag:impress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hop. No Cloudflare. No TLS termination. No nginx. Spring Boot listens on &lt;code&gt;:8080&lt;/code&gt; (it already did, for the nginx upstream), and Prometheus connects directly via the private IP.&lt;/p&gt;

&lt;p&gt;The crucial check before pulling this trigger: &lt;strong&gt;does the OAuth gate still apply when the request arrives via the private interface, or was it only enforced because Cloudflare's WAF was upstream?&lt;/strong&gt; This is the kind of question where "I'm pretty sure" gets you fired. We verified by hand:&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;# From inside jo4-impress, hit the private IP with no bearer:&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://10.108.0.3:8080/actuator/prometheus
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good. Spring Security doesn't care which interface the request came in on — the filter chain runs the same way for private and public traffic. The OAuth requirement is a property of the endpoint, not of the network path. The WAF rule had been belt-and-braces; the actual auth boundary was always at the app.&lt;/p&gt;

&lt;p&gt;Token minting moved to the private IP too, since the token endpoint lives on the same Spring Boot app:&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="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://10.108.0.3:8080/oauth/token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Diff
&lt;/h2&gt;

&lt;p&gt;The whole change is in &lt;code&gt;prometheus.yml&lt;/code&gt;. Before:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-api&lt;/span&gt;
  &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/prometheus&lt;/span&gt;
  &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
  &lt;span class="na"&gt;oauth2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__PROMETHEUS_OAUTH_CLIENT_ID__&lt;/span&gt;
    &lt;span class="na"&gt;client_secret_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/prometheus/oauth-client-secret&lt;/span&gt;
    &lt;span class="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://jo4-api.jo4.io/oauth/token&lt;/span&gt;
    &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;metrics:read&lt;/span&gt;
  &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jo4-api.jo4.io'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-api&lt;/span&gt;
  &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/prometheus&lt;/span&gt;
  &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="na"&gt;oauth2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__PROMETHEUS_OAUTH_CLIENT_ID__&lt;/span&gt;
    &lt;span class="na"&gt;client_secret_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/prometheus/oauth-client-secret&lt;/span&gt;
    &lt;span class="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://10.108.0.3:8080/oauth/token&lt;/span&gt;
    &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;metrics:read&lt;/span&gt;
  &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.108.0.3:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines changed: &lt;code&gt;scheme&lt;/code&gt;, &lt;code&gt;token_url&lt;/code&gt;, and the target. The OAuth block is identical because the auth gate is identical — same client, same secret, same scope. Prometheus didn't know or care that the underlying network had been simplified out from under it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;http&lt;/code&gt; instead of &lt;code&gt;https&lt;/code&gt; looks scary written down, but it's only scary if you treat the VPC like the public internet. DO's VPC is a private network segment between droplets in the same project and region; the threat model that justifies TLS on the open web (passive observers, route hijacks, captive portals) doesn't apply. We still terminate TLS at Cloudflare for everything that reaches users — this just isn't one of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stays as Defense-In-Depth
&lt;/h2&gt;

&lt;p&gt;We didn't strip the auth gate. Three layers remain, but now they're actually layered on top of a sound primary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary boundary: VPC isolation.&lt;/strong&gt; The scrape traffic physically cannot leave DO's nyc3 private network. There is no public path for this byte stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-layer OAuth.&lt;/strong&gt; Same &lt;code&gt;metrics:read&lt;/code&gt; scope, same client_credentials grant. If somehow another droplet ended up in the VPC and tried to scrape, it would get a 401 without the bearer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DO cloud firewall, tag-based.&lt;/strong&gt; On &lt;code&gt;jo4-server&lt;/code&gt;, we added &lt;code&gt;tcp/8080 inbound from tag:impress&lt;/code&gt;. Tag-based rules follow droplets through rebuilds and IP changes — no hardcoded CIDR maintenance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAF rule + public exposure on &lt;code&gt;:443&lt;/code&gt;.&lt;/strong&gt; Kept for now. Not reached in practice (Prometheus doesn't hit it anymore), but the public actuator path still exists for emergency operator access. We'll probably retire it next quarter once we're confident nothing else depends on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't "fewer layers" for its own sake. The point is each remaining layer is on the actual path and earns its keep. Two of the original three were guarding against attacks on a public surface that didn't need to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the topology before the rules.&lt;/strong&gt; I added three security layers to a public path before asking whether the path needed to be public. The DO VPC was a one-line &lt;code&gt;doctl&lt;/code&gt; query away. I just never thought to look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense-in-depth is good. Defense-on-the-wrong-network-path is overhead.&lt;/strong&gt; Every layer has a cost — config, latency, debuggability, things that break during outages. Layers should defend something real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the auth boundary is at the app, not the edge.&lt;/strong&gt; Before deleting the public path, we curled the private IP without a bearer and confirmed the 401. If Spring Security had been relying on a Cloudflare-injected header (it wasn't, but it could have been), this refactor would have silently disabled our auth gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag-based firewall rules &amp;gt; IP-based rules.&lt;/strong&gt; &lt;code&gt;tcp/8080 from tag:impress&lt;/code&gt; survives droplet rebuilds. &lt;code&gt;tcp/8080 from 10.108.0.5/32&lt;/code&gt; survives until the next time the droplet's IP changes and you forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be honest in the comments.&lt;/strong&gt; The &lt;code&gt;prometheus.yml&lt;/code&gt; comment block now narrates the actual topology and the actual primary boundary (VPC), with the auxiliary layers labeled as such. Future-me reading this in two years will know what's load-bearing and what's belt-and-braces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's fine to ship the wrong architecture and fix it.&lt;/strong&gt; The wrong one worked. We had metrics, we had alerts. Nothing was broken. But "works" and "right" aren't the same thing, and shipping the simpler version when you notice the gap is a win, not an embarrassment.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;When have you over-engineered a network path?&lt;/strong&gt; What pushed you back to simpler? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>devops</category>
      <category>architecture</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Why Your Grafana 5xx-Rate Alert Spams DatasourceNoData Every 4 Hours</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:45:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-your-grafana-5xx-rate-alert-spams-datasourcenodata-every-4-hours-4fk9</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-your-grafana-5xx-rate-alert-spams-datasourcenodata-every-4-hours-4fk9</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>grafana</category>
      <category>monitoring</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Self-Healing Cloudflare CIDRs in DigitalOcean Firewalls</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 09 Sep 2026 05:50:35 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/self-healing-cloudflare-cidrs-in-digitalocean-firewalls-45bo</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/self-healing-cloudflare-cidrs-in-digitalocean-firewalls-45bo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/self-healing-cloudflare-cidrs-do-firewall/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Go grep your infra repo for &lt;code&gt;173.245.48.0/20&lt;/code&gt;. If that string is in a firewall rule, a Terraform module, or a &lt;code&gt;doctl&lt;/code&gt; invocation, you have a time bomb. Cloudflare publishes the canonical list of edge IPs at two URLs, and they change. Not often — but often enough that a hardcoded copy quietly goes stale, and on the day a new CIDR ships, a slice of your real visitors gets dropped at the origin firewall with no log entry that obviously means "we're filtering them out."&lt;/p&gt;

&lt;p&gt;We had this problem on the DigitalOcean firewall in front of our self-hosted monitoring droplet. The fix isn't complicated, but it has to be wired through the deploy step that actually applies the firewall, not a sidecar cron. Here's the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Hardcoded CIDRs Go Stale
&lt;/h2&gt;

&lt;p&gt;Cloudflare's list at &lt;a href="https://www.cloudflare.com/ips-v4" rel="noopener noreferrer"&gt;&lt;code&gt;https://www.cloudflare.com/ips-v4&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://www.cloudflare.com/ips-v6" rel="noopener noreferrer"&gt;&lt;code&gt;https://www.cloudflare.com/ips-v6&lt;/code&gt;&lt;/a&gt; is the source of truth for which edge IPs may legitimately terminate TLS for your zone. They publish about 15 v4 ranges and 7 v6 ranges at the time of writing. That list is stable for long stretches and then changes — a new range gets added when CF expands a POP, an old block gets retired in a network migration, sometimes a chunk shifts to a different aggregation prefix.&lt;/p&gt;

&lt;p&gt;The standard hardcoded approach looks fine the day you ship it and decays silently. Six months in, somebody adds you to a new CF range, your DO firewall says "not on the list," and the request never makes it past the origin firewall. From the user's perspective: random subset of traffic, no useful error, retries sometimes work (because they hit a different CF edge on the way back), nothing in your application logs because the packet never reached your app. This is the worst class of bug — invisible to your observability stack because your observability stack is downstream of the failure.&lt;/p&gt;

&lt;p&gt;The right fix is to make drift impossible by construction. Fetch the list live every time you write the firewall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow Step
&lt;/h2&gt;

&lt;p&gt;This is the actual step from our &lt;code&gt;DigitalOcean-490-Impress-Bootstrap&lt;/code&gt; workflow. It runs every time we bootstrap or re-bootstrap the monitoring droplet, and the firewall rules are reconciled atomically against whatever Cloudflare published thirty seconds ago.&lt;/p&gt;

&lt;p&gt;First the SSH base rule, sourced from a GitHub variable that holds operator home IPs:&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="nv"&gt;FIREWALL_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"jo4-impress-firewall"&lt;/span&gt;
&lt;span class="nv"&gt;DROPLET_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;doctl compute droplet list &lt;span class="nt"&gt;--format&lt;/span&gt; Name,ID &lt;span class="nt"&gt;--no-header&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DROPLET_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$1==n {print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Build base tcp/22 rule from operator allow-list (GH variable).&lt;/span&gt;
&lt;span class="c"&gt;# Format `1.2.3.4/32,5.6.7.8/32` → `address:1.2.3.4/32,address:5.6.7.8/32`.&lt;/span&gt;
&lt;span class="nv"&gt;OPERATOR_SSH&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;$HOME_IPS_FOR_SSH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
              | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^/address:/'&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&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="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OPERATOR_SSH&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;"::error::HOME_IPS_FOR_SSH parsed to empty — refusing to deploy a no-SSH firewall (would lock everyone out)"&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="nv"&gt;INBOUND_22&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"protocol:tcp,ports:22,&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OPERATOR_SSH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the live Cloudflare fetch and sanity check:&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;# Fetch live Cloudflare CIDRs for tcp/443. Fail loudly if either&lt;/span&gt;
&lt;span class="c"&gt;# endpoint is unreachable — we'd rather block this workflow run&lt;/span&gt;
&lt;span class="c"&gt;# than silently apply a half-empty allow-list.&lt;/span&gt;
&lt;span class="nv"&gt;CF_V4&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://www.cloudflare.com/ips-v4&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CF_V6&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://www.cloudflare.com/ips-v6&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="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF_V4&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF_V6&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;"::error::Cloudflare published-IP fetch returned empty"&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="nv"&gt;V4_COUNT&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;$CF_V4&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; .&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;V6_COUNT&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;$CF_V6&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&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="nv"&gt;$V4_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 10 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$V6_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 5 &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;"::error::Cloudflare CIDR fetch implausibly small: v4=&lt;/span&gt;&lt;span class="nv"&gt;$V4_COUNT&lt;/span&gt;&lt;span class="s2"&gt; v6=&lt;/span&gt;&lt;span class="nv"&gt;$V6_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&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&lt;/span&gt; &lt;span class="s2"&gt;"📡 Cloudflare published &lt;/span&gt;&lt;span class="nv"&gt;$V4_COUNT&lt;/span&gt;&lt;span class="s2"&gt; v4 + &lt;/span&gt;&lt;span class="nv"&gt;$V6_COUNT&lt;/span&gt;&lt;span class="s2"&gt; v6 ranges"&lt;/span&gt;
&lt;span class="nv"&gt;INBOUND_443&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF_V4&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF_V6&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
              | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^/address:/'&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&lt;/span&gt;, -&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;INBOUND_443&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"protocol:tcp,ports:443,&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INBOUND_443&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;curl&lt;/code&gt; calls. A line count per family. A floor. Then a single comma-joined rule string in the format &lt;code&gt;doctl&lt;/code&gt; wants. Five seconds of work in the runner, and the rule we're about to ship is sourced from CF's live publication rather than from somebody's memory of CF's publication on the day they wrote the workflow.&lt;/p&gt;

&lt;p&gt;Finally the atomic apply:&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;# Always reconcile (cheap, idempotent). `firewall update` is&lt;/span&gt;
&lt;span class="c"&gt;# atomic PUT — replaces all rules in one call, no remove-then-add&lt;/span&gt;
&lt;span class="c"&gt;# gap. This also wipes any stale runner-IP rule from a prior&lt;/span&gt;
&lt;span class="c"&gt;# run whose cleanup step failed.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"♻️  Reconciling rules on existing &lt;/span&gt;&lt;span class="nv"&gt;$FIREWALL_NAME&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$FIREWALL_ID&lt;/span&gt;&lt;span class="s2"&gt;)..."&lt;/span&gt;
doctl compute firewall update &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FIREWALL_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FIREWALL_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--inbound-rules&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOUND_22&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$INBOUND_443&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--outbound-rules&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUTBOUND&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--droplet-ids&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DROPLET_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Every run, every bootstrap, every redeploy: the firewall converges on &lt;code&gt;whatever CF published just now&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sanity Floor
&lt;/h2&gt;

&lt;p&gt;The two-line floor is the only piece of this that needs defending, because it looks defensive-in-a-way-that-might-be-paranoid:&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="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="nv"&gt;$V4_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 10 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$V6_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 5 &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;"::error::Cloudflare CIDR fetch implausibly small: v4=&lt;/span&gt;&lt;span class="nv"&gt;$V4_COUNT&lt;/span&gt;&lt;span class="s2"&gt; v6=&lt;/span&gt;&lt;span class="nv"&gt;$V6_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We know CF publishes ~15 v4 and ~7 v6 ranges. The floor of 10/5 is "well below the real number, well above zero." The threat model isn't "CF is hacked and publishes a tiny list" — it's much more pedestrian:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A transient blip at &lt;code&gt;cloudflare.com&lt;/code&gt; returns a 200 with a partial body. &lt;code&gt;curl -fsSL&lt;/code&gt; is happy because the status was 2xx. The CIDR list comes back truncated.&lt;/li&gt;
&lt;li&gt;A future change to the publication format — a comment header, a JSON wrapper, an HTML maintenance page — survives &lt;code&gt;curl&lt;/code&gt; and starts feeding garbage into our grep-and-sed pipeline.&lt;/li&gt;
&lt;li&gt;A network hiccup mid-stream that delivers some lines and then EOFs cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without the floor, any of those scenarios deploys a firewall with a one- or two-CIDR allow-list. Result: we just locked out 95% of legitimate Cloudflare traffic. The floor catches the "looks like a successful fetch but the content is wrong" failure mode, which is the only mode where this whole system fails dangerously. If CF actually shrinks their published list below 10 v4 ranges, somebody needs to look at it manually anyway, and the workflow failing is the right way to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;doctl firewall update&lt;/code&gt; Is Right
&lt;/h2&gt;

&lt;p&gt;The other detail worth slowing down on is the choice of &lt;code&gt;doctl compute firewall update&lt;/code&gt; over the more obvious-looking &lt;code&gt;remove-rules&lt;/code&gt; + &lt;code&gt;add-rules&lt;/code&gt; pair. The DO API's &lt;code&gt;update&lt;/code&gt; endpoint is an atomic PUT — it replaces the entire rule set in one call. There is no window where the firewall has the old rules removed but the new rules not yet added.&lt;/p&gt;

&lt;p&gt;The remove-then-add pattern has exactly that window. It might be 50ms. It might be 5 seconds if the API is slow. During that window, requests that should have been allowed by the new rules are denied because neither the old nor the new rules are in effect. With a high-traffic origin, "5 seconds of half-denied traffic" is a non-trivial number of failed user requests every time you reconcile.&lt;/p&gt;

&lt;p&gt;The PUT semantics also give us a free side-effect: any stale ephemeral rule from a prior run gets wiped. Our workflow also temporarily adds the runner's public IP to the SSH rule for the duration of the job (so the runner can &lt;code&gt;ssh&lt;/code&gt; into the droplet), then revokes it in an &lt;code&gt;if: always()&lt;/code&gt; cleanup step at job end. If that cleanup step ever fails — &lt;code&gt;doctl&lt;/code&gt; API blip, runner killed mid-step — the stale runner-IP entry would normally accumulate. With atomic PUT reconcile, the next run's firewall update overwrites whatever was there with a fresh rule set built from scratch. Self-healing, no janitor cron needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Replaces
&lt;/h2&gt;

&lt;p&gt;The old pattern is one of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded list in the workflow.&lt;/strong&gt; Decays. No alarm rings the day CF adds a new range. You find out from a customer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate cron that updates the firewall daily.&lt;/strong&gt; Adds a moving part. Has its own credentials, its own failure modes, its own observability gap. The cron breaks, you don't notice, you're back to the hardcoded-and-stale problem with extra complexity on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Terraform module pinned to a CF data source.&lt;/strong&gt; Closer, but only as fresh as your last &lt;code&gt;terraform apply&lt;/code&gt;. If you only re-apply infra changes every few months, you've reintroduced the staleness window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern above sidesteps all of them: the firewall converges on CF's live list every time the bootstrap workflow runs, and the bootstrap workflow runs every time we change anything about the droplet. Drift is bounded by deploy cadence, not by anybody remembering to check.&lt;/p&gt;

&lt;p&gt;There's nothing exotic here — two &lt;code&gt;curl&lt;/code&gt; calls, a sanity check, one atomic &lt;code&gt;doctl&lt;/code&gt; call — but the composition is the point. Live fetch + floor + PUT semantics gives you a firewall that's right by construction, not right by vigilance.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;How do you keep upstream IP allow-lists fresh?&lt;/strong&gt; What's worked for your cloud + edge provider? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>devops</category>
      <category>githubactions</category>
      <category>automation</category>
    </item>
    <item>
      <title>Your Sentry DSN Is a GitHub Variable, Not a Secret</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 07 Sep 2026 05:53:31 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/your-sentry-dsn-is-a-github-variable-not-a-secret-52p9</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/your-sentry-dsn-is-a-github-variable-not-a-secret-52p9</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>githubactions</category>
      <category>security</category>
      <category>sentry</category>
      <category>devops</category>
    </item>
    <item>
      <title>Grafana 11 Provisions Alerts Before Dashboards — and That Breaks folderUid</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:49:41 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/grafana-11-provisions-alerts-before-dashboards-and-that-breaks-folderuid-2bln</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/grafana-11-provisions-alerts-before-dashboards-and-that-breaks-folderuid-2bln</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/grafana-provisioning-folder-uid-collision/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We rolled a tidy little change into our Grafana provisioning: pin the dashboard folder's UID so deep links don't break across redeploys. Restarted the container. Watched it exit 1 with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to create folder for provisioned dashboards" folder=jo4 ... err="a folder with the same name already exists in the current location"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error makes no sense. We hadn't created any folder called &lt;code&gt;jo4&lt;/code&gt; anywhere else. The provisioning files in &lt;code&gt;/etc/grafana/provisioning/dashboards/&lt;/code&gt; are the only place the name appears. So &lt;em&gt;who&lt;/em&gt; created the first one?&lt;/p&gt;

&lt;p&gt;The answer turned out to be: Grafana did. Before our dashboard provider ran. And it's not what the docs imply.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Expected
&lt;/h2&gt;

&lt;p&gt;The Grafana provisioning docs are organized in roughly the order we'd expect a fresh install to come up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Datasources (so dashboards have something to query)&lt;/li&gt;
&lt;li&gt;Dashboards (the things humans look at)&lt;/li&gt;
&lt;li&gt;Alerting (rules that fire against dashboards/queries)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our &lt;code&gt;providers.yaml&lt;/code&gt; set the folder name &lt;em&gt;and&lt;/em&gt; the folder UID, on the reasonable theory that pinning the UID protects external deep links from rotating after a rebuild:&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&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;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folderUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-folder&lt;/span&gt;           &lt;span class="c1"&gt;# &amp;lt;-- our innocent addition&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If dashboards are provisioned before alerting, this is fine. The dashboard provider creates a folder named &lt;code&gt;jo4&lt;/code&gt; with UID &lt;code&gt;jo4-folder&lt;/code&gt;. Alerting provisioning runs later, references &lt;code&gt;folder: jo4&lt;/code&gt; by name, matches the existing one. Everyone goes home.&lt;/p&gt;

&lt;p&gt;That is not what Grafana 11.2.0 actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happens
&lt;/h2&gt;

&lt;p&gt;We spun up a sandbox container, dropped in the exact same provisioning tree, and tailed the logs. Here's the relevant window, milliseconds and all:&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="go"&gt;06:26:57.106  msg="Initialising datasources"
06:26:57.204  msg="starting to provision alerting"
06:26:57.322  msg="finished to provision alerting"
06:26:57.344  msg="starting to provision dashboards"
06:26:57.346  level=error msg="failed to create folder for provisioned dashboards"
              folder=jo4 ... err="a folder with the same name already exists in the current location"
06:26:57.346  msg="Stopped background service" service=*provisioning.ProvisioningServiceImpl
              reason="failed to provision dashboards"
06:26:57.346  level=fatal msg="Server shutdown" reason="..."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read off this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alerting provisions before dashboards.&lt;/strong&gt; It's not even close — alerting starts 140ms before dashboards and finishes 22ms before dashboards even start. This is the opposite of how the docs are organized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The alerting provisioner created the folder.&lt;/strong&gt; Two milliseconds into dashboard provisioning, the error is already firing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole provisioning service stops.&lt;/strong&gt; Not "skip this dashboard provider and continue." It stops everything. The Grafana process then exits 1 because provisioning is a required background service. The container restart-loops.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Once you accept the ordering, the rest falls out cleanly.&lt;/p&gt;

&lt;p&gt;The alerting provisioner takes a &lt;code&gt;folder&lt;/code&gt; field — a &lt;strong&gt;name&lt;/strong&gt;, not a UID. There is no &lt;code&gt;folderUid&lt;/code&gt; field accepted in alerting rule provisioning files. When alerting runs first and sees &lt;code&gt;folder: jo4&lt;/code&gt;, it needs that folder to exist. It doesn't. So Grafana creates it, with a UID it generates on the spot (something like &lt;code&gt;ffn7ba7vmp3i8a&lt;/code&gt; — looks random, is deterministic for this install).&lt;/p&gt;

&lt;p&gt;Now the dashboard provisioner runs. Our &lt;code&gt;providers.yaml&lt;/code&gt; says: "make sure there's a folder named &lt;code&gt;jo4&lt;/code&gt; with UID &lt;code&gt;jo4-folder&lt;/code&gt;." Grafana does the name lookup first, finds an existing folder named &lt;code&gt;jo4&lt;/code&gt; — but its UID is &lt;code&gt;ffn7ba7vmp3i8a&lt;/code&gt;, not &lt;code&gt;jo4-folder&lt;/code&gt;. The provisioner's reconciliation logic decides this is a different folder and tries to &lt;strong&gt;create&lt;/strong&gt; one with the pinned UID. The unique-name constraint on folders fires. Provisioning service stops. Container exits 1.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;folderUid&lt;/code&gt; in the dashboard provider is fundamentally incompatible with also having alerting rules in the same folder — at least under Grafana 11.2.0's current ordering — because alerting &lt;em&gt;will&lt;/em&gt; have created the folder first, with its own auto-generated UID, and your pinned UID can never win the race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;One removed line. The dashboard provider keeps &lt;code&gt;folder: jo4&lt;/code&gt; (the human-readable name) and drops &lt;code&gt;folderUid&lt;/code&gt; entirely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&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;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folderUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-folder&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;disableDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;updateIntervalSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;allowUiUpdates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
      &lt;span class="na"&gt;foldersFromFilesStructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;providers&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;jo4&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
    &lt;span class="na"&gt;disableDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;updateIntervalSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;allowUiUpdates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/grafana/provisioning/dashboards/jo4&lt;/span&gt;
      &lt;span class="na"&gt;foldersFromFilesStructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. With no &lt;code&gt;folderUid&lt;/code&gt;, the dashboard provider does a name-only lookup, finds the folder alerting already created, reuses it, and provisions all the dashboards into it. The container comes up clean.&lt;/p&gt;

&lt;p&gt;We also left a load-bearing comment in &lt;code&gt;providers.yaml&lt;/code&gt; so the next person who sees a dangling UID in deep links and thinks "I'll just pin this" gets the full story before they break the deploy:&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;# IMPORTANT: do NOT set `folderUid`. Grafana 11.2.0 provisioning order is&lt;/span&gt;
&lt;span class="c1"&gt;# datasources → alerting → dashboards (verified empirically). Alerting&lt;/span&gt;
&lt;span class="c1"&gt;# provisioning auto-creates the folder by name (only field accepted) with a&lt;/span&gt;
&lt;span class="c1"&gt;# Grafana-generated UID. If we then pin a different folderUid here, Grafana&lt;/span&gt;
&lt;span class="c1"&gt;# tries to create a SECOND folder with the same name, errors, stops the&lt;/span&gt;
&lt;span class="c1"&gt;# provisioning service, and the container exits 1.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying It Worked
&lt;/h2&gt;

&lt;p&gt;Re-running the sandbox after removing &lt;code&gt;folderUid&lt;/code&gt;, here's the timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"starting to provision alerting"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"finished to provision alerting"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"starting to provision dashboards"&lt;/span&gt;
&lt;span class="py"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"finished to provision dashboards"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error. Process stays up. Then we hit the Grafana API to see who owns the folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:admin http://localhost:3000/api/folders | jq &lt;span class="s1"&gt;'.[] | {title, uid}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jo4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ffn7ba7vmp3i8a"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the dashboards inside that folder all report &lt;code&gt;folderUid: ffn7ba7vmp3i8a&lt;/code&gt;. So do the alerting rule groups. One folder, one UID, both provisioners pointing at it. Exactly what we wanted — we just don't get to choose the UID.&lt;/p&gt;

&lt;p&gt;If you genuinely need a stable folder UID for deep links (we don't, but you might), the only path we've found that works is: let alerting auto-create it, read the resulting UID back via the API once after the first install, and codify it as an external constant. Don't try to pin it through &lt;code&gt;providers.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documentation ordering is not runtime ordering.&lt;/strong&gt; The Grafana docs describe provisioning subsystems in the order you'd intuitively initialize them, but the actual startup sequence in 11.2.0 is datasources → alerting → dashboards. If something cross-references between subsystems, find out who runs first the empirical way — read the logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A unique-name constraint plus two creators is always a race.&lt;/strong&gt; Whenever two independent provisioners can both produce the "same" named resource and only one of them lets you specify the UID, the one without UID control wins by going first. Your pinned UID has no path to victory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provisioning service failure = container failure.&lt;/strong&gt; Grafana doesn't gracefully degrade when one provisioning file is bad. The whole service stops and the process exits. Treat your provisioning YAML with the same rigor as a database migration: it can take down the system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;folderUid&lt;/code&gt; in a dashboard provider is a foot-gun the moment you also have alerting in the same folder.&lt;/strong&gt; Drop it. Let the folder be created by whoever runs first and look it up by name everywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the comment.&lt;/strong&gt; Future-you, or the next operator, will look at the removed &lt;code&gt;folderUid&lt;/code&gt; and wonder why. A five-line comment in the YAML is cheaper than another two-hour debugging session.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Got bit by Grafana provisioning ordering?&lt;/strong&gt; What was your symptom? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>monitoring</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your sed -i Edit Isn't Reaching the Container: The Bind-Mount Inode Trap</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:35:45 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/your-sed-i-edit-isnt-reaching-the-container-the-bind-mount-inode-trap-4ip6</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/your-sed-i-edit-isnt-reaching-the-container-the-bind-mount-inode-trap-4ip6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/docker-bind-mount-sed-i-inode-trap/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We tweaked a scrape config in our Prometheus YAML, ran &lt;code&gt;sed -i&lt;/code&gt; on the host, confirmed the file on disk had the new content, and waited for Prometheus to pick it up. It didn't. An hour later we were staring at a &lt;code&gt;stat&lt;/code&gt; output that explained everything we'd ever misunderstood about bind mounts. If you've ever edited a file Docker mounted into a container and watched the container act like nothing happened, this post is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Our Prometheus container has its config bind-mounted from the host:&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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&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;prom/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/impress/prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We needed to add a scrape target. Easy:&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="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/scrape_interval: 15s/scrape_interval: 30s/'&lt;/span&gt; /opt/impress/prometheus.yml
docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-HUP&lt;/span&gt; 1   &lt;span class="c"&gt;# signal Prometheus to reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host file looked correct. Prometheus's &lt;code&gt;/-/reload&lt;/code&gt; endpoint returned 200. But the runtime config in the UI still showed &lt;code&gt;15s&lt;/code&gt;. We re-ran the SIGHUP. Same. We checked Prometheus logs — it said it had reloaded the config. From its own file. Which apparently still said &lt;code&gt;15s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the moment we stopped trusting our assumptions and asked the container what it was actually seeing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: &lt;code&gt;stat&lt;/code&gt; Tells the Truth
&lt;/h2&gt;

&lt;p&gt;We compared the two views of "the same file":&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;# On the host&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;md5sum&lt;/span&gt; /opt/impress/prometheus.yml
a3f9c1e8...  /opt/impress/prometheus.yml

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; /opt/impress/prometheus.yml
  File: /opt/impress/prometheus.yml
  Size: 2847       Blocks: 8          IO Block: 4096   regular file
Device: fc01h/64513d    Inode: 1835421     Links: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside the container&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;md5sum&lt;/span&gt; /etc/prometheus/prometheus.yml
7b2d4f06...  /etc/prometheus/prometheus.yml

&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;prometheus &lt;span class="nb"&gt;stat&lt;/span&gt; /etc/prometheus/prometheus.yml
  File: /etc/prometheus/prometheus.yml
  Size: 2791       Blocks: 8          IO Block: 4096   regular file
Device: fc01h/64513d    Inode: 1835098     Links: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things jumped out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Different MD5s.&lt;/strong&gt; The container is reading different bytes than the host file contains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Links: 0&lt;/code&gt;&lt;/strong&gt; inside the container. Zero hard links means &lt;strong&gt;no directory entry points at this inode anywhere on the filesystem&lt;/strong&gt;. The file has been unlinked. The container is the only thing keeping the inode alive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Different inode numbers (&lt;code&gt;1835421&lt;/code&gt; on the host, &lt;code&gt;1835098&lt;/code&gt; in the container) confirmed it: these aren't the same file anymore. They were, when the container started. They aren't now. The container is holding open a ghost — an inode that exists only because it still has an open file descriptor pointing at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sed -i Is the Culprit
&lt;/h2&gt;

&lt;p&gt;This is the part of &lt;code&gt;sed -i&lt;/code&gt; nobody reads the man page about. &lt;code&gt;sed -i&lt;/code&gt; does not edit in place. Despite the flag name, it does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the target file for reading.&lt;/li&gt;
&lt;li&gt;Open a temp file in the same directory (&lt;code&gt;sedXYZ&lt;/code&gt; or similar).&lt;/li&gt;
&lt;li&gt;Stream the transformed content to the temp file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rename(2)&lt;/code&gt; the temp file over the target name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;rename(2)&lt;/code&gt; is the killer. The original inode (the one with the old content) is no longer reachable by name, but it doesn't get freed because the container has it open. The directory entry &lt;code&gt;/opt/impress/prometheus.yml&lt;/code&gt; now points at a brand new inode with the new content. On the host that's the same path with new bytes — looks fine. Inside the container, nothing changed: the bind mount was set up at container start against the &lt;em&gt;original inode&lt;/em&gt;, and bind mounts of single files are bound to inodes, not directory entries.&lt;/p&gt;

&lt;p&gt;So the container keeps reading the ghost. Forever. Or until you restart it.&lt;/p&gt;

&lt;p&gt;This is also why &lt;code&gt;Links: 0&lt;/code&gt;. The original directory entry is gone (it now points to the new inode), so the old inode has zero references in the directory tree — but it has one open file descriptor inside the container, so the kernel keeps it alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;There are two correct ways out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: edit in place for real
&lt;/h3&gt;

&lt;p&gt;You want to write into the &lt;em&gt;same&lt;/em&gt; inode, not replace it. &lt;code&gt;cat &amp;gt; file&lt;/code&gt; does exactly that — it opens the existing inode with &lt;code&gt;O_TRUNC&lt;/code&gt; and writes new content. The inode number doesn't change. The bind mount still points at it. The container sees the update immediately:&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;# Build new content somewhere, then truncate-and-write the SAME inode&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/scrape_interval: 15s/scrape_interval: 30s/'&lt;/span&gt; /opt/impress/prometheus.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prom.new
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/prom.new &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /opt/impress/prometheus.yml
&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/prom.new
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm with &lt;code&gt;stat&lt;/code&gt; that the inode number didn't change. If it did, you used a tool that does atomic-rename. Back up and try again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: restart the container
&lt;/h3&gt;

&lt;p&gt;If the file change is paired with anything else (image bump, env-var change, a sibling config edit), just rebuild the container. This is what we did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt; prometheus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--force-recreate&lt;/code&gt; gives us a fully fresh container — new bind mounts against current inodes, plus any env-var or compose-file changes get picked up. Less surgical than &lt;code&gt;cat &amp;gt; file&lt;/code&gt;, but bulletproof when you're not sure what else drifted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose restart prometheus&lt;/code&gt; also works for the inode problem alone, but it won't pick up env-var or compose-file changes — it restarts the existing container in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Have the Same Trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sed -i&lt;/code&gt; is the famous one, but the trap is everywhere atomic-rename is the safe-write idiom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mv newfile oldfile&lt;/code&gt;&lt;/strong&gt; — literally &lt;code&gt;rename(2)&lt;/code&gt;. Same trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vim&lt;/code&gt; and &lt;code&gt;emacs&lt;/code&gt; by default&lt;/strong&gt; — both write to a backup/swap file and rename over the target, for crash safety. Configurable, but the defaults bite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;perl -i -pe '...'&lt;/code&gt;&lt;/strong&gt; — same temp-file-and-rename dance as &lt;code&gt;sed -i&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;awk '...' file &amp;gt; tmp &amp;amp;&amp;amp; mv tmp file&lt;/code&gt;&lt;/strong&gt; — the explicit version of the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most "safe save" code in editors and language tooling&lt;/strong&gt; — &lt;code&gt;gofmt -w&lt;/code&gt;, &lt;code&gt;prettier --write&lt;/code&gt;, &lt;code&gt;black&lt;/code&gt;, you name it. They rename for atomicity, which is &lt;em&gt;correct&lt;/em&gt; for preventing half-written files on crash, and &lt;em&gt;wrong&lt;/em&gt; for bind-mounted single files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that promises atomic writes is doing this. Atomic writes and bind-mounted single files are fundamentally incompatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bulletproof Workflow
&lt;/h2&gt;

&lt;p&gt;After this incident we made two rules for editing bind-mounted config:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefer directory mounts over single-file mounts.&lt;/strong&gt; A bind mount of a directory tracks the directory entries inside it, not specific inodes. Atomic-rename inside that directory works the way you expect. The cost is exposing sibling files to the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a single-file mount is unavoidable, edit with &lt;code&gt;cat &amp;gt; file&lt;/code&gt;.&lt;/strong&gt; Build the new content in a tempfile somewhere outside the mounted path, then truncate-and-write the target. Verify with &lt;code&gt;stat&lt;/code&gt; that the inode number is unchanged.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For our Prometheus case we kept the single-file mount (we don't want other files in &lt;code&gt;/opt/impress&lt;/code&gt; visible to Prometheus) and added a tiny wrapper script that does the cat-redirect dance. No more &lt;code&gt;sed -i&lt;/code&gt; on bind-mounted files. Ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sed -i&lt;/code&gt; is a lie.&lt;/strong&gt; It's not in-place. It's "write a temp file and rename." That &lt;code&gt;rename(2)&lt;/code&gt; creates a new inode and orphans the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts of single files are inode-bound.&lt;/strong&gt; Whatever inode the directory entry pointed to at container-start time is the inode the container will hold forever. Replace that inode on the host and the container keeps the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Links: 0&lt;/code&gt; on a file inside a container is the smoking gun.&lt;/strong&gt; It means the container is the last reference to an inode that's been unlinked from the filesystem. You're looking at a ghost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;md5sum&lt;/code&gt; on both sides of the mount is the fastest diagnostic.&lt;/strong&gt; If host and container disagree, you've hit some flavor of this trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic-rename is correct for safety, wrong for bind mounts.&lt;/strong&gt; Use &lt;code&gt;cat &amp;gt; file&lt;/code&gt; for in-place truncate-and-write, or mount the parent directory instead of the single file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When in doubt, &lt;code&gt;--force-recreate&lt;/code&gt; the container.&lt;/strong&gt; It's the bigger hammer but it's reliable and it picks up env-var changes too.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Ever spent an hour chasing this?&lt;/strong&gt; What was your tell? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why pgrep Can't Detect cloud-init's Apt Races on DigitalOcean</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:51:34 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-pgrep-cant-detect-cloud-inits-apt-races-on-digitalocean-20fd</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-pgrep-cant-detect-cloud-inits-apt-races-on-digitalocean-20fd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cloud-init-vendor-script-apt-lock-race/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've ever written a wait loop that polls &lt;code&gt;pgrep apt-get&lt;/code&gt; before running your own &lt;code&gt;apt-get&lt;/code&gt;, congratulations: you have a race condition. We had one too. On DigitalOcean's Ubuntu 24.04 droplets, our cold-boot setup script would intermittently die on the apt frontend lock — even though we'd "carefully" waited for apt to be idle. Here's why our defensive wait loop was lying to us, and the one line that actually fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We run a small observability droplet — Prometheus + Grafana behind nginx, called &lt;code&gt;jo4-impress&lt;/code&gt;. It's bootstrapped from a GitHub Actions workflow that SCPs &lt;code&gt;impress/do-setup/&lt;/code&gt; to the freshly-provisioned droplet and runs &lt;code&gt;setup-impress.sh&lt;/code&gt;. The script installs Docker via the convenience script (&lt;code&gt;curl -fsSL https://get.docker.com | sh&lt;/code&gt;), then brings up the compose stack.&lt;/p&gt;

&lt;p&gt;Because we knew Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; and the &lt;code&gt;apt-daily&lt;/code&gt; timers fire shortly after boot, we put a pgrep-based wait loop in front of the Docker install. The idea: poll until no &lt;code&gt;apt-get&lt;/code&gt; is running, &lt;em&gt;then&lt;/em&gt; run our own. Belt-and-braces. What could go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;On a fresh droplet, the script intermittently failed with the classic:&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="go"&gt;E: Could not get lock /var/lib/dpkg/lock-frontend.
   It is held by process 2746 (apt-get)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frustrating part: our wait loop reported "no apt-get running" two seconds before this error. We weren't ignoring it. We were &lt;em&gt;checking&lt;/em&gt;. And yet PID 2746 — an &lt;code&gt;apt-get&lt;/code&gt; we never saw — grabbed the lock the instant our &lt;code&gt;curl get.docker.com | sh&lt;/code&gt; invoked its own &lt;code&gt;apt-get update&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the timeline pulled from &lt;code&gt;/var/log/apt/history.log&lt;/code&gt; on the droplet (workflow run 26424379616):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;23:48:13&lt;/td&gt;
&lt;td&gt;Droplet boots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:01–27&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt-get install ca-certificates gnupg curl …&lt;/code&gt; (spawned by &lt;code&gt;install-do-agent&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:35–38&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt-get install do-agent&lt;/code&gt; (still inside &lt;code&gt;install-do-agent&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:42&lt;/td&gt;
&lt;td&gt;Our pgrep wait loop concludes: "no apt-get running, proceeding"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:43&lt;/td&gt;
&lt;td&gt;`curl get.docker.com \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:45&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Lock contention error.&lt;/strong&gt; Holder: PID 2746&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:49:48–49&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;apt-get install droplet-agent&lt;/code&gt; completes (this was PID 2746)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PID 2746 was spawned by &lt;code&gt;install-dotty-agent&lt;/code&gt;, a DigitalOcean cloud-init vendor script. It started in the four-second gap between &lt;code&gt;install-do-agent&lt;/code&gt; finishing and our &lt;code&gt;curl | sh&lt;/code&gt; racing into &lt;code&gt;apt-get update&lt;/code&gt;. Our pgrep loop caught the quiet moment between two vendor scripts and concluded — correctly, in the instant it sampled — that apt was idle. By the time we acted on that conclusion, the next vendor script had already launched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Sources of Contention
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. On a DigitalOcean Ubuntu 24.04 droplet at first boot, there are &lt;strong&gt;two completely independent&lt;/strong&gt; things competing for the apt lock, and most defenses only address one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(1) The systemd timer units.&lt;/strong&gt; These are the ones every "first-boot apt fix" tutorial talks about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;unattended-upgrades.service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apt-daily.service&lt;/code&gt;, &lt;code&gt;apt-daily.timer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apt-daily-upgrade.service&lt;/code&gt;, &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They fire on a randomized schedule shortly after boot. &lt;code&gt;systemctl stop&lt;/code&gt; + &lt;code&gt;systemctl mask&lt;/code&gt; handles them. This is the well-known half of the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(2) cloud-init's &lt;code&gt;modules-final → config-scripts_vendor&lt;/code&gt; stage.&lt;/strong&gt; This is the half nobody warns you about. DigitalOcean ships two vendor scripts that cloud-init runs during this stage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;install-do-agent&lt;/code&gt; — installs the DigitalOcean monitoring agent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;install-dotty-agent&lt;/code&gt; — installs &lt;code&gt;droplet-agent&lt;/code&gt; (the web-console SSH bridge)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both shell out to &lt;code&gt;apt-get install …&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt;. They are not systemd units. They don't appear in &lt;code&gt;systemctl list-units&lt;/code&gt;. Masking the apt timers does precisely nothing to them, because they were never running through those timers in the first place.&lt;/p&gt;

&lt;p&gt;On the run captured above, &lt;code&gt;install-do-agent&lt;/code&gt; ran 23:48:29–23:49:41 (71.8 seconds total) and &lt;code&gt;install-dotty-agent&lt;/code&gt; ran 23:49:41–23:49:53 (12.2 seconds). Between them: a short window where &lt;code&gt;pgrep apt-get&lt;/code&gt; returns empty — but cloud-init is very much still going to launch the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the pgrep Loop Failed
&lt;/h2&gt;

&lt;p&gt;The pgrep approach has a structural flaw, not a tuning flaw. Let's be explicit:&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;# The old wait loop (approximately)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 60&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; pgrep &lt;span class="nt"&gt;-x&lt;/span&gt; apt-get &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&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;"apt is free, proceeding"&lt;/span&gt;
    &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done
&lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce  &lt;span class="c"&gt;# races whatever cloud-init launches next&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check answers one question: &lt;em&gt;is there an apt-get process running **right now&lt;/em&gt;&lt;em&gt;?&lt;/em&gt; That's a point-in-time observation. It tells you nothing about whether something &lt;strong&gt;about to be launched&lt;/strong&gt; will run an &lt;code&gt;apt-get&lt;/code&gt; in the next few seconds.&lt;/p&gt;

&lt;p&gt;Cloud-init's vendor-scripts stage is a sequence. While it's executing the sequence, the gaps between individual &lt;code&gt;apt-get&lt;/code&gt; invocations are normal — script setup, package downloads, post-install hooks — and routinely large enough to make pgrep look clean. The wait loop is sampling a process state when it should be checking a higher-level lifecycle state: "is cloud-init done yet?"&lt;/p&gt;

&lt;p&gt;The systemd-timer half of the problem made this look like a tuning issue ("just sleep longer", "increase the poll count"). It wasn't. No amount of polling helps when the next contender hasn't been spawned yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Cloud-init ships a command that answers the actual question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloud-init status &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It blocks until cloud-init reaches its &lt;code&gt;done&lt;/code&gt; state — vendor scripts inclusive. Once it returns, every &lt;code&gt;install-*-agent&lt;/code&gt; invocation has completed and won't be spawning more apt-gets. We can install Docker without surprise contenders.&lt;/p&gt;

&lt;p&gt;The diff that fixed &lt;code&gt;setup-impress.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- # Wait until any boot-time apt is finished.
- for i in $(seq 1 60); do
-   if ! pgrep -x apt-get &amp;gt;/dev/null; then
-     break
-   fi
-   sleep 5
- done
&lt;/span&gt;&lt;span class="gi"&gt;+ # Defense-in-depth: silence the systemd timer units...
+ APT_UNITS=(
+   unattended-upgrades.service
+   apt-daily.service apt-daily.timer
+   apt-daily-upgrade.service apt-daily-upgrade.timer
+ )
+ systemctl stop "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+ systemctl mask "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+
+ # Load-bearing: block until cloud-init's vendor scripts finish.
+ echo "⏳ Waiting for cloud-init to finish (DO vendor scripts release apt)..."
+ cloud-init status --wait
+
&lt;/span&gt;  echo "📦 Installing Docker via convenience script..."
  curl -fsSL https://get.docker.com | sh
  systemctl enable --now docker
&lt;span class="gi"&gt;+
+ # Lift the masks once Docker is in.
+ systemctl unmask "${APT_UNITS[@]}" 2&amp;gt;/dev/null || true
+ systemctl enable --now apt-daily.timer apt-daily-upgrade.timer 2&amp;gt;/dev/null || true
+ systemctl enable --now unattended-upgrades.service 2&amp;gt;/dev/null || true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First: &lt;code&gt;cloud-init status --wait&lt;/code&gt; is doing the load-bearing work. It's the one line that closes the race. Second: we still mask the apt timers, but only as defense-in-depth against the scenario where cloud-init has finished but a timer fires inside the Docker-install window. We re-enable them at the end so the droplet's normal patching cadence resumes.&lt;/p&gt;

&lt;p&gt;The full context lives in &lt;a href="https://github.com/anandchakru/jo4/blob/main/impress/do-setup/setup-impress.sh" rel="noopener noreferrer"&gt;&lt;code&gt;impress/do-setup/setup-impress.sh&lt;/code&gt;&lt;/a&gt;, commented inline so the next person who reads it doesn't have to re-derive any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pgrep&lt;/code&gt; answers "is X running now?" — not "will X run in the next ten seconds?"&lt;/strong&gt; Any defensive wait built around pgrep has this blind spot. Use it for "wait for &lt;em&gt;this specific&lt;/em&gt; PID to exit", not "wait for &lt;em&gt;a class of process&lt;/em&gt; to be permanently quiet."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Ubuntu 24.04 cloud images, there are two boot-time apt actors, not one.&lt;/strong&gt; Systemd timers and cloud-init vendor scripts run independently. Masking the timers and ignoring cloud-init buys you the silence right up until cloud-init speaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cloud-init status --wait&lt;/code&gt; is the right primitive.&lt;/strong&gt; It encodes the lifecycle question ("is cloud-init done?") that your script actually wants the answer to. No polling, no false positives, no off-by-a-few-seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belt-and-braces is fine; one of them has to actually be load-bearing.&lt;/strong&gt; We kept the systemd masks because they cost us nothing and close a real (if narrower) window. But the masks alone never fixed the bug — &lt;code&gt;cloud-init status --wait&lt;/code&gt; did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When an intermittent bug has a four-second window, the logs from one bad run are worth more than a week of staring at the code.&lt;/strong&gt; &lt;code&gt;/var/log/apt/history.log&lt;/code&gt; on the failing droplet handed us the answer in the form of PID 2746 and an exact second. The fix took ten minutes once we had it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Hit a similar cloud-init-vs-apt race?&lt;/strong&gt; Drop the timeline in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>digitalocean</category>
      <category>devops</category>
      <category>debugging</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
