<?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: Amorizz</title>
    <description>The latest articles on DEV Community by Amorizz (@amorizz).</description>
    <link>https://dev.to/amorizz</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%2F4133898%2Fc3911844-2240-4f75-aca2-028632837215.png</url>
      <title>DEV Community: Amorizz</title>
      <link>https://dev.to/amorizz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amorizz"/>
    <language>en</language>
    <item>
      <title>Sentry caught my Express error. The request still hung forever</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Fri, 16 Oct 2026 20:10:32 +0000</pubDate>
      <link>https://dev.to/amorizz/sentry-caught-my-express-error-the-request-still-hung-forever-32kk</link>
      <guid>https://dev.to/amorizz/sentry-caught-my-express-error-the-request-still-hung-forever-32kk</guid>
      <description>&lt;p&gt;I assumed that with an error SDK installed, an &lt;code&gt;async&lt;/code&gt; Express route that throws would at least give the user a 500. I had never tested it, so I did. The result surprised me.&lt;/p&gt;

&lt;p&gt;The short version: on Express 4, an &lt;code&gt;async&lt;/code&gt; handler that throws after an &lt;code&gt;await&lt;/code&gt; is reported by Sentry, keeps the process alive, and never answers the request. The client just waits.&lt;/p&gt;

&lt;p&gt;Everything below was run on 2026-10-16 on a Linux box with Node 20.19.2, Express 4.22.3, Express 5.3.0 and &lt;code&gt;@sentry/node&lt;/code&gt; 11.8.0. The "Sentry server" is a throwaway 15-line Node script on localhost, so nothing leaves the machine. Outputs are pasted from the runs, with log noise trimmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Express 4 does not look at the promise your &lt;code&gt;async&lt;/code&gt; handler returns. A throw after an &lt;code&gt;await&lt;/code&gt; becomes an unhandled rejection, not a &lt;code&gt;next(err)&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;Without an error SDK, Node 20 kills the process. With &lt;code&gt;@sentry/node&lt;/code&gt; initialised, the process survives, the error is reported, and the request hangs. That is the nasty one, because everything looks healthy.&lt;/li&gt;
&lt;li&gt;Fix on Express 4: wrap the handler and &lt;code&gt;.catch(next)&lt;/code&gt;. Or move to Express 5, where rejected promises go to your error handler on their own. The Express docs describe the Express 5 behaviour in their &lt;a href="https://expressjs.com/en/guide/error-handling.html" rel="noopener noreferrer"&gt;error handling guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The smallest app that reproduces it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://key@127.0.0.1:9999/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync boom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/async&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;async boom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupExpressErrorHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[error middleware]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;handled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two routes that throw. One throws right away. The other throws after a 10 ms &lt;code&gt;await&lt;/code&gt;, which is what any real handler does the moment it calls a database.&lt;/p&gt;

&lt;p&gt;And the "Sentry server", just enough to see what arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sink.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;zlib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zlib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gunzipSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
          &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SINK event:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mech=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mechanism&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mechanism&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test is the same every time: start the app, &lt;code&gt;curl&lt;/code&gt; the sync route, &lt;code&gt;curl&lt;/code&gt; the async route with &lt;code&gt;-m 5&lt;/code&gt;, then &lt;code&gt;curl /ok&lt;/code&gt; to see if the server is still alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run 1: Express 4, no SDK
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /sync  -&amp;gt; http=500 curl_exit=0
GET /async -&amp;gt; http=000 curl_exit=52
GET /ok afterwards -&amp;gt; (curl_exit=7)
process: dead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="nx"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt; &lt;span class="nx"&gt;boom&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/async&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;async boom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
                                                       &lt;span class="o"&gt;^&lt;/span&gt;
&lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;boom&lt;/span&gt;
&lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="nx"&gt;v20&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mf"&gt;19.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sync throw gets a clean 500 from my error middleware. The async one never reaches it. Node 20 treats the unhandled rejection as fatal and the whole process exits. The client sees an empty reply (curl exit 52) and the next request is refused (exit 7).&lt;/p&gt;

&lt;p&gt;That is loud, at least. Your process manager restarts it and you notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run 2: Express 4, with Sentry
&lt;/h2&gt;

&lt;p&gt;Same app, &lt;code&gt;SENTRY=1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /sync  -&amp;gt; http=500 curl_exit=0
GET /async -&amp;gt; http=000 curl_exit=28
GET /ok afterwards -&amp;gt; ok (curl_exit=0)
process: alive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SINK event: Error sync boom  mech= auto.middleware.express
SINK event: Error async boom mech= auto.node.onunhandledrejection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 28 is curl giving up after my 5 second limit. The error was reported. The process did not crash. And the request got nothing.&lt;/p&gt;

&lt;p&gt;Two things I noticed in the output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The sync error came in through &lt;code&gt;auto.middleware.express&lt;/code&gt;, meaning the Express error handler saw it. The async one came in as &lt;code&gt;auto.node.onunhandledrejection&lt;/code&gt;. Sentry caught it, but from the global rejection hook, not from Express.&lt;/li&gt;
&lt;li&gt;The app log shows Node's usual "This error originated either by throwing inside of an async function without a catch block" warning, and then nothing else. The process keeps running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To check this is a real hang and not a slow response, I left one request open for 25 seconds. curl timed out at 25.0 s with no status code, and &lt;code&gt;ss&lt;/code&gt; still showed one established connection on port 3000 twenty seconds in. The socket stays open until the client or a proxy gives up.&lt;/p&gt;

&lt;p&gt;So the SDK did its job. It reported the error and kept my process alive. What it cannot do is write a response for a request Express no longer knows about. In production that "stays up" behaviour is what turns a crash into a silent hang, and the hang is probably cut off by your load balancer's idle timeout, not by anything in your app. I did not test a proxy here, so I can't tell you what yours does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run 3: Express 5
&lt;/h2&gt;

&lt;p&gt;I made a second project with Express 5.3.0 and the same &lt;code&gt;app.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /sync  -&amp;gt; http=500 curl_exit=0
GET /async -&amp;gt; http=500 curl_exit=0
GET /ok afterwards -&amp;gt; ok (curl_exit=0)
process: alive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[error middleware] sync boom
[error middleware] async boom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Sentry on, both events arrive with &lt;code&gt;mech= auto.middleware.express&lt;/code&gt;. The async error goes through my error middleware, the client gets a 500, and nothing hangs.&lt;/p&gt;

&lt;p&gt;This matches what the Express docs say: route handlers that return a promise call &lt;code&gt;next(value)&lt;/code&gt; when they reject, and async functions always return a promise. No code change needed, only the version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix if you are stuck on Express 4
&lt;/h2&gt;

&lt;p&gt;A small wrapper that hands rejections to &lt;code&gt;next&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/async&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;async boom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run on Express 4.22.3 with Sentry initialised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wrapped express4: http=500 time=0.029606s
SINK event: Error async boom mech= auto.middleware.express
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;500 in about 30 ms, and the event now comes through the Express error handler, same as the sync case. Sentry's &lt;code&gt;setupExpressErrorHandler&lt;/code&gt; has to go after your routes and before your own error middleware, which is where it was in my &lt;code&gt;app.js&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check in your app today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which Express version are you on?&lt;/strong&gt; &lt;code&gt;npm ls express&lt;/code&gt;. If it says 4.x, every async handler without a &lt;code&gt;catch&lt;/code&gt; is a hang waiting for a bad day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for handlers that look safe but are not.&lt;/strong&gt; &lt;code&gt;app.get('/x', async (req, res) =&amp;gt; {&lt;/code&gt; with no &lt;code&gt;try&lt;/code&gt; and no wrapper. Also &lt;code&gt;setTimeout&lt;/code&gt; and event emitter callbacks, which Express never sees on any version. The Express docs say to catch those yourself and call &lt;code&gt;next(err)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the error source in your tracker.&lt;/strong&gt; In my run the hung request showed up as an unhandled rejection, not as an Express error. If your tracker has a mechanism or "handled" field, filter for the unhandled rejection ones. Those are the routes without a wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a response timeout in front of the app.&lt;/strong&gt; I did not benchmark this, so I will not give you a number. The point is that a hung request should end with a 504 from your proxy in seconds, not a user closing the tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not turn off the crash to make the noise stop.&lt;/strong&gt; Crashing was the better failure mode in Run 1. A restart loop is easier to spot than a silent hang.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where I run this
&lt;/h2&gt;

&lt;p&gt;I build Epure, a small self-hosted error tracker: one Rust binary and Postgres 16, errors only, and you point the official Sentry SDK at it by swapping the DSN. The results above come from the throwaway sink, not from Epure, so they say nothing about it either way. If you want to see what that DSN swap looks like, the &lt;a href="https://epure.sh/docs?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=express-hang" rel="noopener noreferrer"&gt;docs&lt;/a&gt; have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One Node version (20.19.2), one SDK version. A different &lt;code&gt;@sentry/node&lt;/code&gt; major or Node's &lt;code&gt;--unhandled-rejections&lt;/code&gt; flag could change the hang.&lt;/li&gt;
&lt;li&gt;A single route, tested with &lt;code&gt;curl&lt;/code&gt;. I did not run a real database or a proxy in front.&lt;/li&gt;
&lt;li&gt;I read the Express 5 behaviour from the docs and confirmed it with the run above. I did not read the router source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which Express version is your production API on right now, and does it have a request timeout in front of it?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your .env hit git. Here's how I keep secrets on a VPS</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:31:31 +0000</pubDate>
      <link>https://dev.to/amorizz/your-env-hit-git-heres-how-i-keep-secrets-on-a-vps-3hae</link>
      <guid>https://dev.to/amorizz/your-env-hit-git-heres-how-i-keep-secrets-on-a-vps-3hae</guid>
      <description>&lt;p&gt;I pushed a small Node + Postgres Compose stack to a private repo and only then noticed &lt;code&gt;.env&lt;/code&gt; was in the first commit. &lt;code&gt;DATABASE_URL&lt;/code&gt; had a real password. Adding &lt;code&gt;.gitignore&lt;/code&gt; afterward did nothing useful: Git still had the blob, and &lt;code&gt;git check-ignore&lt;/code&gt; stayed quiet while the path was tracked.&lt;/p&gt;

&lt;p&gt;This is the layout I use on a VPS now. Everything below was run on the box on 2026-10-15 with git 2.47.3, Docker Engine 26.1.5, Compose 2.26.1 and Node 20. Numbers and command output are from those runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt; before the first commit. Commit &lt;code&gt;.env.example&lt;/code&gt; with fake values. If &lt;code&gt;.env&lt;/code&gt; already landed in git, &lt;code&gt;git rm --cached&lt;/code&gt; only stops future commits. The old blob stays until you rewrite history or rotate the secret.&lt;/li&gt;
&lt;li&gt;On the VPS, keep real secrets in a file mode &lt;code&gt;600&lt;/code&gt; (for example &lt;code&gt;secrets.env&lt;/code&gt;) and point Compose at it with &lt;code&gt;env_file&lt;/code&gt;. Do not put &lt;code&gt;${POSTGRES_PASSWORD}&lt;/code&gt; in the YAML if that value comes from a project &lt;code&gt;.env&lt;/code&gt; you might commit.&lt;/li&gt;
&lt;li&gt;Compose interpolates &lt;code&gt;${...}&lt;/code&gt; in the Compose file itself, including inside &lt;code&gt;command:&lt;/code&gt; strings. A JS template literal like &lt;code&gt;`token=${t}`&lt;/code&gt; becomes empty before the container starts. Prefer a script file, or write &lt;code&gt;$$t&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What are you actually protecting?
&lt;/h2&gt;

&lt;p&gt;Three different places can hold the same password. Fixing one does not fix the others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Place&lt;/th&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What fixes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Git history&lt;/td&gt;
&lt;td&gt;Clone, fork, or a future public flip exposes the blob forever&lt;/td&gt;
&lt;td&gt;Never commit it; if you did, rotate and treat history as burned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File on the VPS&lt;/td&gt;
&lt;td&gt;Any local account can read a &lt;code&gt;644&lt;/code&gt; secrets file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chmod 600&lt;/code&gt;, owner = the deploy user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process environment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker compose config&lt;/code&gt;, crash dumps, child processes&lt;/td&gt;
&lt;td&gt;Prefer &lt;code&gt;env_file&lt;/code&gt;; avoid baking secrets into resolved YAML via &lt;code&gt;${}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt; only affects the first column, and only for paths that are not already tracked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does .env end up in git?
&lt;/h2&gt;

&lt;p&gt;I reproduced the failure in a throwaway repo. First commit included &lt;code&gt;.env&lt;/code&gt;. Then I added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env
.env.*
!.env.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that commit, Git still listed &lt;code&gt;.env&lt;/code&gt;:&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;git check-ignore &lt;span class="nt"&gt;-v&lt;/span&gt; .env
&lt;span class="c"&gt;# (no output: still tracked, so ignore rules do not apply)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;git ls-files | &lt;span class="nb"&gt;grep env&lt;/span&gt;
.env
.env.example

&lt;span class="nv"&gt;$ &lt;/span&gt;git show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-list &lt;span class="nt"&gt;--max-parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:.env"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://app:[REDACTED]@db:5432/app
&lt;span class="nv"&gt;EPURE_DSN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://[REDACTED]@ingest.example.com/1
&lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git rm --cached .env&lt;/code&gt; plus a new commit makes &lt;code&gt;check-ignore&lt;/code&gt; work again:&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;git &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;--cached&lt;/span&gt; .env
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"stop tracking .env (too late for history)"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git check-ignore &lt;span class="nt"&gt;-v&lt;/span&gt; .env
.gitignore:1:.env   .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first commit still has the file. &lt;code&gt;git rev-list --objects --all&lt;/code&gt; still showed the &lt;code&gt;.env&lt;/code&gt; blob. Ignore files are not a time machine.&lt;/p&gt;

&lt;p&gt;The correct order is boring and it works. Create &lt;code&gt;.gitignore&lt;/code&gt; and &lt;code&gt;.env.example&lt;/code&gt; first, then add:&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;git status &lt;span class="nt"&gt;--short&lt;/span&gt;
A  .env.example
A  .gitignore
A  app.js
&lt;span class="c"&gt;# .env is absent from the index&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;git check-ignore &lt;span class="nt"&gt;-v&lt;/span&gt; .env
.gitignore:1:.env   .env

&lt;span class="nv"&gt;$ &lt;/span&gt;git ls-files
.env.example
.gitignore
app.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secret string in &lt;code&gt;git grep&lt;/code&gt; across &lt;code&gt;$(git rev-list --all)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes on the VPS?
&lt;/h2&gt;

&lt;p&gt;I keep two files next to the Compose project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.env.example&lt;/code&gt; in git: keys and fake values, so a teammate knows what to create.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;secrets.env&lt;/code&gt; on the host only: real values, mode &lt;code&gt;600&lt;/code&gt;, listed in &lt;code&gt;.gitignore&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&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;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 secrets.env
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%a %n'&lt;/span&gt; secrets.env .env.worldreadable
600 secrets.env
644 .env.worldreadable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the lab box, Python reported &lt;code&gt;other_read=False&lt;/code&gt; for &lt;code&gt;600&lt;/code&gt; and &lt;code&gt;other_read=True&lt;/code&gt; for &lt;code&gt;644&lt;/code&gt;. Same owner can read both. Every other account on the VPS can read the &lt;code&gt;644&lt;/code&gt; copy.&lt;/p&gt;

&lt;p&gt;A minimal systemd unit can load the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/app&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/app/secrets.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node /opt/app/server.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rule: &lt;code&gt;/etc/app/secrets.env&lt;/code&gt; is &lt;code&gt;600&lt;/code&gt;, not in git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose: env_file vs ${PASSWORD} in the YAML
&lt;/h2&gt;

&lt;p&gt;Compose loads a project &lt;code&gt;.env&lt;/code&gt; for &lt;strong&gt;interpolation in the Compose file&lt;/strong&gt;. That is separate from what the container sees as &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bad shape (host &lt;code&gt;.env&lt;/code&gt; supplies &lt;code&gt;${POSTGRES_PASSWORD}&lt;/code&gt;, and the resolved config prints it):&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;# compose.bad.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&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;postgres:16.15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22.23.3-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://app:${POSTGRES_PASSWORD}@db:5432/app&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_SECRET}&lt;/span&gt;
&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="nv"&gt;$ &lt;/span&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.bad.yml config | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'PASSWORD|DATABASE_URL|APP_SECRET'&lt;/span&gt;
      APP_SECRET: from-host-dotenv
      DATABASE_URL: postgres://app:HostInterpolatedSecret@db:5432/app
      POSTGRES_PASSWORD: HostInterpolatedSecret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was &lt;strong&gt;2&lt;/strong&gt; plaintext hits for &lt;code&gt;HostInterpolatedSecret&lt;/code&gt; in the full config dump. Fine for local debugging. Bad to paste into an issue, a CI log, or a screenshot.&lt;/p&gt;

&lt;p&gt;Better shape: put secrets in &lt;code&gt;secrets.env&lt;/code&gt;, mount them with &lt;code&gt;env_file&lt;/code&gt;, and keep the YAML free of &lt;code&gt;${SECRET}&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="c1"&gt;# compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&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;postgres:16.15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secrets.env&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="c1"&gt;# POSTGRES_PASSWORD comes from secrets.env&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22.23.3-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&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;./print-env.js:/app/print-env.js:ro&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secrets.env&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;print-env.js"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// print-env.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POSTGRES_HOST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password_in_process_env&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password_len&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password_equals_envfile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EnvFileOnlySecret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the project &lt;code&gt;.env&lt;/code&gt; renamed away so only &lt;code&gt;secrets.env&lt;/code&gt; could supply the password:&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;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.better.yml run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; app
password_in_process_env &lt;span class="nb"&gt;true
&lt;/span&gt;password_len 17
NODE_ENV production
password_equals_envfile &lt;span class="nb"&gt;true
&lt;/span&gt;password_equals_host_dotenv &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;password_len 17&lt;/code&gt; matches &lt;code&gt;EnvFileOnlySecret&lt;/code&gt;. The host-interpolated value never appeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ${} gotcha inside command: strings
&lt;/h2&gt;

&lt;p&gt;I put a JS template literal in the Compose &lt;code&gt;command&lt;/code&gt; once. Compose warned and emptied it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level=warning msg="The \"t\" variable is not set. Defaulting to a blank string."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolved command became &lt;code&gt;console.log('template', \&lt;/code&gt;token=&lt;code&gt;);&lt;/code&gt; with no &lt;code&gt;t&lt;/code&gt;. The container never saw &lt;code&gt;${t}&lt;/code&gt;. Fix: move the script to a file (as above), or escape as &lt;code&gt;$$t&lt;/code&gt; in the YAML so Compose emits a literal &lt;code&gt;$t&lt;/code&gt; for the shell or Node source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt; after the first commit.&lt;/strong&gt; Tracked paths ignore the ignore file. Run &lt;code&gt;git ls-files .env&lt;/code&gt; and &lt;code&gt;git check-ignore -v .env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git rm --cached&lt;/code&gt; without rotating.&lt;/strong&gt; Future commits are clean. Clones of the old history still have the password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.env.example&lt;/code&gt; missing or filled with real values.&lt;/strong&gt; People copy the wrong file. Keep fake placeholders only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode &lt;code&gt;644&lt;/code&gt; on the VPS secrets file.&lt;/strong&gt; Any local account can read it. Use &lt;code&gt;600&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;${POSTGRES_PASSWORD}&lt;/code&gt; in Compose YAML.&lt;/strong&gt; &lt;code&gt;docker compose config&lt;/code&gt; prints the value. Prefer &lt;code&gt;env_file&lt;/code&gt; and build URLs inside the app from separate env vars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS &lt;code&gt;${...}&lt;/code&gt; inside Compose &lt;code&gt;command:&lt;/code&gt;.&lt;/strong&gt; Compose eats it. Use a file or &lt;code&gt;$$&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming a private GitHub repo is enough.&lt;/strong&gt; Private today can be public tomorrow, or the laptop backup can leak. History still carries the blob.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committing &lt;code&gt;secrets.env&lt;/code&gt; "just for the team".&lt;/strong&gt; That is the same bug with a different filename. Use a password manager, sealed secrets, or a one-time scp onto the VPS.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this shows up for me
&lt;/h2&gt;

&lt;p&gt;The stack I'm building, Epure, is a two-container Compose file (Rust binary + Postgres 16) for exception-only error monitoring. Same rule as above: example env in git, real DSN and DB password only on the host. The product is not APM, replay, or logs. If you self-host it, the &lt;a href="https://epure.sh/docs?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=thursday" rel="noopener noreferrer"&gt;docs&lt;/a&gt; assume you already keep secrets out of the repo.&lt;/p&gt;

&lt;p&gt;After you stop tracking &lt;code&gt;.env&lt;/code&gt;, do you rotate the leaked secret, or only fix the ignore file?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>docker</category>
    </item>
    <item>
      <title>Write a Docker Compose healthcheck that actually checks your app</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Sun, 11 Oct 2026 13:40:44 +0000</pubDate>
      <link>https://dev.to/amorizz/write-a-docker-compose-healthcheck-that-actually-checks-your-app-b59</link>
      <guid>https://dev.to/amorizz/write-a-docker-compose-healthcheck-that-actually-checks-your-app-b59</guid>
      <description>&lt;p&gt;I wrote what looked like a careful Compose file for a small Node + Postgres app: a &lt;code&gt;pg_isready&lt;/code&gt; healthcheck on Postgres and &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; on the app. On a fresh volume the app died with &lt;code&gt;ECONNREFUSED&lt;/code&gt; while &lt;code&gt;docker compose ps&lt;/code&gt; showed the database as &lt;code&gt;(healthy)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I stopped Postgres to see what my readiness endpoint would say, and the Node process crashed instead. Six restarts in 40 seconds. Both bugs came from the same mistake: I was checking that something was running, not that it could do its job.&lt;/p&gt;

&lt;p&gt;This is the setup I ended up with. Everything below was run on Docker Engine 26.1 with Compose 2.26, &lt;code&gt;postgres:16.15-alpine&lt;/code&gt; and &lt;code&gt;node:22.23.3-alpine&lt;/code&gt;, and the outputs are pasted from those runs, trimmed to the relevant lines (&lt;code&gt;ps&lt;/code&gt; cut down to the SERVICE and STATUS columns, Start/End timestamps dropped from &lt;code&gt;inspect&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pg_isready&lt;/code&gt; with no &lt;code&gt;-h&lt;/code&gt; talks to the Unix socket, and during first-boot init the official Postgres image runs a temporary server that only listens on that socket. Add &lt;code&gt;-h 127.0.0.1&lt;/code&gt; so the check uses TCP like your app does.&lt;/li&gt;
&lt;li&gt;Split your app's probes: &lt;code&gt;/healthz&lt;/code&gt; answers "is the process alive", &lt;code&gt;/readyz&lt;/code&gt; answers "can I serve a real request right now" (boot finished, database answers &lt;code&gt;SELECT 1&lt;/code&gt; within 1 second).&lt;/li&gt;
&lt;li&gt;Plain Docker never restarts a container for being &lt;code&gt;unhealthy&lt;/code&gt;. Pick what the healthcheck probes based on what reacts to it in your setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does a healthcheck actually prove?
&lt;/h2&gt;

&lt;p&gt;A healthcheck proves only what its command touches. "Process up", "port open", "app booted" and "dependencies reachable" are four different facts, and most Compose files check the first or second one and assume the rest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;th&gt;What it misses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container is &lt;code&gt;Up&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PID 1 hasn't exited&lt;/td&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port accepts a TCP connection&lt;/td&gt;
&lt;td&gt;Something is listening&lt;/td&gt;
&lt;td&gt;App may still be booting, DB may be down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_isready&lt;/code&gt; (socket)&lt;/td&gt;
&lt;td&gt;A Postgres server answers on the socket&lt;/td&gt;
&lt;td&gt;First-boot temp server, TCP, auth, your database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /healthz&lt;/code&gt; returns 200&lt;/td&gt;
&lt;td&gt;Event loop runs, HTTP stack works&lt;/td&gt;
&lt;td&gt;Database, migrations, config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /readyz&lt;/code&gt; returns 200&lt;/td&gt;
&lt;td&gt;Boot finished and the DB answered &lt;code&gt;SELECT 1&lt;/code&gt; in 1s&lt;/td&gt;
&lt;td&gt;Slow queries, other dependencies you didn't add&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kubernetes splits this into liveness and readiness probes. Docker gives you one &lt;code&gt;healthcheck&lt;/code&gt; per container, so you have to decide which question it answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did pg_isready say healthy too early?
&lt;/h2&gt;

&lt;p&gt;Because it asked the wrong server. The official image's entrypoint runs &lt;code&gt;initdb&lt;/code&gt;, starts a temporary Postgres that listens only on the Unix socket, runs your &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; scripts, then shuts it down and starts the real server on TCP. &lt;code&gt;pg_isready -U app&lt;/code&gt; uses the socket by default, so it reports "accepting connections" while the temp server is up.&lt;/p&gt;

&lt;p&gt;My repro has a slow init script (an 8 second &lt;code&gt;sleep&lt;/code&gt; standing in for a schema load):&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;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# initdb/01-slow-init.sh: stands in for a first boot that does real work&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"init: loading schema (slow on purpose)"&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;8
psql &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POSTGRES_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POSTGRES_DB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE ROLE reporting NOLOGIN;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a Compose file that trusts plain &lt;code&gt;pg_isready&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="c1"&gt;# compose.lie.yml: the version that lies&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&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;postgres:16.15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&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;./initdb:/docker-entrypoint-initdb.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-U"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://app:app@db:5432/app&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&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="nv"&gt;$ &lt;/span&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.lie.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.lie.yml ps &lt;span class="nt"&gt;-a&lt;/span&gt;
SERVICE   STATUS
app       Exited &lt;span class="o"&gt;(&lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt; 3 seconds ago
db        Up 5 seconds &lt;span class="o"&gt;(&lt;/span&gt;healthy&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .State.Health}}'&lt;/span&gt; lab-2026-10-11-healthcheck-db-1
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"Status"&lt;/span&gt;: &lt;span class="s2"&gt;"healthy"&lt;/span&gt;, &lt;span class="s2"&gt;"FailingStreak"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"Log"&lt;/span&gt;: &lt;span class="o"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"ExitCode"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"Output"&lt;/span&gt;: &lt;span class="s2"&gt;"/var/run/postgresql:5432 - accepting connections&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;}]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;Output&lt;/code&gt;: &lt;code&gt;/var/run/postgresql:5432&lt;/code&gt;. That's the socket. The logs show the order (timestamps trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;db-1   13:23:59.918  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db-1   13:24:00.056  init: loading schema (slow on purpose)
app-1  13:24:00.901  listening on :3000
app-1  13:24:00.904  boot failed: ECONNREFUSED
db-1   13:24:08.065  CREATE ROLE
db-1   13:24:08.190  listening on IPv4 address "0.0.0.0", port 5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app gave up about 7 seconds before Postgres listened on TCP. Adding &lt;code&gt;psql -c 'SELECT 1'&lt;/code&gt; over the socket doesn't fix it either. I tried: the temp server answers that too, and the app still got &lt;code&gt;ECONNREFUSED&lt;/code&gt;. The fix is to probe the way your app connects, over TCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compose file that tells the truth
&lt;/h2&gt;

&lt;p&gt;Probe Postgres over TCP, give the app its own healthcheck against &lt;code&gt;/readyz&lt;/code&gt;, and gate startup on both. This is the full file.&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;# compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&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;postgres:16.15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&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;./initdb:/docker-entrypoint-initdb.d:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# -h 127.0.0.1 forces TCP. The first-boot temp server only listens on the socket.&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;psql&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-tAc&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&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;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&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;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./app&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;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://app:app@db:5432/app&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;127.0.0.1:3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-e"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch('http://127.0.0.1:3000/readyz').then(r=&amp;gt;process.exit(r.ok?0:1),()=&amp;gt;process.exit(1))"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
      &lt;span class="na"&gt;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
        &lt;span class="na"&gt;restart&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each knob does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;start_period&lt;/code&gt;: failures in this window don't count toward &lt;code&gt;retries&lt;/code&gt;, but the first success marks the container healthy right away. Size it for your slowest cold boot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start_interval&lt;/code&gt; (Engine 25+): how often to probe during &lt;code&gt;start_period&lt;/code&gt;. 1s here means &lt;code&gt;up --wait&lt;/code&gt; returns as soon as things are ready instead of waiting out a 10s interval.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeout&lt;/code&gt;: kept above the app's own 1s DB timeout, so &lt;code&gt;/readyz&lt;/code&gt; returns a clean 503 before Docker kills the probe.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;restart: true&lt;/code&gt; under &lt;code&gt;depends_on&lt;/code&gt;: Compose restarts the app when &lt;em&gt;you&lt;/em&gt; restart &lt;code&gt;db&lt;/code&gt; with a Compose command. It doesn't fire when the container runtime restarts a crashed &lt;code&gt;db&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The /healthz and /readyz handlers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; should be cheap and depend on nothing, so it only fails when the process itself is broken. &lt;code&gt;/readyz&lt;/code&gt; should fail fast when the app can't serve a real request: boot not finished, or the database not answering within a second.&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"health-lab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"pg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8.13.1"&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js: /healthz says "the process works", /readyz says "I can do real work"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// a hung DB must fail the probe, not hang it&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Without this, a DB restart kills the whole process (unhandled 'error' event).&lt;/span&gt;
&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg idle client error:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;booted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// flips after startup work (migrations, caches) is done&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CREATE TABLE IF NOT EXISTS hits (id serial PRIMARY KEY, at timestamptz DEFAULT now())&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;booted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;boot: migrations done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/healthz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/readyz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;booted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT 1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;query_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO hits DEFAULT VALUES&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listening on :3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Retry boot instead of exiting: stay live (/healthz 200), report not ready (/readyz 503).&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bootLoop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;booted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`boot attempt &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22.23.3-alpine&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="nt"&gt;--no-audit&lt;/span&gt; &lt;span class="nt"&gt;--no-fund&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.js ./&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first version had neither the &lt;code&gt;pool.on("error")&lt;/code&gt; line nor the retry loop. When I stopped Postgres, pg emitted &lt;code&gt;57P01 terminating connection due to administrator command&lt;/code&gt; on an idle client, nothing listened for it, and Node exited. The restart policy brought it back, boot failed because the DB was still down, it exited again: &lt;code&gt;RestartCount&lt;/code&gt; was 6 after 40 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it: start, then break the database
&lt;/h2&gt;

&lt;p&gt;Run it with &lt;code&gt;--wait&lt;/code&gt; so the command only returns once every healthcheck passes. Then stop and pause Postgres and watch what each probe says.&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;&lt;span class="nb"&gt;time &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
 Container lab-2026-10-11-healthcheck-db-1  Healthy
 Container lab-2026-10-11-healthcheck-app-1  Healthy
real    0m14.674s

&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose ps
SERVICE   STATUS
app       Up 1 second &lt;span class="o"&gt;(&lt;/span&gt;healthy&lt;span class="o"&gt;)&lt;/span&gt;
db        Up 11 seconds &lt;span class="o"&gt;(&lt;/span&gt;healthy&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .State.Health}}'&lt;/span&gt; lab-2026-10-11-healthcheck-db-1
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"Status"&lt;/span&gt;: &lt;span class="s2"&gt;"healthy"&lt;/span&gt;, &lt;span class="s2"&gt;"FailingStreak"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"Log"&lt;/span&gt;: &lt;span class="o"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"ExitCode"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"Output"&lt;/span&gt;: &lt;span class="s2"&gt;"127.0.0.1:5432 - accepting connections&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;}]}&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/healthz&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/readyz
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"ready"&lt;/span&gt;,&lt;span class="s2"&gt;"db"&lt;/span&gt;:&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inspect output now says &lt;code&gt;127.0.0.1:5432&lt;/code&gt;, so the check went over TCP. Now take the database away:&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;docker compose stop db
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;' HTTP %{http_code}\n'&lt;/span&gt; localhost:3000/healthz
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; HTTP 200
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;' HTTP %{http_code}\n'&lt;/span&gt; localhost:3000/readyz
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"not_ready"&lt;/span&gt;,&lt;span class="s2"&gt;"db"&lt;/span&gt;:&lt;span class="s2"&gt;"Connection terminated due to connection timeout"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; HTTP 503

&lt;span class="c"&gt;# 40 seconds later&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-a&lt;/span&gt;
SERVICE   STATUS
app       Up 43 seconds &lt;span class="o"&gt;(&lt;/span&gt;unhealthy&lt;span class="o"&gt;)&lt;/span&gt;
db        Exited &lt;span class="o"&gt;(&lt;/span&gt;0&lt;span class="o"&gt;)&lt;/span&gt; 42 seconds ago
&lt;span class="nv"&gt;$ &lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.RestartCount}} {{.State.Health.Status}}'&lt;/span&gt; lab-2026-10-11-healthcheck-app-1
0 unhealthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0 unhealthy&lt;/code&gt; is the line most people don't expect. &lt;code&gt;restart: unless-stopped&lt;/code&gt; reacts to the process exiting. It ignores health. A hung database is a different failure from a stopped one, so pause it too:&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;docker compose start db     &lt;span class="c"&gt;# app goes back to (healthy) on its own&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose pause db
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;time &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;' HTTP %{http_code}\n'&lt;/span&gt; localhost:3000/readyz
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"not_ready"&lt;/span&gt;,&lt;span class="s2"&gt;"db"&lt;/span&gt;:&lt;span class="s2"&gt;"Query read timeout"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; HTTP 503
real    0m1.012s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One second and a 503, instead of a probe that hangs until Docker's timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the Docker healthcheck call /healthz or /readyz?
&lt;/h2&gt;

&lt;p&gt;It depends on what reacts to &lt;code&gt;unhealthy&lt;/code&gt; in your setup. If nothing restarts unhealthy containers, point it at &lt;code&gt;/readyz&lt;/code&gt; so &lt;code&gt;depends_on&lt;/code&gt; and &lt;code&gt;up --wait&lt;/code&gt; mean "ready to serve". If something does restart them, point it at &lt;code&gt;/healthz&lt;/code&gt;, or a database blip turns into every app container restarting at once.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What reacts to &lt;code&gt;unhealthy&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Point the healthcheck at&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing (plain Compose, &lt;code&gt;restart:&lt;/code&gt; policy only)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/readyz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gates &lt;code&gt;depends_on&lt;/code&gt; and &lt;code&gt;up --wait&lt;/code&gt; on real readiness, restarts nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swarm, or an autoheal-style sidecar&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/healthz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A DB outage shouldn't restart healthy app processes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse proxy health checks (Traefik, Caddy, a load balancer)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/readyz&lt;/code&gt; from the proxy&lt;/td&gt;
&lt;td&gt;Pull traffic from a replica that can't serve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy script after &lt;code&gt;docker compose pull&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/readyz&lt;/code&gt; with &lt;code&gt;curl -f&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fail the deploy before you switch traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Failure checklist
&lt;/h2&gt;

&lt;p&gt;When the status and reality disagree, it's usually one of these. I reproduced 1 through 5 and 7 in the lab above.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_isready&lt;/code&gt; with no &lt;code&gt;-h&lt;/code&gt;.&lt;/strong&gt; It checks the socket, so it passes during first-boot init. Use &lt;code&gt;-h 127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl&lt;/code&gt; isn't in the image.&lt;/strong&gt; &lt;code&gt;node:22.23.3-slim&lt;/code&gt; ships neither &lt;code&gt;curl&lt;/code&gt; nor &lt;code&gt;wget&lt;/code&gt;, and the check fails with &lt;code&gt;exec: "curl": executable file not found in $PATH&lt;/code&gt;. Use &lt;code&gt;node -e "fetch(...)"&lt;/code&gt;, or busybox &lt;code&gt;wget&lt;/code&gt; on Alpine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One string in exec form.&lt;/strong&gt; &lt;code&gt;["CMD", "pg_isready -U postgres"]&lt;/code&gt; makes Docker look for a binary literally named &lt;code&gt;pg_isready -U postgres&lt;/code&gt;. Split the arguments, or use &lt;code&gt;CMD-SHELL&lt;/code&gt; when you need &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; or pipes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localhost&lt;/code&gt; resolves to &lt;code&gt;::1&lt;/code&gt;.&lt;/strong&gt; In the Alpine container, &lt;code&gt;localhost&lt;/code&gt; resolved to &lt;code&gt;::1&lt;/code&gt;. My test server listened on &lt;code&gt;0.0.0.0&lt;/code&gt; (IPv4 only), and &lt;code&gt;wget http://localhost:3000/&lt;/code&gt; got &lt;code&gt;Connection refused&lt;/code&gt;. Use &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;pool.on("error")&lt;/code&gt; in Node.&lt;/strong&gt; A Postgres restart kills the process with an unhandled &lt;code&gt;'error'&lt;/code&gt; event, and the restart loop hides it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/readyz&lt;/code&gt; with no timeout.&lt;/strong&gt; Without &lt;code&gt;connectionTimeoutMillis&lt;/code&gt; and &lt;code&gt;query_timeout&lt;/code&gt;, a hung DB makes the probe wait until Docker kills it, and the health log only says &lt;code&gt;Health check exceeded timeout (3s)&lt;/code&gt;. Time out inside the app, below the healthcheck &lt;code&gt;timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting &lt;code&gt;unhealthy&lt;/code&gt; to restart anything.&lt;/strong&gt; It doesn't, in plain Docker. It only feeds &lt;code&gt;depends_on&lt;/code&gt;, &lt;code&gt;--wait&lt;/code&gt;, &lt;code&gt;docker ps&lt;/code&gt; and whatever tool you add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$&lt;/code&gt; in a &lt;code&gt;CMD-SHELL&lt;/code&gt; string.&lt;/strong&gt; Compose interpolates &lt;code&gt;$VAR&lt;/code&gt; before the shell sees it. Write &lt;code&gt;$$POSTGRES_USER&lt;/code&gt; if you want the container's env var.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this runs for me
&lt;/h2&gt;

&lt;p&gt;The stack I'm building, Epure, is a two-container Compose file: one Rust binary and Postgres 16. It does exception-only error monitoring for apps that already use the official Sentry SDKs, no APM, replay or logs. Its &lt;a href="https://epure.sh/docs/guides/production-checklist?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;production checklist&lt;/a&gt; has the same lesson in another form: &lt;code&gt;/health&lt;/code&gt; returns ok over plain HTTP while login loops, because the session cookie is HTTPS-only. A green probe only tells you the process answered.&lt;/p&gt;

&lt;p&gt;The lab is small enough to copy: &lt;code&gt;compose.yml&lt;/code&gt;, &lt;code&gt;server.js&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, the Dockerfile above, and one init script with a &lt;code&gt;sleep 8&lt;/code&gt; in it if you want to see the first-boot race yourself.&lt;/p&gt;

&lt;p&gt;What does your &lt;code&gt;/readyz&lt;/code&gt; check besides the database?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>docker</category>
    </item>
    <item>
      <title>Your sampleRate won't save you from an error flood. Here's what does</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Wed, 07 Oct 2026 20:21:45 +0000</pubDate>
      <link>https://dev.to/amorizz/your-samplerate-wont-save-you-from-an-error-flood-heres-what-does-1dbm</link>
      <guid>https://dev.to/amorizz/your-samplerate-wont-save-you-from-an-error-flood-heres-what-does-1dbm</guid>
      <description>&lt;p&gt;I wrote a flood test for the error tracker I'm building: one bug thrown 997 times in a retry loop, plus three rare bugs thrown once each. In the run that looked most like production, all three rare bugs vanished before they left the Node process.&lt;/p&gt;

&lt;p&gt;Then I tried the fix everyone reaches for, &lt;code&gt;sampleRate: 0.1&lt;/code&gt;. Across five runs, 2 of the 15 rare errors made it. The loop that caused the mess was still the biggest thing on the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sampleRate&lt;/code&gt; drops errors at random. It cuts the noisy loop by 90% and your one-off bugs by the same 90%, so the error you needed is the one most likely to disappear.&lt;/li&gt;
&lt;li&gt;The SDK drops events before sampling even runs: Dedupe eats exact repeats, and a tight burst overflows the 64-slot transport buffer.&lt;/li&gt;
&lt;li&gt;What worked: send everything, then rate limit &lt;strong&gt;stored bodies per fingerprint&lt;/strong&gt; on the server. Count 100% of hits, keep the first 100 stack traces per bug per minute, let every new bug through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why doesn't sampleRate fix an error flood?
&lt;/h2&gt;

&lt;p&gt;Because it doesn't know which error is the flood. &lt;code&gt;sampleRate&lt;/code&gt; is a per-event coin flip in the SDK. At &lt;code&gt;0.1&lt;/code&gt;, every error has a 10% chance of being sent, whether it's the 900th copy of the same &lt;code&gt;TypeError&lt;/code&gt; or the only &lt;code&gt;RangeError&lt;/code&gt; you'll see all week.&lt;/p&gt;

&lt;p&gt;That's fine for volume and bad for signal. A bug that fires once has a 90% chance of never reaching you. The count you do see is off by about 10x, because the server gets roughly 100 events and nothing in them says "multiply me". Client reports tell the server how many events were dropped and why (&lt;code&gt;sample_rate&lt;/code&gt;, &lt;code&gt;queue_overflow&lt;/code&gt;), but not which bug they belonged to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flood lab: one loop, three rare bugs
&lt;/h2&gt;

&lt;p&gt;You can reproduce this in a few minutes with Node 20.19 or newer. No Docker, no account. &lt;code&gt;ingest.js&lt;/code&gt; is a fake endpoint that parses just enough of the Sentry envelope format to count errors per fingerprint. &lt;code&gt;flood.js&lt;/code&gt; uses the real &lt;code&gt;@sentry/node&lt;/code&gt; SDK.&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;mkdir &lt;/span&gt;flood-lab &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;flood-lab
npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
npm i @sentry/node@11.5.0 &lt;span class="nt"&gt;--save-exact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ingest.js&lt;/code&gt;. The fingerprint is the exception type plus the top in-app frame (file, function, line). The valve is a token bucket per fingerprint: 100 tokens, refilled at 100 per minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ingest.js: fake error ingest with a per-fingerprint spike valve&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zlib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:zlib&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VALVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VALVE&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stored bodies per fingerprint per minute&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buckets&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issues&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;allowStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;RATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stacktrace&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;in_app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\0&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineno&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/stats&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()]));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-encoding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gzip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gunzipSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                &lt;span class="c1"&gt;// every hit is counted&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;VALVE&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;allowStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// bodies are rationed&lt;/span&gt;
      &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the client never sees the valve&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ingest on :9000 (valve &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VALVE&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flood.js&lt;/code&gt;. Three env switches: &lt;code&gt;SAMPLE_RATE&lt;/code&gt;, &lt;code&gt;DEDUPE=off&lt;/code&gt; and &lt;code&gt;PACE=off&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// flood.js: one bug stuck in a retry loop, plus three rare bugs you care about&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEDUPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEDUPE&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PACE&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://public@localhost:9000/1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SAMPLE_RATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;DEDUPE&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Dedupe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chargeCard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&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;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cannot read properties of undefined (reading 'id')&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyCoupon&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&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;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;discount out of range: -5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SMTP 421 try again later&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportCsv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&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;SyntaxError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unexpected token &amp;lt; in JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rare&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;applyCoupon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;exportCsv&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rare&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;chargeCard&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PACE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:9000/stats&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`sampleRate=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SAMPLE_RATE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; dedupe=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEDUPE&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; pace=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PACE&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;issue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stored&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the ingest in one terminal and the flood in another. The ingest keeps state in memory, so restart it between runs.&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;# terminal 1&lt;/span&gt;
&lt;span class="nv"&gt;VALVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off node ingest.js

&lt;span class="c"&gt;# terminal 2&lt;/span&gt;
&lt;span class="nv"&gt;PACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off node flood.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run 1: the SDK drops more than you think
&lt;/h2&gt;

&lt;p&gt;Before any sampling, the SDK already throws events away. Here's the naive run: defaults, no pacing, valve off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sampleRate=1 dedupe=on pace=off
issue                                 count  stored
TypeError: Cannot read properties of      4       4
RangeError: discount out of range: -      1       1
Error: SMTP 421 try again later           1       1
SyntaxError: Unexpected token &amp;lt; in J      1       1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;997 &lt;code&gt;TypeError&lt;/code&gt;s thrown, 4 received. That's &lt;code&gt;dedupeIntegration&lt;/code&gt;, on by default. It compares each event with the previous one and drops exact repeats, so you get one &lt;code&gt;TypeError&lt;/code&gt; per stretch between the rare errors. With &lt;code&gt;debug: true&lt;/code&gt; you'll see this 993 times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sentry Logger [warn]: Event dropped due to being a duplicate of previously captured event.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real floods come from many browsers or workers, each with its own "previous event". So I turned Dedupe off:&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;PACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off &lt;span class="nv"&gt;DEDUPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off node flood.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sampleRate=1 dedupe=off pace=off
issue                                 count  stored
TypeError: Cannot read properties of     64      64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the run that bit me. 64 events arrived and all three rare bugs were gone. The transport allows 64 requests in flight (&lt;code&gt;DEFAULT_TRANSPORT_BUFFER_SIZE = 64&lt;/code&gt; in &lt;code&gt;@sentry/core&lt;/code&gt;). A synchronous burst fills it and everything after is dropped. With &lt;code&gt;debug: true&lt;/code&gt;, 936 times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sentry Logger [log]: Recording outcome: "queue_overflow:error"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rare bugs were events 250, 500 and 750. They never had a chance.&lt;/p&gt;

&lt;p&gt;In the lab, pacing with &lt;code&gt;await Sentry.flush()&lt;/code&gt; every 50 events fixes it, and so does &lt;code&gt;transportOptions: { bufferSize: 2000 }&lt;/code&gt;. Both gave me 997 + 1 + 1 + 1. In a real incident, a bigger buffer just means you're queueing the flood in your app's memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run 2: sampleRate 0.1 loses the rare bugs
&lt;/h2&gt;

&lt;p&gt;Now the popular fix. Paced, Dedupe off, valve off, &lt;code&gt;sampleRate&lt;/code&gt; at 0.1, five runs:&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;DEDUPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off &lt;span class="nv"&gt;SAMPLE_RATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.1 node flood.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sampleRate=0.1 dedupe=off pace=on
issue                                 count  stored
TypeError: Cannot read properties of    120     120
SyntaxError: Unexpected token &amp;lt; in J      1       1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was one of my two lucky runs: one rare bug out of three. The other lucky run caught the SMTP error instead. The remaining three runs got 96, 102 and 85 &lt;code&gt;TypeError&lt;/code&gt;s and zero rare bugs. Yours will differ, it's random. That's the point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Loop bug (thrown 997x)&lt;/th&gt;
&lt;th&gt;Rare bugs (3 per run)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Arrived over 5 runs&lt;/td&gt;
&lt;td&gt;85 to 120 per run&lt;/td&gt;
&lt;td&gt;2 of 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you conclude&lt;/td&gt;
&lt;td&gt;count is off by about 10x&lt;/td&gt;
&lt;td&gt;most of them never happened&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The loop still owns the issue list. The bugs you'd want to look at are the ones that disappeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run 3: count everything, ration the bodies
&lt;/h2&gt;

&lt;p&gt;Same flood, no sampling, valve on.&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;# terminal 1&lt;/span&gt;
node ingest.js

&lt;span class="c"&gt;# terminal 2&lt;/span&gt;
&lt;span class="nv"&gt;DEDUPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off node flood.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sampleRate=1 dedupe=off pace=on
issue                                 count  stored
TypeError: Cannot read properties of    997     100
RangeError: discount out of range: -      1       1
Error: SMTP 421 try again later           1       1
SyntaxError: Unexpected token &amp;lt; in J      1       1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three runs, same output each time. The count is exact. The loop bug keeps 100 full stack traces and the rest are counted, not stored. Each rare bug gets its own bucket with 100 fresh tokens, so it always lands.&lt;/p&gt;

&lt;p&gt;Every request got a &lt;code&gt;202&lt;/code&gt;, so the SDK has no reason to back off. All the throttling happens where you can see it: on the server, per bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the spike valve works in Epure
&lt;/h2&gt;

&lt;p&gt;Same idea, in Rust. Epure is the self-hosted error tracker I'm building. It takes errors from the official Sentry SDKs through a DSN swap and drops transactions and replays. Here's the core of the valve from &lt;a href="https://github.com/epure-sh/epure/blob/main/crates/server/src/pipeline/spike.rs" rel="noopener noreferrer"&gt;&lt;code&gt;crates/server/src/pipeline/spike.rs&lt;/code&gt;&lt;/a&gt;, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_RATE_PER_MINUTE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;SpikeDecision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AllowStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CounterOnly&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;TokenBucket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SpikeDecision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.refill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nn"&gt;SpikeDecision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AllowStore&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;SpikeDecision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CounterOnly&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AllowStore&lt;/code&gt; queues the full event. &lt;code&gt;CounterOnly&lt;/code&gt; queues a job that only bumps the issue counter. Buckets sit in a &lt;code&gt;DashMap&lt;/code&gt; keyed by fingerprint, and idle ones get evicted once there are 20k keys. The fingerprint is a SHA-256 of the exception type plus the top in-app frame, unless the SDK sends a custom &lt;code&gt;fingerprint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;From the outside, per the &lt;a href="https://epure.sh/docs/api/errors?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sample-rate-error-flood" rel="noopener noreferrer"&gt;errors and rate limits docs&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What the client sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spike valve&lt;/td&gt;
&lt;td&gt;100 raw events / minute / fingerprint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;202&lt;/code&gt;. Extra bodies aren't stored, the counter still goes up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project ingest cap&lt;/td&gt;
&lt;td&gt;5000 events / hour / project&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; &lt;code&gt;ingest_cap_exceeded&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No Redis. The buckets live in memory in the Rust process, next to Postgres 16, in a two-container compose file. The repo has an integration test that posts the same browser envelope 150 times and expects 150 &lt;code&gt;202&lt;/code&gt;s, at least 150 counter increments and at most 100 stored rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a project-level limit?
&lt;/h2&gt;

&lt;p&gt;Project-level limits have the same blind spot as &lt;code&gt;sampleRate&lt;/code&gt;. They protect the bill and can't tell which bug matters.&lt;/p&gt;

&lt;p&gt;Sentry's SaaS has Spike Protection. Per its docs, it sets a per-project hourly threshold from your quota and the last 7 days of traffic, then discards events above it. That's a real safety net for your quota. But it limits the project, so a rare bug that lands mid-spike competes with the loop for the same allowance.&lt;/p&gt;

&lt;p&gt;Epure doesn't dodge this either. Its hourly project cap is checked &lt;strong&gt;before&lt;/strong&gt; the valve, and counter-only hits count toward it. Rough math: a loop sustained above about 83 events a minute (5000 / 60) burns the default cap within the hour. After that, every event from the project gets a &lt;code&gt;403&lt;/code&gt; until the next clock hour, rare bugs included. You can raise the cap per project. The valve keeps a loop out of your database. It doesn't stop a sustained loop from eating the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is sampleRate still the right call?
&lt;/h2&gt;

&lt;p&gt;When the upload itself costs you (a browser SDK on a lot of phones), or when your vendor bills per event and you need a ceiling today.&lt;/p&gt;

&lt;p&gt;If one known error is the noise, an &lt;code&gt;ignoreErrors&lt;/code&gt; entry or a &lt;code&gt;beforeSend&lt;/code&gt; filter for that exact error beats a global coin flip. And don't mix up the knobs: &lt;code&gt;tracesSampleRate&lt;/code&gt; is performance data, &lt;code&gt;sampleRate&lt;/code&gt; is errors. Turning down traces is cheap. Turning down errors is how you lose the one-off bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure checklist
&lt;/h2&gt;

&lt;p&gt;If your flood test doesn't match the outputs above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only a handful of events arrived.&lt;/strong&gt; Dedupe is on by default. Filter it out or interleave different errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactly 64 events arrived.&lt;/strong&gt; You hit the transport buffer. Pace with &lt;code&gt;await Sentry.flush()&lt;/code&gt; or set &lt;code&gt;transportOptions: { bufferSize }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rare errors got merged into the loop issue.&lt;/strong&gt; Your fingerprint is too coarse. Group by message only and every &lt;code&gt;Cannot read properties of undefined&lt;/code&gt; shares one bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deploy reset your throttling.&lt;/strong&gt; Buckets are in memory, so a restart refills them. And since the frame key includes the line number, moving code gives the bug a new fingerprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You run several ingest processes.&lt;/strong&gt; In-memory buckets are per process. Two processes can store up to 2x the bodies per minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything returns &lt;code&gt;403 ingest_cap_exceeded&lt;/code&gt;.&lt;/strong&gt; That's the hourly project cap. The valve never returns an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;202&lt;/code&gt; but the issue list is empty.&lt;/strong&gt; Accept is async, so the event may still be queued. Check the DSN's project and environment, then &lt;code&gt;docker compose logs epure&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer events than expected, no errors anywhere.&lt;/strong&gt; Someone left &lt;code&gt;sampleRate&lt;/code&gt; below 1 in shared config: &lt;code&gt;grep -rn "sampleRate" src/&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What would you set?
&lt;/h2&gt;

&lt;p&gt;I picked 100 stored stack traces per bug per minute because that's about what I'd scroll through during an incident, and I'm not sure it's right. If a loop hit your app tonight, how many full stack traces of the same bug per minute would you actually want kept?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>devops</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why Rust + Postgres RLS</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Sun, 04 Oct 2026 13:20:31 +0000</pubDate>
      <link>https://dev.to/amorizz/why-rust-postgres-rls-mp2</link>
      <guid>https://dev.to/amorizz/why-rust-postgres-rls-mp2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;We wanted &lt;strong&gt;multi-tenant exception ingest&lt;/strong&gt; on a &lt;strong&gt;two-container&lt;/strong&gt; stack: one Rust binary + PostgreSQL 16. No Redis. No Kafka.&lt;/li&gt;
&lt;li&gt;Dashboard reads go through Postgres &lt;strong&gt;RLS&lt;/strong&gt; with &lt;code&gt;SET LOCAL app.current_org_id&lt;/code&gt;. App bugs must not cross orgs on a shared DB.&lt;/li&gt;
&lt;li&gt;Ingest is &lt;strong&gt;DSN → project_id&lt;/strong&gt;, not the app RLS role. RLS is defense-in-depth for dashboard &lt;code&gt;/api/v1&lt;/code&gt;, not magic on every path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I build Epure: exception-only error monitoring you can self-host. This week I am writing down the stack bet, not a migrate recipe. We already shipped the cheap VPS shape and the DSN swap. The question people ask next is why Rust plus Postgres row-level security instead of a bigger fleet or app-only filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rust + Postgres RLS for exception ingest?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We optimized for a small blast radius on a shared Postgres: org-scoped dashboard reads enforced in the database, ingest scoped by DSN, two containers total.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tenancy on a side-project VPS is easy to hand-wave. One engineer, one DB, &lt;code&gt;WHERE org_id = ?&lt;/code&gt; in every handler. That works until a handler forgets the filter, a join leaks a neighbor row, or a reporting query skips the org check. On a multi-tenant exception store that is the whole product surface: other people's stack traces.&lt;/p&gt;

&lt;p&gt;So we put the tenant key in Postgres. The dashboard role cannot see another org's rows even when application code is wrong. Rust keeps the binary one process: ingest HTTP, migrations, dashboard API. Postgres 16 holds events, issues, and the RLS policies. That is the whole product runtime besides your TLS edge.&lt;/p&gt;

&lt;p&gt;If you run GlitchTip or self-hosted Sentry, the portable lesson is the same: decide where the tenant boundary lives before you scale the number of tenants on one database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model: who can read what if X fails?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cookie and membership checks gate the dashboard. RLS is the backstop if a query forgets the org filter. Ingest trusts the DSN, not a client-supplied org id.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Without RLS (app filters only)&lt;/th&gt;
&lt;th&gt;With &lt;code&gt;epure_app&lt;/code&gt; + RLS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Handler drops &lt;code&gt;WHERE org_id = …&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Neighbor org rows can return&lt;/td&gt;
&lt;td&gt;Policy still filters on &lt;code&gt;app.current_org_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client sends another org's UUID&lt;/td&gt;
&lt;td&gt;App must reject; easy to miss on one route&lt;/td&gt;
&lt;td&gt;Session sets org from membership, not from the body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stolen session cookie for Org A&lt;/td&gt;
&lt;td&gt;Sees Org A (expected)&lt;/td&gt;
&lt;td&gt;Still only Org A; no cross-org via forgotten filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaked ingest DSN for Project P&lt;/td&gt;
&lt;td&gt;Writes land on P&lt;/td&gt;
&lt;td&gt;Same: ingest is DSN-scoped; not the app RLS role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard uses migrate/&lt;code&gt;epure&lt;/code&gt; role by mistake&lt;/td&gt;
&lt;td&gt;Full table access&lt;/td&gt;
&lt;td&gt;Wrong. &lt;code&gt;epure&lt;/code&gt; is for migrations. App must use &lt;code&gt;epure_app&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What we are not claiming: RLS stops a stolen DSN, a stolen cookie inside the right org, or a bug in the ingest writer. Different paths, different trust boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a shared DB without RLS, app-only filters, or a heavier stack?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;App-only filters fail open on one missed query. Heavier stacks buy isolation with ops cost we refused on a two-container box.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why it lost for this job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shared DB, no RLS&lt;/td&gt;
&lt;td&gt;Every read path must remember the org filter. One miss is a tenancy bug.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App-only org filters&lt;/td&gt;
&lt;td&gt;Fine for a prototype. Weak as the only line when exceptions are the payload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate database per customer&lt;/td&gt;
&lt;td&gt;Strong isolation. Painful backups, migrations, and $5-class ops.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full self-hosted Sentry (~65 containers)&lt;/td&gt;
&lt;td&gt;Real product. Wrong unit when you only need exceptions (&lt;a href="https://dev.to/amorizz/error-monitoring-on-a-5-vps-21oa"&gt;$5 VPS notes&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis / Kafka / extra workers&lt;/td&gt;
&lt;td&gt;Useful for big fleets. We did not want them for exception ingest on one VPS.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GlitchTip is a fair lighter alternative on Django-shaped stacks. We still chose Rust + Postgres RLS because we are building the ingest path and want the tenant boundary in the database for dashboard reads. A GlitchTip reader can still steal the role split and &lt;code&gt;SET LOCAL&lt;/code&gt; pattern below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working sketch: three roles, SET LOCAL, policy shape
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three Postgres roles on one &lt;code&gt;epure&lt;/code&gt; database. Dashboard: cookie → membership → &lt;code&gt;SET LOCAL app.current_org_id&lt;/code&gt; → queries. Ingest: DSN → &lt;code&gt;project_id&lt;/code&gt;. Migrations: &lt;code&gt;epure&lt;/code&gt; only.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role / URL&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;epure&lt;/code&gt; via &lt;code&gt;DATABASE_URL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Migrations / bootstrap on startup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;epure_ingest&lt;/code&gt; via &lt;code&gt;EPURE_INGEST_DATABASE_URL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;DSN-scoped event writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;epure_app&lt;/code&gt; via &lt;code&gt;EPURE_APP_DATABASE_URL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Dashboard &lt;code&gt;/api/v1/*&lt;/code&gt; with RLS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compose injects all three for the app container. Missing migrate URL exits on start. Missing ingest URL yields storage errors on write. Missing app URL 500s the dashboard.&lt;/p&gt;

&lt;p&gt;Dashboard request shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Valid session cookie.&lt;/li&gt;
&lt;li&gt;Confirm the user is a member of the org.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET LOCAL app.current_org_id = '&amp;lt;uuid&amp;gt;'&lt;/code&gt; on that transaction.&lt;/li&gt;
&lt;li&gt;Run queries as &lt;code&gt;epure_app&lt;/code&gt;. Never trust a client-supplied org id for the session setting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ingest request shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authenticate with the project DSN.&lt;/li&gt;
&lt;li&gt;Resolve &lt;code&gt;project_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write as &lt;code&gt;epure_ingest&lt;/code&gt;. Do not set &lt;code&gt;app.current_org_id&lt;/code&gt; from the client.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Illustrative policy shape (paraphrased from the docs pattern; pin your own names to migrations):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- illustrative: dashboard role reads events only for the session org&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;events_org_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;epure_app&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_org_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- FORCE ROW LEVEL SECURITY so table owners cannot bypass by accident&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events land in monthly partitions &lt;code&gt;events_YYYY_MM&lt;/code&gt;. Project retention TTL is 14 / 30 / 90 days; old partitions drop. Issue aggregates remain after event TTL. That retention story is separate from RLS, but it is part of the same cheap-ops bet.&lt;/p&gt;

&lt;p&gt;You do not need our binary to try the pattern. Any multi-tenant read API on Postgres can use a restricted role, &lt;code&gt;SET LOCAL&lt;/code&gt; for the tenant key, and &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits and failure checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exception-only. RLS is defense-in-depth for dashboard reads. Not a full security whitepaper. Never "100% Sentry compatible."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Limits we accept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No APM, session replay, profiling, logs product, or mobile suite.&lt;/li&gt;
&lt;li&gt;Partial Sentry wire for error tracking. Official SDKs via DSN swap (&lt;a href="https://dev.to/amorizz/migrate-sentry-epure-in-10-min-564"&gt;migrate post&lt;/a&gt;). Not a clone of every SaaS feature.&lt;/li&gt;
&lt;li&gt;Ingest is not protected by the same org RLS session as the dashboard. DSN leakage is a project-level problem.&lt;/li&gt;
&lt;li&gt;RLS does not replace authn, authz, TLS, or password hygiene.&lt;/li&gt;
&lt;li&gt;Idle stack ~82 MiB was measured 2026-09-12 on our box. Treat that as one data point, not a promise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard connected with &lt;code&gt;DATABASE_URL&lt;/code&gt; / &lt;code&gt;epure&lt;/code&gt;.&lt;/strong&gt; Migrations role can see everything. Use &lt;code&gt;EPURE_APP_DATABASE_URL&lt;/code&gt; for &lt;code&gt;/api/v1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgot &lt;code&gt;SET LOCAL app.current_org_id&lt;/code&gt;.&lt;/strong&gt; Queries as &lt;code&gt;epure_app&lt;/code&gt; return empty or error depending on policy. Fix the session setter, do not weaken the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting org id from the JSON body.&lt;/strong&gt; Membership must drive &lt;code&gt;SET LOCAL&lt;/code&gt;. Client UUID is not a credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DSN pasted into a dashboard &lt;code&gt;/api/v1&lt;/code&gt; call.&lt;/strong&gt; Wrong path. Ingest uses the envelope/store routes and the ingest role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Example passwords on a public &lt;code&gt;EPURE_PUBLIC_URL&lt;/code&gt;.&lt;/strong&gt; Boot refuses documented laptop defaults and leftover &lt;code&gt;CHANGE_ME&lt;/code&gt; off localhost. Use the production env template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting RLS to hide events after a leaked project DSN.&lt;/strong&gt; Rotate the key. RLS does not rewrite ingest auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming event TTL deletes issue rows.&lt;/strong&gt; Partitions drop; issue aggregates remain. Read retention docs before promising "everything gone."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Configuration map for the three URLs and roles: &lt;a href="https://epure.sh/docs/self-hosting/configuration?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;self-hosting configuration&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;Where do you put the tenant boundary for multi-tenant reads: app filters only, Postgres RLS, or separate databases per customer?&lt;/p&gt;

</description>
      <category>rust</category>
      <category>postgres</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Migrate Sentry -&gt; Epure in 10 min</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:27:10 +0000</pubDate>
      <link>https://dev.to/amorizz/migrate-sentry-epure-in-10-min-564</link>
      <guid>https://dev.to/amorizz/migrate-sentry-epure-in-10-min-564</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the &lt;strong&gt;official Sentry SDK&lt;/strong&gt;. Change only &lt;code&gt;dsn&lt;/code&gt; to your Epure project.&lt;/li&gt;
&lt;li&gt;Set traces, profiles, and replay sample rates to &lt;strong&gt;0&lt;/strong&gt;. Epure is exception-only.&lt;/li&gt;
&lt;li&gt;Throw one test error, expect ingest &lt;strong&gt;HTTP 202&lt;/strong&gt;, then an &lt;strong&gt;Issues&lt;/strong&gt; row. Know what you drop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I already ship &lt;code&gt;@sentry/node&lt;/code&gt; (and browser) against SaaS Sentry. This week I pointed the same init at our self-hosted ingest: same SDK, new DSN, one verified event. The bill and the data residency story were enough; I did not want a client rewrite.&lt;/p&gt;

&lt;p&gt;About ten minutes of wall clock once the ingest host is healthy. That is DSN copy, init edit, one throw, Issues check. Not a stopwatch claim for compose, TLS, or CORS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before the clock starts?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ingest already up. Official Sentry SDK already in the app.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prerequisite&lt;/th&gt;
&lt;th&gt;Done means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Epure host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/health&lt;/code&gt; returns &lt;code&gt;{"status":"ok"}&lt;/code&gt; on the public origin you will put in the DSN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project + DSN&lt;/td&gt;
&lt;td&gt;Dashboard → Settings → SDK connection (or Setup). Key created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App SDK&lt;/td&gt;
&lt;td&gt;You already call &lt;code&gt;Sentry.init&lt;/code&gt; with a SaaS DSN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope match&lt;/td&gt;
&lt;td&gt;You only need exceptions / issues, not APM, replay, profiling, logs, or mobile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Host not up yet? Bring the two-container stack online first (&lt;a href="https://dev.to/amorizz/error-monitoring-on-a-5-vps-21oa"&gt;Error monitoring on a $5 VPS&lt;/a&gt;), then come back. This post does not retell compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timed steps: DSN swap and one event
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Copy the project DSN, replace only &lt;code&gt;dsn&lt;/code&gt;, zero unused rates, throw once, confirm Issues.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Copy the project DSN
&lt;/h3&gt;

&lt;p&gt;Open the project → &lt;strong&gt;Settings → SDK connection&lt;/strong&gt; (or Setup). Create a DSN key if none exists. Copy the full string. Do not paste a project UUID from the URL bar into the DSN.&lt;/p&gt;

&lt;p&gt;Shape looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://YOUR_PUBLIC_KEY@errors.example.com/YOUR_PROJECT_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Swap &lt;code&gt;dsn&lt;/code&gt; only
&lt;/h3&gt;

&lt;p&gt;CI fixtures pin &lt;code&gt;@sentry/browser&lt;/code&gt; / &lt;code&gt;@sentry/node&lt;/code&gt; &lt;strong&gt;7.120.0&lt;/strong&gt;. Match that if you want the same line the docs exercise. On Sentry JS v8+, also set &lt;code&gt;enableTracing: false&lt;/code&gt; when the option exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or @sentry/browser@7.120.0&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://YOUR_PUBLIC_KEY@errors.example.com/YOUR_PROJECT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// match the Issues filter chip&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-app@1.2.3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;profilesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;replaysSessionSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;replaysOnErrorSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrating from Sentry SaaS: keep the same init options. Replace only &lt;code&gt;dsn&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Send one test error
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Epure migrate test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// short workers: await Sentry.flush(2000) before process exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Confirm ingest, then Issues
&lt;/h3&gt;

&lt;p&gt;Expect ingest &lt;strong&gt;HTTP 202&lt;/strong&gt; with a body like &lt;code&gt;{ "id": "..." }&lt;/code&gt;. Refresh Issues. Match the &lt;strong&gt;environment&lt;/strong&gt; filter to &lt;code&gt;Sentry.init&lt;/code&gt; (&lt;code&gt;production&lt;/code&gt; above, or &lt;code&gt;local&lt;/code&gt; in docs examples). You should see an unresolved issue for that message.&lt;/p&gt;

&lt;p&gt;Empty feed after a 202? Wait a second, check the env chip, confirm you opened the project that owns the DSN.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you keep / drop?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exceptions and issue triage stay. Replay, APM, profiling, logs, and mobile do not.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keep&lt;/th&gt;
&lt;th&gt;Drop (out of scope)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official Sentry SDKs (&lt;code&gt;@sentry/node&lt;/code&gt;, &lt;code&gt;@sentry/browser&lt;/code&gt;, …)&lt;/td&gt;
&lt;td&gt;Session replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Envelope / store error path&lt;/td&gt;
&lt;td&gt;APM / distributed tracing product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;release&lt;/code&gt;, issue grouping&lt;/td&gt;
&lt;td&gt;Continuous profiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted data path&lt;/td&gt;
&lt;td&gt;Full SaaS suite extras&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Never "100% Sentry compatible." Partial wire for error tracking. If GlitchTip is a better fit for a lighter Django-shaped stack, that is fine. This post is that migrate path.&lt;/p&gt;

&lt;p&gt;Deeper SDK init, CORS, and compose details live in the earlier how-to: &lt;a href="https://dev.to/amorizz/point-the-official-sentry-sdk-at-self-hosted-ingest-dsn-only-3ij2"&gt;Point the official Sentry SDK at self-hosted ingest (DSN only)&lt;/a&gt;. Soft-link only. Do not retell every init step here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CORS blocks the browser SDK.&lt;/strong&gt; SPA origin missing from &lt;code&gt;EPURE_CORS_ORIGINS&lt;/code&gt;. Fix origins before blaming the package (&lt;a href="https://dev.to/amorizz/fix-cors-before-you-blame-the-sdk-45b8"&gt;CORS post&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short worker exits before flush.&lt;/strong&gt; Node/Go lambda-style processes drop the last event. Call &lt;code&gt;flush&lt;/code&gt; / &lt;code&gt;Flush&lt;/code&gt; before exit (&lt;a href="https://dev.to/amorizz/your-last-error-never-arrives-flush-before-exit-in-node-and-go-53co"&gt;flush post&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DSN revoked or wrong key.&lt;/strong&gt; Secret shown once at create. Rotate in Settings → SDK connection if you pasted a dead key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public URL mismatch.&lt;/strong&gt; &lt;code&gt;/health&lt;/code&gt; on localhost is not the host inside the DSN. Align &lt;code&gt;EPURE_PUBLIC_URL&lt;/code&gt; / proxy with what the SDK posts to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment chip hides the row.&lt;/strong&gt; Issues filter is &lt;code&gt;staging&lt;/code&gt; while init says &lt;code&gt;production&lt;/code&gt;. Match them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second &lt;code&gt;Sentry.init&lt;/code&gt; still on SaaS.&lt;/strong&gt; A leftover init in a shared bootstrap still ships to &lt;code&gt;ingest.sentry.io&lt;/code&gt;. Grep for every &lt;code&gt;Sentry.init&lt;/code&gt; and every &lt;code&gt;dsn:&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the host is healthy and you want the install map in one place: &lt;a href="https://epure.sh/docs/get-started/quickstart?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=thursday" rel="noopener noreferrer"&gt;Epure quickstart&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;What blocked your last DSN swap: CORS, auth, or a missing flush on short workers?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tutorial</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Error monitoring on a $5 VPS</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Mon, 28 Sep 2026 20:46:13 +0000</pubDate>
      <link>https://dev.to/amorizz/error-monitoring-on-a-5-vps-21oa</link>
      <guid>https://dev.to/amorizz/error-monitoring-on-a-5-vps-21oa</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;We run &lt;strong&gt;exception-only&lt;/strong&gt; ingest on a &lt;strong&gt;$5-12 class VPS&lt;/strong&gt;: two containers (app + Postgres 16), official Sentry SDKs via DSN swap.&lt;/li&gt;
&lt;li&gt;We ruled out &lt;strong&gt;full self-hosted Sentry&lt;/strong&gt; (dozens of containers) and a &lt;strong&gt;SaaS bill&lt;/strong&gt; for errors-only work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres stays off the host port.&lt;/strong&gt; TLS edge is a separate recipe (&lt;a href="https://dev.to/amorizz/put-self-hosted-exception-tracking-behind-caddy-keep-postgres-off-the-host-3gpo"&gt;Caddy post&lt;/a&gt;); this post is cost + footprint + constraints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run Epure exception ingest on the same class of box I use for side projects: one cheap VPS, one engineer, no Kubernetes. The job is narrow. Catch production exceptions, group them, keep Postgres off the public internet. Not APM. Not session replay. Not a 65-container Sentry clone on a droplet that costs less than lunch.&lt;/p&gt;

&lt;p&gt;Here are the notes from that choice: constraints, what we ruled out, a minimal compose sketch, and the failure checklist we hit while wiring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What constraints did we accept?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;$5-12/month VPS, one operator, official Sentry SDKs pointed at our own DSN. Errors only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concrete box:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;What we mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Budget&lt;/td&gt;
&lt;td&gt;$5-12 class VPS (1-2 vCPU, modest RAM). Not a managed cluster.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headcount&lt;/td&gt;
&lt;td&gt;One engineer who can SSH, edit compose, read logs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client path&lt;/td&gt;
&lt;td&gt;Official Sentry SDKs (&lt;code&gt;@sentry/node&lt;/code&gt;, &lt;code&gt;sentry-go&lt;/code&gt;, etc.). DSN swap. No custom client.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Exceptions / issues. No APM, replay, profiling, logs, or mobile suite.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you need the full Sentry product surface, this post is the wrong shape. Keep SaaS or self-host the big stack on hardware sized for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did we rule out?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Full self-hosted Sentry (~65 containers) and paying SaaS just for exception ingest.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why it lost for this job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Sentry&lt;/td&gt;
&lt;td&gt;Real product. Also a real fleet (Kafka, ClickHouse, Redis, workers, …). Fine on a dedicated host. Painful on a $5 box when you only need errors.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS Sentry (exceptions-only)&lt;/td&gt;
&lt;td&gt;Works. You pay for a bill and a vendor boundary you may not want for a side project or a privacy-sensitive ingest path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GlitchTip&lt;/td&gt;
&lt;td&gt;Fair lighter alternative. Open source, Sentry-protocol-ish, smaller than full Sentry. We still chose Epure because we are building it and want Rust + Postgres RLS for our threat model. A GlitchTip reader can still use the VPS constraints below.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We are not saying "kill Sentry." We are saying: if the job is &lt;strong&gt;exceptions on a cheap VPS&lt;/strong&gt;, the 65-container install is the wrong unit of deployment.&lt;/p&gt;

&lt;p&gt;Epure is exception-only. Official SDKs + DSN. Not "100% Sentry compatible." If your checklist includes replay, APM, or profiling, stop here and pick a suite that actually ships those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model before compose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → TLS edge (Caddy :443) → app:8080 (Docker net)
                                  → postgres:5432 (Docker net only, NO host publish)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two containers for the product. Proxy in front for HTTPS. Database never bound to &lt;code&gt;0.0.0.0:5432&lt;/code&gt; on the host.&lt;/p&gt;

&lt;p&gt;For the TLS edge recipe (Caddyfile, 80/443, Postgres unpublished), use the live post: &lt;a href="https://dev.to/amorizz/put-self-hosted-exception-tracking-behind-caddy-keep-postgres-off-the-host-3gpo"&gt;Put self-hosted exception tracking behind Caddy&lt;/a&gt;. This article does &lt;strong&gt;not&lt;/strong&gt; rehash every Caddy stanza.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal two-container compose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;App + &lt;code&gt;postgres:16&lt;/code&gt;. Publish the app (or leave it for Caddy on the Docker network). Do not publish Postgres to the host.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sketch shaped like production (passwords via &lt;code&gt;.env&lt;/code&gt;, no example defaults on a public URL):&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;# docker-compose.errors.yml (sketch; pin your own image tags)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/epure-sh/epure:latest&lt;/span&gt;   &lt;span class="c1"&gt;# pin a release tag in prod&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;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;.env&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://epure:${POSTGRES_PASSWORD}@postgres:5432/epure&lt;/span&gt;
      &lt;span class="na"&gt;EPURE_PUBLIC_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://errors.example.com&lt;/span&gt;
      &lt;span class="na"&gt;EPURE_CORS_ORIGINS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;8080:8080"&lt;/span&gt;   &lt;span class="c1"&gt;# or omit and let Caddy reach app:8080 on the Docker net&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;postgres&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;postgres:16-alpine&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;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;epure&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;epure&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&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;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;epure&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;epure"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="c1"&gt;# NO ports: stay on the Docker network only&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up:&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 &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.errors.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl &lt;span class="nt"&gt;-sS&lt;/span&gt; http://127.0.0.1:8080/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt;&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&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;Then put Caddy (or nginx) on 80/443 in front of &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, point DNS, register, copy a project DSN, and &lt;code&gt;captureException&lt;/code&gt; from an official SDK.&lt;/p&gt;

&lt;p&gt;Canonical install steps (prod overlay, &lt;code&gt;./configure --prod&lt;/code&gt;, PaaS templates): &lt;a href="https://epure.sh/docs/self-hosting/installation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;Epure self-hosting installation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expected footprint (honest)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Qualitative only until we publish measured numbers.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Container count (order of magnitude)&lt;/th&gt;
&lt;th&gt;Fit on $5-12 VPS?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full self-hosted Sentry&lt;/td&gt;
&lt;td&gt;Dozens (~65-class fleet with deps)&lt;/td&gt;
&lt;td&gt;Usually no, not for errors-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GlitchTip&lt;/td&gt;
&lt;td&gt;Small multi-service compose&lt;/td&gt;
&lt;td&gt;Often yes (lighter than full Sentry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epure (what we run)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2&lt;/strong&gt; (app + Postgres 16)&lt;/td&gt;
&lt;td&gt;Designed for this class of box&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We will not invent RAM figures. If you measure, paste &lt;code&gt;docker stats&lt;/code&gt; and &lt;code&gt;free -h&lt;/code&gt; in the comments. We will update this section with real numbers when we have them on a named plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Postgres published to the host&lt;/strong&gt; (&lt;code&gt;5432:5432&lt;/code&gt; "just for debugging"). Scanners find it. Drop the &lt;code&gt;ports:&lt;/code&gt; block; reach DB only as &lt;code&gt;postgres:5432&lt;/code&gt; on the Compose network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laptop compose used as prod.&lt;/strong&gt; Example passwords + localhost URL rules do not belong on a public hostname. Use the production overlay / env template from the docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No TLS, cookies broken.&lt;/strong&gt; Secure session cookies need HTTPS. Finish the &lt;a href="https://dev.to/amorizz/put-self-hosted-exception-tracking-behind-caddy-keep-postgres-off-the-host-3gpo"&gt;Caddy edge&lt;/a&gt; before you debug "login flaps."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health is green, DSN host is wrong.&lt;/strong&gt; &lt;code&gt;/health&lt;/code&gt; on localhost is not the same as the public origin in the DSN. Match &lt;code&gt;EPURE_PUBLIC_URL&lt;/code&gt; / proxy host to what the SDK posts to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS blocks browser SDKs.&lt;/strong&gt; SPA origin missing from allowlist. Fix origins before blaming the SDK (see our CORS post if you already hit this).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOM while "just trying self-hosted Sentry."&lt;/strong&gt; The fleet, not your app. Errors-only on two containers is the cheap-VPS shape; full Sentry wants a bigger host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting APM / replay / profiling.&lt;/strong&gt; Exception-only stacks will not grow those features because you wished hard. Pick the product that matches the checklist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the two-container shape matches what you need, follow the install doc once, put TLS in front, swap the DSN on an official SDK, and confirm a test exception lands: &lt;a href="https://epure.sh/docs/self-hosting/installation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;self-hosting installation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;What do you run on a cheap VPS for errors, and what blew the RAM first?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your last error never arrives — flush before exit in Node (and Go)</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Sun, 27 Sep 2026 13:22:15 +0000</pubDate>
      <link>https://dev.to/amorizz/your-last-error-never-arrives-flush-before-exit-in-node-and-go-53co</link>
      <guid>https://dev.to/amorizz/your-last-error-never-arrives-flush-before-exit-in-node-and-go-53co</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;captureException&lt;/code&gt; (and friends) &lt;strong&gt;queue&lt;/strong&gt; the event. The HTTP POST is async.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;process.exit&lt;/code&gt; / &lt;code&gt;os.Exit&lt;/code&gt; / Lambda freeze can kill the process &lt;strong&gt;before&lt;/strong&gt; that POST finishes. Dashboard stays empty.&lt;/li&gt;
&lt;li&gt;Fix: &lt;code&gt;await Sentry.flush()&lt;/code&gt; or &lt;code&gt;await Sentry.close()&lt;/code&gt; in Node; &lt;code&gt;sentry.Flush(2 * time.Second)&lt;/code&gt; in Go, &lt;strong&gt;before&lt;/strong&gt; you die.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You ship a cron, a Lambda, a CLI, or a one-shot k8s Job. Something throws. You call &lt;code&gt;captureException&lt;/code&gt;. The process exits. You refresh the error dashboard. Nothing. You re-check the DSN, blame "Sentry being flaky," then burn an afternoon on network paths that were fine the whole time.&lt;/p&gt;

&lt;p&gt;The SDK did what you asked: it queued the event. The process died before the transport finished. This post is the portable flush recipe for Node (&lt;code&gt;@sentry/node&lt;/code&gt;) and Go (&lt;code&gt;getsentry/sentry-go&lt;/code&gt;). Same calls work against SaaS Sentry, GlitchTip, or any self-hosted ingest that speaks the Sentry protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the last error vanishes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Capture is async. Exit ends the process. Anything still in the SDK queue never leaves the box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Official SDKs do not block your handler on every &lt;code&gt;captureException&lt;/code&gt;. They serialize the event, hand it to an internal transport, and return. That is correct for long-running servers: you do not want every error to stall a request.&lt;/p&gt;

&lt;p&gt;Short-lived workers have a different lifetime. The job finishes in hundreds of milliseconds. You call &lt;code&gt;process.exit(1)&lt;/code&gt; or the runtime freezes the Lambda. The event buffer still has work. No more event loop ticks. No more goroutine. The POST never happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; capture = "please send this soon." Flush/close = "block until the queue is empty (or the timeout hits), then I am allowed to exit."&lt;/p&gt;

&lt;h2&gt;
  
  
  Broken Node repro
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reproduce with &lt;code&gt;captureException&lt;/code&gt; plus an immediate &lt;code&gt;process.exit&lt;/code&gt; and no flush. Expect an empty issue stream even when the DSN is valid.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pin the SDK you will actually run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @sentry/node@11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;broken-worker.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// any valid Sentry-protocol DSN&lt;/span&gt;
  &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flush-demo: cron blew up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// looks done… it is not&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&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;SENTRY_DSN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'https://PUBLIC@HOST/PROJECT'&lt;/span&gt; node broken-worker.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected when broken&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Process exits in a few ms with code &lt;code&gt;1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your ingest host / SaaS project shows &lt;strong&gt;no&lt;/strong&gt; new issue for &lt;code&gt;flush-demo: cron blew up&lt;/code&gt; (or it appears much later / never, depending on race).&lt;/li&gt;
&lt;li&gt;You will be tempted to rotate the DSN. Do not. Watch the network next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional sanity check: temporarily set &lt;code&gt;debug: true&lt;/code&gt; in &lt;code&gt;Sentry.init&lt;/code&gt;. You often see the SDK still preparing or sending when the process already died.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed Node (flush / close)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Await &lt;code&gt;Sentry.flush(timeoutMs)&lt;/code&gt; if the process might keep running, or &lt;code&gt;Sentry.close(timeoutMs)&lt;/code&gt; right before exit. Both return a Promise&amp;lt;boolean&amp;gt;: &lt;code&gt;true&lt;/code&gt; if the queue drained in time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fixed-worker.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flush-demo: cron blew up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// drain the queue, then disable the client (safe before exit)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sentry.close timed out; some events may be lost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only need to drain and the process continues (batch of jobs in one process), prefer flush:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// client stays enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SIGTERM / graceful shutdown&lt;/strong&gt; (Compose, k8s, systemd):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`got &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, flushing Sentry`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SIGINT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SIGINT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pinned versions used in this post&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Pin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@sentry/node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;11&lt;/code&gt; (tested shape against &lt;code&gt;11.0.0&lt;/code&gt; docs APIs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Expected when fixed&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;close&lt;/code&gt; / &lt;code&gt;flush&lt;/code&gt; takes up to your timeout (here 2s) and resolves &lt;code&gt;true&lt;/code&gt; on a healthy network path.&lt;/li&gt;
&lt;li&gt;The issue for &lt;code&gt;flush-demo: cron blew up&lt;/code&gt; shows up on the project within a few seconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lastEventId()&lt;/code&gt; (optional) returns an id after capture; after a successful flush you can correlate it in the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS Lambda note: the runtime can freeze after your handler returns. Prefer flushing inside the handler before you return, not only on a process-level signal you may never see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go: Flush before os.Exit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Call &lt;code&gt;sentry.Flush(timeout)&lt;/code&gt; before the program terminates. &lt;code&gt;defer&lt;/code&gt; is fine for normal returns; &lt;code&gt;os.Exit&lt;/code&gt; skips defers, so flush manually before Exit.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/getsentry/sentry-go@v0.49.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/getsentry/sentry-go"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Dsn&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SENTRY_DSN"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sentry.Init: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// covers normal return paths&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CaptureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flush-demo: job failed"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c"&gt;// os.Exit skips defers; flush first&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;sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sentry.Flush timed out; some events may be lost"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected when fixed:&lt;/strong&gt; &lt;code&gt;Flush&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; and the exception appears on the project. When broken (capture + &lt;code&gt;os.Exit&lt;/code&gt; with no Flush), you get the same empty-dashboard trap as Node.&lt;/p&gt;

&lt;p&gt;Do not call &lt;code&gt;Flush&lt;/code&gt; after every &lt;code&gt;CaptureException&lt;/code&gt; in a hot loop. That turns async transport into sync spam. Flush at boundaries: end of job, before Exit, on shutdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bites (and where it usually does not)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You need an explicit flush on short-lived processes. Long-running servers mostly get away without it, but SIGTERM handlers still benefit.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Usually needs flush?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cron / systemd oneshot&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Process exits as soon as the script ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda / Cloud Functions&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Freeze after return; buffer dies with the freeze&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI / &lt;code&gt;go run&lt;/code&gt; job&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;User hits Ctrl-C or you &lt;code&gt;os.Exit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k8s Job / Compose &lt;code&gt;restart: "no"&lt;/code&gt; one-shot&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Same as cron&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running HTTP API&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;td&gt;Event loop / goroutines keep running; still flush on SIGTERM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your "server" is actually a request-scoped worker that exits per message (some queue consumers), treat it like a cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm steps + failure checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Confirm by forcing one known error, awaiting flush/close, and seeing that exact message in the project before you touch the DSN again.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use a disposable message string (&lt;code&gt;flush-demo: …&lt;/code&gt;) so you can find it fast.&lt;/li&gt;
&lt;li&gt;Run the fixed Node or Go snippet against a known-good DSN.&lt;/li&gt;
&lt;li&gt;Assert &lt;code&gt;flush&lt;/code&gt;/&lt;code&gt;close&lt;/code&gt;/&lt;code&gt;Flush&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt; (log it).&lt;/li&gt;
&lt;li&gt;Open the project: the issue exists with that message.&lt;/li&gt;
&lt;li&gt;Re-run the broken variant once so you trust the difference is flush, not luck.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Failure checklist&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;process.exit&lt;/code&gt; / &lt;code&gt;os.Exit&lt;/code&gt; before flush.&lt;/strong&gt; Classic. Move exit after &lt;code&gt;await close&lt;/code&gt; / &lt;code&gt;Flush&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgot that &lt;code&gt;os.Exit&lt;/code&gt; skips &lt;code&gt;defer&lt;/code&gt;.&lt;/strong&gt; Go: flush explicitly before Exit; do not rely on deferred Flush alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeout too short on a slow link.&lt;/strong&gt; &lt;code&gt;2000&lt;/code&gt; ms is a starting point; bump for cold starts / far regions, or accept loss and log &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda returns before flush.&lt;/strong&gt; Await flush inside the handler; do not only register process signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong DSN still possible.&lt;/strong&gt; Flush succeeding with &lt;code&gt;true&lt;/code&gt; but no UI event means auth/host/project mismatch. Fix DSN after flush is proven locally (debug transport logs help).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample rates / &lt;code&gt;beforeSend&lt;/code&gt; dropping the event.&lt;/strong&gt; Flush only drains what the SDK kept. A filter that returns &lt;code&gt;null&lt;/code&gt; will never arrive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple clients / hubs.&lt;/strong&gt; Flush the client that captured the event (or shut down each one you created).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire-and-forget &lt;code&gt;close()&lt;/code&gt; without await.&lt;/strong&gt; In Node, a floating promise loses the race to &lt;code&gt;process.exit&lt;/code&gt; again.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Same flush rule on any self-hosted error host
&lt;/h2&gt;

&lt;p&gt;The SDK calls above are host-agnostic: they drain the client queue toward whatever DSN you configured. Same pattern for SaaS Sentry, GlitchTip, or another Sentry-protocol ingest. If you need a place to stand that host up, the &lt;a href="https://epure.sh/docs/get-started/quickstart?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; covers the basics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;Did you lose a prod-only exception because the worker exited before flush, and how long did you chase the wrong DSN?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
      <category>go</category>
    </item>
    <item>
      <title>Fix CORS before you blame the SDK</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:49:54 +0000</pubDate>
      <link>https://dev.to/amorizz/fix-cors-before-you-blame-the-sdk-45b8</link>
      <guid>https://dev.to/amorizz/fix-cors-before-you-blame-the-sdk-45b8</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The bug is usually the &lt;strong&gt;preflight&lt;/strong&gt; (&lt;code&gt;OPTIONS&lt;/code&gt;), not your &lt;code&gt;fetch&lt;/code&gt; call or the client library.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; and &lt;code&gt;credentials: 'include'&lt;/code&gt; &lt;strong&gt;never&lt;/strong&gt; work together.&lt;/li&gt;
&lt;li&gt;Echo an &lt;strong&gt;exact&lt;/strong&gt; origin from an allowlist, not a wildcard, when the SPA and API sit on different hosts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your SPA lives on &lt;code&gt;https://app.example.com&lt;/code&gt;. Your API lives on &lt;code&gt;https://api.example.com&lt;/code&gt;. You open DevTools, hit a button, and get a red CORS error. The first instinct is to blame axios, the SDK, or “browser fetch being weird.”&lt;/p&gt;

&lt;p&gt;Pause. Cross-origin requests that are not simple GETs almost always send an &lt;code&gt;OPTIONS&lt;/code&gt; preflight first. If that preflight fails, the real request never leaves the browser, and nothing you change in the client will fix a missing or wrong response header on the API.&lt;/p&gt;

&lt;p&gt;This post is a portable SPA→API CORS recipe: minimal repro, Express and Caddy configs that engineers actually paste, the credentials gotcha, and a failure checklist. No product tour. Just headers that make the browser happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “but curl works” lies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Curl never sends a CORS preflight.&lt;/strong&gt; The browser does, and it can block the real request before your route handler runs.&lt;/p&gt;

&lt;p&gt;From a terminal, &lt;code&gt;curl -X POST https://api.example.com/v1/items -H 'Content-Type: application/json' -d '{}'&lt;/code&gt; talks HTTP like any other client. There is no same-origin policy. There is no &lt;code&gt;Origin&lt;/code&gt; header the browser injects. There is no automatic &lt;code&gt;OPTIONS&lt;/code&gt; probe.&lt;/p&gt;

&lt;p&gt;In Chrome or Firefox, a cross-origin &lt;code&gt;POST&lt;/code&gt; with &lt;code&gt;Content-Type: application/json&lt;/code&gt; is a &lt;strong&gt;non-simple&lt;/strong&gt; request. The browser first asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;OPTIONS&lt;/span&gt; &lt;span class="nn"&gt;/v1/items&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
&lt;span class="na"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://app.example.com&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Request-Method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Request-Headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;content-type&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the API does not answer with the right &lt;code&gt;Access-Control-*&lt;/code&gt; headers (or does not handle &lt;code&gt;OPTIONS&lt;/code&gt; at all), DevTools shows a CORS failure. Your Node route for &lt;code&gt;POST /v1/items&lt;/code&gt; may be perfect. You never reached it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; curl proves the route and auth work. The browser proves the CORS policy works. Fix both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal browser repro
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reproduce with one &lt;code&gt;fetch&lt;/code&gt; from &lt;code&gt;https://app.example.com&lt;/code&gt; to &lt;code&gt;https://api.example.com&lt;/code&gt;, then watch Network for a failed &lt;code&gt;OPTIONS&lt;/code&gt; (or a successful &lt;code&gt;POST&lt;/code&gt; with missing CORS headers on the response).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Serve a tiny page from the SPA origin (static file, Vite, whatever. Origin must be &lt;code&gt;https://app.example.com&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"go"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Call API&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;pre&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"out"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/pre&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;go&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/v1/items&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cors-check&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
            &lt;span class="c1"&gt;// credentials: "include", // turn on only when you need cookies&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected when CORS is broken&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Console: something like &lt;code&gt;TypeError: Failed to fetch&lt;/code&gt; (Chrome) or a CORS policy message naming &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Network: an &lt;code&gt;OPTIONS&lt;/code&gt; to &lt;code&gt;/v1/items&lt;/code&gt; with status &lt;code&gt;(failed)&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, or &lt;code&gt;404&lt;/code&gt;, or a &lt;code&gt;POST&lt;/code&gt; that returns 200 in Network but still throws in JS because response headers omit CORS allowances.&lt;/li&gt;
&lt;li&gt;You do &lt;strong&gt;not&lt;/strong&gt; see a normal JSON body in the page until both preflight and response headers are correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Expected when CORS is fixed&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Network: &lt;code&gt;OPTIONS&lt;/code&gt; → &lt;code&gt;204&lt;/code&gt; (or &lt;code&gt;200&lt;/code&gt;) with &lt;code&gt;Access-Control-Allow-Origin: https://app.example.com&lt;/code&gt; (exact match).&lt;/li&gt;
&lt;li&gt;Then &lt;code&gt;POST&lt;/code&gt; → your real status (&lt;code&gt;200&lt;/code&gt; / &lt;code&gt;201&lt;/code&gt; / &lt;code&gt;401&lt;/code&gt;…); JS reads the body without a CORS &lt;code&gt;TypeError&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swap hostnames for your staging domains and keep the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API must send
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For a browser SPA on another origin, the API must answer preflight and real responses with an allowlisted &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;, plus methods and headers the SPA actually uses, and &lt;code&gt;Allow-Credentials&lt;/code&gt; only if cookies (or other credentialed mode) are in play.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Minimum response headers for the repro above (no cookies):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Typical value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://app.example.com&lt;/code&gt; (from allowlist, &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;*&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET, POST, PUT, PATCH, DELETE, OPTIONS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Content-Type, Authorization&lt;/code&gt; (mirror what the SPA sends)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Access-Control-Allow-Credentials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; &lt;strong&gt;only&lt;/strong&gt; if you use &lt;code&gt;credentials: 'include'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Access-Control-Max-Age&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;optional; e.g. &lt;code&gt;600&lt;/code&gt; to cache preflight&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Echo the request &lt;code&gt;Origin&lt;/code&gt; when it is in the allowlist. Do not invent a second origin string.&lt;/p&gt;

&lt;h3&gt;
  
  
  Express (allowlist + optional credentials)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// npm i cors@2&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;cors&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowlist&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// local Vite; drop in prod if you prefer&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nf"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// non-browser / same-origin tools may omit Origin&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;allowlist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Origin &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; not allowlisted`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;allowedHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// set true only when cookies / credentialed fetch&lt;/span&gt;
    &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/items&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Manual &lt;code&gt;OPTIONS&lt;/code&gt; (if you prefer not to use &lt;code&gt;cors&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;allowlist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Vary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET,POST,PUT,PATCH,DELETE,OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type, Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// res.setHeader("Access-Control-Allow-Credentials", "true"); // only if needed&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; with the SPA on an allowlisted origin, DevTools shows &lt;code&gt;OPTIONS&lt;/code&gt; 204 and &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; equal to that origin. A non-allowlisted origin stays blocked. That is the point of the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caddy (allowlist + OPTIONS)
&lt;/h3&gt;

&lt;p&gt;Put CORS on the API site block (or in front of a reverse proxy). &lt;strong&gt;Match an exact allowlisted &lt;code&gt;Origin&lt;/code&gt;&lt;/strong&gt;, then echo that value. Do not reflect arbitrary Origins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api.example.com {
    # Exact Origin allowlist (values on one line are OR'd)
    @allowed header Origin https://app.example.com
    # Local Vite: OR localhost onto the same matcher:
    # @allowed header Origin https://app.example.com http://localhost:5173

    @preflight method OPTIONS

    handle @preflight {
        header @allowed {
            Access-Control-Allow-Origin "{http.request.header.Origin}"
            Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            Access-Control-Allow-Headers "Content-Type, Authorization"
            Access-Control-Max-Age "600"
            Vary Origin
            # Access-Control-Allow-Credentials "true"  # only if cookies
        }
        respond 204
    }

    handle {
        header @allowed {
            Access-Control-Allow-Origin "{http.request.header.Origin}"
            Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            Access-Control-Allow-Headers "Content-Type, Authorization"
            Vary Origin
        }
        reverse_proxy 127.0.0.1:3000
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; reflecting any &lt;code&gt;Origin&lt;/code&gt; (no allowlist matcher) is insecure once credentials are on. Any site can trigger credentialed requests against your API. Keep the &lt;code&gt;@allowed&lt;/code&gt; matcher tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; &lt;code&gt;curl -i -X OPTIONS https://api.example.com/v1/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST'&lt;/code&gt; returns &lt;code&gt;204&lt;/code&gt; with &lt;code&gt;Access-Control-Allow-Origin: https://app.example.com&lt;/code&gt;. A non-allowlisted &lt;code&gt;Origin&lt;/code&gt; gets no CORS allowance. The same allowlisted origin in the browser repro then succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credentials gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If the SPA sends cookies (or &lt;code&gt;credentials: 'include'&lt;/code&gt;), the API must return &lt;code&gt;Access-Control-Allow-Credentials: true&lt;/code&gt; and an exact &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;. Never &lt;code&gt;*&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Common own-goal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// SPA&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// API (broken)&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Credentials&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browsers reject that combination. Spec rule: credentialed responses cannot use &lt;code&gt;*&lt;/code&gt;. You must echo the precise requesting origin (&lt;code&gt;https://app.example.com&lt;/code&gt;), and that origin must be on your allowlist.&lt;/p&gt;

&lt;p&gt;Also check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cookie &lt;code&gt;SameSite&lt;/code&gt; / &lt;code&gt;Secure&lt;/code&gt; / domain. CORS success does not fix a cookie the browser refuses to store or send.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Authorization&lt;/code&gt; header alone does &lt;strong&gt;not&lt;/strong&gt; require &lt;code&gt;credentials: 'include'&lt;/code&gt;. Prefer bearer tokens in headers when you can; turn credentials on only for cookie sessions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Confirm steps + failure checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Confirm with DevTools Network: green &lt;code&gt;OPTIONS&lt;/code&gt;, exact &lt;code&gt;Allow-Origin&lt;/code&gt;, then a readable &lt;code&gt;POST&lt;/code&gt; body, then walk the checklist if anything is still red.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hard-refresh the SPA; clear “Disable cache” oddities if you were testing aggressively.&lt;/li&gt;
&lt;li&gt;Filter Network by &lt;code&gt;OPTIONS&lt;/code&gt;: status 2xx/204, headers match allowlist origin.&lt;/li&gt;
&lt;li&gt;Inspect the following &lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;GET&lt;/code&gt;: same &lt;code&gt;Allow-Origin&lt;/code&gt; on the &lt;strong&gt;response&lt;/strong&gt; (preflight alone is not enough for reading the body).&lt;/li&gt;
&lt;li&gt;Console has no CORS &lt;code&gt;TypeError&lt;/code&gt;; your &lt;code&gt;pre&lt;/code&gt; / UI shows the JSON (or a real app error like 401. That is progress).&lt;/li&gt;
&lt;li&gt;Repeat from a second origin that is &lt;strong&gt;not&lt;/strong&gt; allowlisted. It must still fail.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Failure checklist&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;OPTIONS&lt;/code&gt; route.&lt;/strong&gt; Proxy or framework returns 404/405 on preflight; add middleware or Caddy &lt;code&gt;handle @preflight&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Allow-Origin: *&lt;/code&gt; with credentials.&lt;/strong&gt; Browser blocks; switch to exact origin echo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong origin string.&lt;/strong&gt; &lt;code&gt;https://app.example.com&lt;/code&gt; vs &lt;code&gt;https://www.app.example.com&lt;/code&gt; vs trailing slash; allowlist the exact &lt;code&gt;Origin&lt;/code&gt; header value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy stripping headers.&lt;/strong&gt; CDN / nginx / Caddy upstream drops &lt;code&gt;Access-Control-*&lt;/code&gt;; set headers at the edge that the browser sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Allow-Headers&lt;/code&gt; incomplete.&lt;/strong&gt; SPA sends &lt;code&gt;Authorization&lt;/code&gt; or a custom header not listed; preflight fails even when Origin is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only preflight fixed.&lt;/strong&gt; &lt;code&gt;OPTIONS&lt;/code&gt; OK but actual &lt;code&gt;POST&lt;/code&gt; response omits CORS headers; apply the same policy to all methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP vs HTTPS mismatch.&lt;/strong&gt; SPA on &lt;code&gt;https://&lt;/code&gt;, API redirect or mixed content; fix TLS before debugging CORS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached bad preflight.&lt;/strong&gt; Old &lt;code&gt;Max-Age&lt;/code&gt; remembered; try a private window or wait out cache while iterating.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Same OPTIONS wall on another host
&lt;/h2&gt;

&lt;p&gt;Any browser SDK that &lt;code&gt;POST&lt;/code&gt;s from your SPA origin to a different host hits the same preflight rules. Allowlist the SPA origin and set credentials correctly on that host too. If you need a place to stand an ingest host up, the &lt;a href="https://epure.sh/docs/self-hosting/installation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;self-hosting install notes&lt;/a&gt; cover the basics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;What was your dumbest CORS own-goal: &lt;code&gt;*&lt;/code&gt; with credentials, missing OPTIONS route, or a proxy stripping headers?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>node</category>
    </item>
    <item>
      <title>Deploy a Docker app on a VPS with Caddy (keep Postgres off the internet)</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Wed, 23 Sep 2026 16:01:11 +0000</pubDate>
      <link>https://dev.to/amorizz/put-self-hosted-exception-tracking-behind-caddy-keep-postgres-off-the-host-3gpo</link>
      <guid>https://dev.to/amorizz/put-self-hosted-exception-tracking-behind-caddy-keep-postgres-off-the-host-3gpo</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Most side projects die the same way on a cheap VPS: the app is up, &lt;strong&gt;Postgres is on &lt;code&gt;:5432&lt;/code&gt;&lt;/strong&gt;, and HTTPS is “later.”&lt;/li&gt;
&lt;li&gt;The boring fix is one Compose file: &lt;strong&gt;Caddy on 80/443&lt;/strong&gt;, your app on the Docker network, &lt;strong&gt;database unpublished&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Copy the stack below, point DNS, prove &lt;code&gt;https://your.domain/health&lt;/code&gt; (or &lt;code&gt;/&lt;/code&gt;), then ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why your first VPS deploy feels cursed
&lt;/h2&gt;

&lt;p&gt;You can &lt;code&gt;docker compose up&lt;/code&gt; on a laptop in five minutes. On a public IP the same file often means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The database is reachable from the internet&lt;/strong&gt; (you published &lt;code&gt;5432&lt;/code&gt; “just for debugging”).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No real HTTPS&lt;/strong&gt; — you tested on &lt;code&gt;http://IP:3000&lt;/code&gt; and called it done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in the repo&lt;/strong&gt; — &lt;code&gt;.env.example&lt;/code&gt; values made it to production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is exotic. It is the default path for a Node/Go/Python API with Postgres. This guide is the minimum production shape that fixes those three without Kubernetes.&lt;/p&gt;

&lt;p&gt;You will end with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → :443 Caddy (TLS) → app:8080 (Docker net)
                              → db:5432 (Docker net only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only Caddy listens on the public host. Everything else stays inside Compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS (Ubuntu 22.04/24.04 is fine) with a public IPv4&lt;/li&gt;
&lt;li&gt;A domain (or subdomain) whose &lt;strong&gt;A record&lt;/strong&gt; points at that IP&lt;/li&gt;
&lt;li&gt;Docker Engine + Compose plugin installed&lt;/li&gt;
&lt;li&gt;Ports &lt;strong&gt;80&lt;/strong&gt; and &lt;strong&gt;443&lt;/strong&gt; open (and &lt;strong&gt;22&lt;/strong&gt; for SSH). Do not open 5432.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Docker is missing:&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;# official convenience script — or use your distro’s docker.io + compose plugin&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# log out/in, then:&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The stack (app + Postgres + Caddy)
&lt;/h2&gt;

&lt;p&gt;Create a project folder:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/apps/myapp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/apps/myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;docker-compose.yml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Replace the &lt;code&gt;app&lt;/code&gt; image with yours. The important bits are the &lt;strong&gt;network&lt;/strong&gt;, &lt;strong&gt;no DB publish&lt;/strong&gt;, and &lt;strong&gt;Caddy as the only public ports&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&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;caddy:2.8&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;span class="na"&gt;ports&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;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&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;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&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;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-registry/your-app:1.2.3&lt;/span&gt;   &lt;span class="c1"&gt;# pin a tag — avoid :latest in prod&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;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;.env&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://app:${POSTGRES_PASSWORD}@db:5432/app&lt;/span&gt;
      &lt;span class="na"&gt;PUBLIC_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-fsS"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8080/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# no ports: — Caddy reaches it as http://app:8080 on the Docker network&lt;/span&gt;

  &lt;span class="na"&gt;db&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;postgres:16&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;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&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;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# CRITICAL: no ports: section — Postgres stays off the host&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;Caddyfile&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.example.com {
  encode gzip
  reverse_proxy app:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy talks to the &lt;strong&gt;service name&lt;/strong&gt; &lt;code&gt;app&lt;/code&gt; on the shared Compose network. Do &lt;strong&gt;not&lt;/strong&gt; proxy to &lt;code&gt;localhost&lt;/code&gt; here — inside the Caddy container, localhost is Caddy itself.&lt;/p&gt;

&lt;p&gt;Persist &lt;code&gt;/data&lt;/code&gt; (the &lt;code&gt;caddy_data&lt;/code&gt; volume). Without it, every recreate re-issues certificates and can burn Let’s Encrypt rate limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;.env&lt;/code&gt; (on the server only)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# put the same value in .env — chmod 600 .env — never commit it&lt;/span&gt;
&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="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'POSTGRES_PASSWORD=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point DNS for &lt;code&gt;app.example.com&lt;/code&gt; at the VPS &lt;strong&gt;before&lt;/strong&gt; you expect a trusted cert. While debugging certs, you can temporarily add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…so a broken config does not eat your weekly production quota. Remove staging when it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring it up
&lt;/h2&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;
docker compose ps
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: &lt;code&gt;caddy&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, and &lt;code&gt;db&lt;/code&gt; healthy. Prove the boundary:&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;# public path&lt;/span&gt;
curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://app.example.com/health

&lt;span class="c"&gt;# Postgres must NOT be on the host&lt;/span&gt;
ss &lt;span class="nt"&gt;-lntp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;5432 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"good: nothing on host :5432"&lt;/span&gt;
docker compose port db 5432 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"good: db has no published port"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;/health&lt;/code&gt; is not your route, hit &lt;code&gt;/&lt;/code&gt; or whatever your app exposes. The point is: &lt;strong&gt;TLS works on the public name&lt;/strong&gt;, and &lt;strong&gt;5432 is invisible from the host&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firewall (keep it boring)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not &lt;code&gt;ufw allow 5432&lt;/code&gt;. Note: Docker’s published ports can bypass UFW on some setups — another reason to &lt;strong&gt;never publish the DB&lt;/strong&gt; in Compose at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates without drama
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull app
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; app
docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin image tags. Back up &lt;code&gt;postgres_data&lt;/code&gt; (and &lt;code&gt;caddy_data&lt;/code&gt; if you care about certs surviving a wipe) before you treat the VPS as disposable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# example volume backup idea — adapt to your backup tool&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; myapp_postgres_data:/data &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/backup alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/postgres_data.tgz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Confirm checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Only &lt;strong&gt;80/443&lt;/strong&gt; (and SSH) intentional on the host&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;https://your.domain&lt;/code&gt; works; HTTP redirects to HTTPS&lt;/li&gt;
&lt;li&gt;[ ] App reached via &lt;strong&gt;service name&lt;/strong&gt; from Caddy, not a published app port&lt;/li&gt;
&lt;li&gt;[ ] Postgres &lt;strong&gt;not&lt;/strong&gt; published (&lt;code&gt;docker compose ps&lt;/code&gt; / no host &lt;code&gt;:5432&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;.env&lt;/code&gt; is &lt;code&gt;chmod 600&lt;/code&gt;, not in git&lt;/li&gt;
&lt;li&gt;[ ] Named volumes for DB + Caddy data&lt;/li&gt;
&lt;li&gt;[ ] Healthcheck so Caddy does not proxy a migrating app forever&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure checklist (≥5)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Published &lt;code&gt;5432&lt;/code&gt; “temporarily”&lt;/strong&gt; — scanners do not care that it was temporary. Remove the publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reverse_proxy localhost:8080&lt;/code&gt; inside Caddy-in-Docker&lt;/strong&gt; — wrong localhost. Use &lt;code&gt;app:8080&lt;/code&gt; on the Compose network (or publish the app on &lt;code&gt;127.0.0.1&lt;/code&gt; and run Caddy on the host).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS not pointed yet&lt;/strong&gt; — ACME fails; check &lt;code&gt;docker compose logs caddy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burned Let’s Encrypt quota&lt;/strong&gt; — use staging CA while iterating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;:latest&lt;/code&gt; in prod&lt;/strong&gt; — surprise breakage on pull day. Pin tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reused a laptop volume on the server&lt;/strong&gt; — passwords and schemas diverge; prefer a fresh volume for first prod boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App still published on &lt;code&gt;0.0.0.0:3000&lt;/code&gt;&lt;/strong&gt; — you bypassed Caddy and skipped TLS. Drop the publish or bind &lt;code&gt;127.0.0.1&lt;/code&gt; only if Caddy is on the host.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this pattern shows up next
&lt;/h2&gt;

&lt;p&gt;Same shape — HTTPS edge, app private, Postgres private — applies to dashboards, APIs, and self-hosted tools. One public example of that two-container-behind-proxy idea for &lt;strong&gt;exception ingest&lt;/strong&gt; (official Sentry SDKs + DSN host change, not APM/replay): &lt;a href="https://epure.sh/docs/self-hosting/installation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sunday" rel="noopener noreferrer"&gt;self-hosting installation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;When you first put a side project on a VPS, what bit you first — open Postgres, missing TLS, or secrets in git?&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Point the official Sentry SDK at self-hosted ingest (DSN only)</title>
      <dc:creator>Amorizz</dc:creator>
      <pubDate>Sun, 20 Sep 2026 10:00:44 +0000</pubDate>
      <link>https://dev.to/amorizz/point-the-official-sentry-sdk-at-self-hosted-ingest-dsn-only-3ij2</link>
      <guid>https://dev.to/amorizz/point-the-official-sentry-sdk-at-self-hosted-ingest-dsn-only-3ij2</guid>
      <description>&lt;p&gt;You don’t need a forked Sentry SDK. Keep &lt;code&gt;@sentry/browser&lt;/code&gt; or &lt;code&gt;@sentry/node&lt;/code&gt;, change only &lt;code&gt;dsn&lt;/code&gt;, then prove &lt;code&gt;/health&lt;/code&gt; → HTTP &lt;code&gt;202&lt;/code&gt; → Issues. Two containers (app + Postgres 16); leave replay, traces, and profiles at sample rate &lt;code&gt;0&lt;/code&gt; — those envelope items are out of scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Clone and &lt;code&gt;docker compose up&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;GET /health&lt;/code&gt; → &lt;code&gt;{"status":"ok"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Install a pinned official SDK (&lt;code&gt;7.120.0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Sentry.init&lt;/code&gt; with your DSN and zeroed unused sample rates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;captureException&lt;/code&gt; and expect ingest &lt;code&gt;202&lt;/code&gt;, then an Issues row&lt;/li&gt;
&lt;li&gt;Optional: raw &lt;code&gt;store&lt;/code&gt; / &lt;code&gt;envelope&lt;/code&gt; curls if the SDK path is noisy&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Two containers, one health check
&lt;/h2&gt;

&lt;p&gt;Clone the public tree and start compose from the repo root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/epure-sh/epure.git
&lt;span class="nb"&gt;cd &lt;/span&gt;epure
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two services: the app image and &lt;code&gt;postgres:16-alpine&lt;/code&gt;. No Redis. No separate worker fleet on this laptop path.&lt;/p&gt;

&lt;p&gt;If port &lt;code&gt;8080&lt;/code&gt; is taken:&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;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# set EPURE_PORT and EPURE_PUBLIC_URL together, then recreate the app container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for health:&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;-sS&lt;/span&gt; http://localhost:8080/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected body:&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&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;Open &lt;code&gt;http://localhost:8080&lt;/code&gt;. Register in the UI, or for local smoke only (never production):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/seed-dev.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Copy the DSN (only thing that changes)
&lt;/h2&gt;

&lt;p&gt;From the dashboard: Settings → SDK connection (or the setup checklist). Create a key if none exists. Shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://{public_key}@localhost:8080/{project_id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrating from Sentry SaaS? Keep the same &lt;code&gt;init&lt;/code&gt; options. Replace only &lt;code&gt;dsn&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Init once, throw once
&lt;/h2&gt;

&lt;p&gt;Pin the fixtures the public docs exercise:&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;# browser SPA&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @sentry/browser@7.120.0

&lt;span class="c"&gt;# or Node API / worker&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @sentry/node@7.120.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browser example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://YOUR_PUBLIC_KEY@localhost:8080/YOUR_PROJECT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-app@0.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tracesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;profilesSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;replaysSessionSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;replaysOnErrorSampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Epure quickstart test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node is the same shape with &lt;code&gt;@sentry/node&lt;/code&gt;. Set unused sample rates to &lt;code&gt;0&lt;/code&gt;. Transactions, session replay, and profiles are discarded on an exception-only host — zeroing them stops the SDK from sending work you will never see in Issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm 202, then Issues
&lt;/h2&gt;

&lt;p&gt;Ingest accepts before the Issues row appears. A successful SDK or store/envelope POST returns HTTP &lt;code&gt;202&lt;/code&gt; with a body like:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"660e8400-e29b-41d4-a716-446655440001"&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;Refresh &lt;code&gt;http://localhost:8080&lt;/code&gt;. Match the environment filter chip to &lt;code&gt;Sentry.init&lt;/code&gt; (&lt;code&gt;local&lt;/code&gt; in the example). You want an unresolved issue for &lt;code&gt;Error&lt;/code&gt; / &lt;code&gt;Epure quickstart test&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Empty feed copy looks like &lt;code&gt;0 UNRESOLVED EXCEPTIONS&lt;/code&gt;. That alone is not proof of failure — see the checklist below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: curl without an SDK
&lt;/h2&gt;

&lt;p&gt;Useful when you need to separate “SDK wiring” from “host ingest.” Seed IDs below match &lt;code&gt;./scripts/seed-dev.sh&lt;/code&gt; in the public docs. Change host if &lt;code&gt;EPURE_PORT&lt;/code&gt; is not &lt;code&gt;8080&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Legacy store JSON:&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;-sS&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/epure-ingest.json &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:8080/api/550e8400-e29b-41d4-a716-446655440000/store/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Sentry-Auth: Sentry sentry_version=7, sentry_key=a1b2c3d4e5f6g7h8i9j0, sentry_secret=supersecretdevkey"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "platform": "javascript",
  "exception": {
    "values": [{ "type": "Error", "value": "Epure try-it" }]
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;HTTP/1.1 202 Accepted&lt;/code&gt; and an &lt;code&gt;id&lt;/code&gt; in the JSON body.&lt;/p&gt;

&lt;p&gt;Envelope path (same auth, binary body from the repo):&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;-sS&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/epure-envelope.json &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:8080/api/550e8400-e29b-41d4-a716-446655440000/envelope/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Sentry-Auth: Sentry sentry_version=7, sentry_key=a1b2c3d4e5f6g7h8i9j0, sentry_secret=supersecretdevkey"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-sentry-envelope"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @fixtures/sentry/browser/envelope.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Endpoints that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/{project_id}/store/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/{project_id}/envelope/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When 202 is a lie
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;202&lt;/code&gt; means auth + schema accepted. It is non-blocking. It does not mean “row visible in Issues yet.”&lt;/p&gt;

&lt;p&gt;Checklist when the feed stays empty after a &lt;code&gt;202&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wait, then refresh&lt;/strong&gt; — accept is ahead of the worker. One immediate refresh is not final. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment chip&lt;/strong&gt; — UI filter must match &lt;code&gt;environment&lt;/code&gt; in &lt;code&gt;Sentry.init&lt;/code&gt; (&lt;code&gt;local&lt;/code&gt; in the examples).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project / DSN mismatch&lt;/strong&gt; — open the project that owns the key you put in &lt;code&gt;dsn&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong host or port&lt;/strong&gt; — &lt;code&gt;EPURE_PUBLIC_URL&lt;/code&gt; / &lt;code&gt;EPURE_PORT&lt;/code&gt; drift after a &lt;code&gt;.env&lt;/code&gt; change without recreating the app container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK still talking to SaaS&lt;/strong&gt; — leftover &lt;code&gt;dsn&lt;/code&gt;, or a second &lt;code&gt;Sentry.init&lt;/code&gt; later in boot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If store curl returns &lt;code&gt;202&lt;/code&gt; but the SDK path does not, debug the client. If both miss &lt;code&gt;202&lt;/code&gt;, debug the host (&lt;code&gt;docker compose logs&lt;/code&gt; on the app service, health, DSN key).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this host is not
&lt;/h2&gt;

&lt;p&gt;Exception-only lane. Do not expect APM, session replay, profiling, log product, or mobile SDKs to “just work” because the DSN looks familiar. JS/TS source maps are a separate verify step (upload + prove demangled frames) — out of scope here.&lt;/p&gt;

&lt;p&gt;Do not write “100% Sentry compatible” in a PR description or a README badge. Honest subset: official SDK, DSN swap, exceptions, envelope/store.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does Sentry JS 8.x work?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Often yes. CI fixtures are &lt;code&gt;7.120.0&lt;/code&gt;. On v8+, set &lt;code&gt;enableTracing: false&lt;/code&gt; when the option exists, and keep traces / profiles / replay sample rates at &lt;code&gt;0&lt;/code&gt;. Pin &lt;code&gt;7.120.0&lt;/code&gt; if you want the same line the docs exercise. Confirm with a real throw, not only a green install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Python or curl only?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Yes. Envelope and store accept official Sentry SDKs and raw JSON. Store is the smallest wire path. Phase 1 demangle stays JS/TS only, so frames stay as sent for other runtimes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. Two containers for this path: app + Postgres 16.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laptop compose in production?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. Use the production overlay and a real &lt;code&gt;.env&lt;/code&gt; from &lt;code&gt;.env.production.example&lt;/code&gt;. Seed script and example DB passwords are local smoke only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical docs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Quickstart (this flow): &lt;a href="https://epure.sh/docs/get-started/quickstart" rel="noopener noreferrer"&gt;https://epure.sh/docs/get-started/quickstart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install / Postgres 16 / prod overlay: &lt;a href="https://epure.sh/docs/self-hosting/installation" rel="noopener noreferrer"&gt;https://epure.sh/docs/self-hosting/installation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those URLs 404 someday, the commands above still stand: health JSON, store/envelope &lt;code&gt;202&lt;/code&gt;, DSN host swap, sample rates at &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;After ingest returns &lt;code&gt;202&lt;/code&gt; but Issues stays empty — do you check the environment chip, project/DSN match, or worker lag first?&lt;/p&gt;

</description>
      <category>sentry</category>
      <category>docker</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
