<?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: Chalom Ellezam</title>
    <description>The latest articles on DEV Community by Chalom Ellezam (@chalom_ellezam_5989bce65e).</description>
    <link>https://dev.to/chalom_ellezam_5989bce65e</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%2F3849936%2F958b9107-f1c7-43d7-b3ac-90788b7e70fd.png</url>
      <title>DEV Community: Chalom Ellezam</title>
      <link>https://dev.to/chalom_ellezam_5989bce65e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chalom_ellezam_5989bce65e"/>
    <language>en</language>
    <item>
      <title>Stop sending every alert to Slack. The 4-channel routing matrix I built after 18 months of being paged at the wrong time.</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:29:37 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/stop-sending-every-alert-to-slack-the-4-channel-routing-matrix-i-built-after-18-months-of-being-4e1a</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/stop-sending-every-alert-to-slack-the-4-channel-routing-matrix-i-built-after-18-months-of-being-4e1a</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am a senior backend tech lead in Paris and I run Belmo, a small European PaaS. This article mentions Belmo once near the end. Everything else is platform-agnostic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is 02:47 on a Tuesday. My phone is silent. Stripe's webhook signature has been failing for two hours, every paid signup is queueing into a retry table that nobody is draining, and the alert sits in a Slack channel I last opened on Friday at 18h. I find out at 09:14 when a customer emails asking why his subscription is "stuck on pending".&lt;/p&gt;

&lt;p&gt;That morning I rebuilt my routing. Eighteen months and roughly 4,000 alerts later, the version below is what stuck. None of it is platform-specific. The mistake I made, and that I see in almost every solo founder I review, is treating "alerts" as one thing that goes to one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why piping everything into one channel stops working around month three
&lt;/h2&gt;

&lt;p&gt;When you ship your first MVP, you have maybe five alerts a week. They fit in one Slack channel called &lt;code&gt;#alerts&lt;/code&gt;, you open it when you sit at your desk, and life is good. Around month three something changes. You added Sentry, a status check, a cron monitor, a payment-failure log, an LLM cost-spike detector. Now you have 50 alerts a week. Most are noise, a few are urgent, and one of them is the one bleeding money right now.&lt;/p&gt;

&lt;p&gt;Slack rewards triage during business hours. After hours it is no better than email, because nobody opens Slack at 23h to "check the alerts channel". Telegram on the other hand pushes notifications past Do Not Disturb if you let it, which is exactly what you want for a P0 and exactly what you do not want for a deploy-finished ping. Email is great for digests and awful for anything time-sensitive. Discord is where your community lives, which means alerts there either get drowned by &lt;code&gt;#general&lt;/code&gt; or read by users you did not intend.&lt;/p&gt;

&lt;p&gt;So the question is never "which channel is best". It is "which alert belongs in which channel". Every production alert is answering four questions. Does this need to wake me up? Does it need a human in the next five minutes? Is it for the team, or only for me? And is the content sensitive (DB errors with row data, customer emails, Stripe events)?&lt;/p&gt;

&lt;p&gt;Once you answer those four, the channel picks itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four channels, evaluated by what they actually do at 03h
&lt;/h2&gt;

&lt;p&gt;I want to be specific, because most "Slack vs Telegram" posts compare features in a vacuum. Here is what each channel actually does to your sleep and your inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack.&lt;/strong&gt; Slack is great for team-facing alerts that need context (a thread, a screenshot, a back-and-forth). It is terrible for waking a single human up. Its mobile notifications respect your phone's Do Not Disturb, which is what 99% of users want, but it means a 03h alert in Slack will not ring your phone unless you specifically configured a high-priority keyword for it. Slack is where your team triages incidents the next morning. It is not where you find out the site is down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telegram bot DMs.&lt;/strong&gt; A Telegram bot that messages you directly (not a group chat, a 1:1 DM with the bot) is the cheapest pager you will ever build. It bypasses DND if you whitelist the contact, the API is one &lt;code&gt;curl&lt;/code&gt; away, there is no SaaS bill, and the message lands on your lock screen with a sound. The downside: it is private to you. Anything you want the team to see in the morning has to go elsewhere too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discord.&lt;/strong&gt; Discord is what your community is in, and that is a feature for community-facing alerts (a "new signup" cheer in a founder Slack, a deploy log in a build channel) and a bug for anything else. Webhook posting is one line, but if your alert channel lives in the same server as your users, accidents happen. Use a separate, private Discord server for ops if you go this route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email.&lt;/strong&gt; Email is excellent for one thing: digests. The daily 09h summary, the weekly cost report, the "here are 12 things that happened overnight that you should know about but did not need to act on". It is awful for anything that needs a response in less than four hours, because by then it is already buried under 30 newer emails.&lt;/p&gt;

&lt;p&gt;I deliberately leave SMS and PagerDuty off this list. SMS costs money per message and rate-limits at the worst time. PagerDuty is excellent and also overkill for a solo founder with two services. If you are a team of five with on-call rotation, add PagerDuty. Below that, Telegram-as-pager is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix that picks the channel for you
&lt;/h2&gt;

&lt;p&gt;Here is the routing logic I run for every project I ship now. It maps a single severity tag (&lt;code&gt;P0&lt;/code&gt;, &lt;code&gt;P1&lt;/code&gt;, &lt;code&gt;P2&lt;/code&gt;, &lt;code&gt;P3&lt;/code&gt;) and an audience flag (&lt;code&gt;me&lt;/code&gt;, &lt;code&gt;team&lt;/code&gt;, &lt;code&gt;community&lt;/code&gt;) to a channel.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Audience&lt;/th&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;P0&lt;/td&gt;
&lt;td&gt;Site down, payments failing, data loss in progress&lt;/td&gt;
&lt;td&gt;me&lt;/td&gt;
&lt;td&gt;Telegram bot DM (+ optional SMS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P1&lt;/td&gt;
&lt;td&gt;Degraded, urgent, customer-visible within 1h&lt;/td&gt;
&lt;td&gt;me&lt;/td&gt;
&lt;td&gt;Telegram bot DM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P2&lt;/td&gt;
&lt;td&gt;Anomaly, needs eyes today, not bleeding&lt;/td&gt;
&lt;td&gt;team&lt;/td&gt;
&lt;td&gt;Slack (or private Discord)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P3&lt;/td&gt;
&lt;td&gt;FYI, monitoring noise, "we processed 412 jobs"&lt;/td&gt;
&lt;td&gt;me&lt;/td&gt;
&lt;td&gt;Email digest, daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P3&lt;/td&gt;
&lt;td&gt;Deploy finished, build succeeded, new signup&lt;/td&gt;
&lt;td&gt;community/team&lt;/td&gt;
&lt;td&gt;Discord or Slack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thing this matrix forces you to do is tag every alert at the source. You no longer write "if (error) sendToSlack". You write &lt;code&gt;sendAlert({ severity: "P0", audience: "me", message: ... })&lt;/code&gt; and a tiny router decides where it lands. Severity tagging is the unsexy work that pays off the most. It is the same discipline that PagerDuty enforces with their incident levels, but you do not need to pay PagerDuty to enforce it on yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-line router that does all of this
&lt;/h2&gt;

&lt;p&gt;Here is the Node.js version. Python and Go are trivial translations. The thing I want you to see is how short it is. Most founders assume "alert routing" is a project. It is a function.&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;// alerts.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TELEGRAM_TOKEN&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;TELEGRAM_BOT_TOKEN&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;TELEGRAM_CHAT_ID&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;TELEGRAM_CHAT_ID&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;SLACK_WEBHOOK&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;SLACK_WEBHOOK_URL&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;DISCORD_WEBHOOK&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;DISCORD_WEBHOOK_URL&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;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.telegram.org/bot&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage`&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;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;parse_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Markdown&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="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;sendSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;await&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;SLACK_WEBHOOK&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="nx"&gt;text&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendDiscord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;await&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;DISCORD_WEBHOOK&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;// Email digest is just an INSERT into a daily_digest table.&lt;/span&gt;
&lt;span class="c1"&gt;// A cron at 09h flushes it. No external service needed.&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;queueEmailDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&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 daily_digest (created_at, body) VALUES (now(), $1)&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;text&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;export&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;sendAlert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audience&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&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;severity&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;message&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;severity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P1&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;return&lt;/span&gt; &lt;span class="nf"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P2&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;return&lt;/span&gt; &lt;span class="nx"&gt;audience&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sendSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P3&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="nx"&gt;audience&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;community&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;audience&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team&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;return&lt;/span&gt; &lt;span class="nf"&gt;sendDiscord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;return&lt;/span&gt; &lt;span class="nf"&gt;queueEmailDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;Every alert in your code now becomes a single function call with three keys. The router takes care of the rest. The two extra lines of discipline (tagging severity and audience at the call site) are what gives you back your weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three routing mistakes that cost me real sleep
&lt;/h2&gt;

&lt;p&gt;I do not want this post to read as if I figured this out in a meeting. I figured it out by getting it wrong, three different ways, on three different projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake one: routing P0 to Slack because "the team is there".&lt;/strong&gt; This is the 03h Stripe-webhook story above. The team is there but the team is asleep, and Slack is asleep with them. P0 means "wake one human up", and that human is you. Slack will not. A Telegram bot will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake two: routing everything to Telegram because "it works".&lt;/strong&gt; I did this for six weeks after the Stripe incident. By week three I had alert fatigue so badly that I silenced the bot. By week five I missed a real P0 because I had trained myself to ignore the buzz. Alerts have a budget. Demote ruthlessly. If it is not actionable within an hour, it does not go to Telegram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake three: no dedup window.&lt;/strong&gt; A failing cron that runs every minute will send you 1,440 identical Telegram messages in a day. The fix is two lines: hash the alert message, look up the hash in Redis or a small &lt;code&gt;sent_alerts&lt;/code&gt; table, skip if seen in the last 30 minutes. I added this after the cron that watches my email worker spammed me with 600 messages on a Sunday. The number to remember is 30 minutes: long enough to suppress noise, short enough that a real incident gets a fresh ping if it persists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built around all of this
&lt;/h2&gt;

&lt;p&gt;The reason I think about this so much: I built Belmo's monitoring around exactly this routing instinct. Past clients like BeReal had whole observability teams writing these routers in-house. The smaller clients I review on the side had none of it. Alerts went to one Slack channel, the channel got muted in week six, then a customer complaint became the monitoring system. So I built an AI watcher that reads logs, classifies anomalies into severities (retry loops, token spikes, hot Sentry fingerprints, silent cron failures), and pushes the P0s and P1s straight to a Telegram bot. The free tier never sleeps, which matters specifically because the alerting layer must not be the thing that fails. That is the whole pitch. You do not need Belmo for any of this to work, only a tagging discipline and the 30-line router.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight, regardless of which platform you ship on
&lt;/h2&gt;

&lt;p&gt;Seven steps, in order, none of them more than 10 minutes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open every place an alert currently fires in your code. Count them. If you do not know the number, that is the first finding.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;severity&lt;/code&gt; and &lt;code&gt;audience&lt;/code&gt; argument to your existing alert function. Default both to safe values (&lt;code&gt;P2&lt;/code&gt;, &lt;code&gt;me&lt;/code&gt;) so nothing breaks.&lt;/li&gt;
&lt;li&gt;For every existing alert, set the severity. Be honest. Most of what you have today is P2 or P3.&lt;/li&gt;
&lt;li&gt;Set up the Telegram bot. There is a whole post on this earlier in the series (#4 below). Total time, 5 minutes.&lt;/li&gt;
&lt;li&gt;Set up a daily digest table and a 09h cron to email yourself everything that hit P3 during the night. This is the alert-fatigue antidote.&lt;/li&gt;
&lt;li&gt;Add the 30-minute dedup window. Two lines and a Redis key, or a row in Postgres with a unique constraint on the hash and an expiry.&lt;/li&gt;
&lt;li&gt;Test it. Throw a fake P0 at the router. Make sure your phone buzzes when it should and does not when it should not. Then go to sleep.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I still get wrong
&lt;/h2&gt;

&lt;p&gt;The matrix above is not right for every team. If you are at five engineers with on-call rotation, PagerDuty pays for itself within a quarter. If your alerts contain customer PII, Telegram bots are not where you want them landing (encrypted email or self-hosted Mattermost is better). And there is a category I have not solved well: alerts that need both wake-up and team triage. Today I send those to Telegram first, then re-post the resolved post-mortem to Slack the next morning. Two steps where one would be better.&lt;/p&gt;

&lt;p&gt;The question I want to leave you with: which channel is your phone showing right now, and how many of those alerts have you ignored for more than 48 hours? The answer tells you which severity is in the wrong place. Drop the count in the comments. I will publish whatever pattern I see most often next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Previous posts in this series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Telegram alerts for any production app, a 5-minute setup.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;How I built a Discord 'ship-tracker' bot in a weekend.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I migrated 12 client projects off Heroku. Here's the playbook.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;The Claude Code to production checklist: 15 things that aren't obvious.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your Stripe webhook is going to silently drop a paid customer.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I deployed 12 vibe-coded apps to production. The same 6 things broke every single time.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your .env file is probably already in your Git history.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your Postgres will die at 50 concurrent users, not 50,000.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your indie SaaS has no staging environment. Here is the 30-minute setup.&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devops</category>
      <category>observability</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your indie SaaS has no staging environment. Here is the 30-minute setup (and the 4 production bugs it catches before your users do).</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:27:44 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-no-staging-environment-here-is-the-30-minute-setup-and-the-4-production-bugs-4ef1</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-no-staging-environment-here-is-the-30-minute-setup-and-the-4-production-bugs-4ef1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am a senior backend tech lead in Paris and I run Belmo, a small European PaaS. This article mentions Belmo once near the end. Everything else works on any platform you ship on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A founder I review code for shipped a "two-line fix" to her Lovable-built waitlist app at 21h on a Friday. The deploy went green, the homepage rendered. By Saturday morning, none of the new signups were being saved. A migration she had run against her local DB a week earlier had silently been included in the deploy. Production was on schema v3, the code was talking to schema v4.&lt;/p&gt;

&lt;p&gt;She did not have a staging environment. Nobody on this kind of project ever does, and yet every solo founder I review eventually loses a weekend to a bug that staging would have caught at the cost of 30 minutes of setup.&lt;/p&gt;

&lt;p&gt;This is the version of the setup I now recommend. It does not require Kubernetes, a DevOps hire, or doubling your hosting bill. It catches exactly the kind of bugs that bite when you ship fast and your "test environment" is your laptop with the dev server running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why most solo founders skip staging
&lt;/h2&gt;

&lt;p&gt;The canonical advice for staging was written for teams of ten. It assumes a Terraform repo, a CI matrix, separate AWS sub-accounts, and a QA engineer who runs the test suite. Nobody building a solo SaaS in 2026 has any of those. So the founder reads the advice, decides it is not for them, and ships straight from &lt;code&gt;main&lt;/code&gt; to production. They are not wrong about the canonical advice. They are wrong about the conclusion.&lt;/p&gt;

&lt;p&gt;The 90% version of staging is much lighter. A separate branch, a separate web service running the code on that branch, a separate database, a separate set of secrets, and a separate URL. That is it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four bugs staging actually catches
&lt;/h2&gt;

&lt;p&gt;I want to be specific about what staging buys you, because most of what people imagine staging does (catching logic bugs, validating UX) is better done elsewhere. Here is what staging uniquely catches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One, schema drift.&lt;/strong&gt; Your local dev DB has a column &lt;code&gt;email_lower&lt;/code&gt; that your prod DB does not. You shipped a query that uses it. Production crashes. This happens constantly when a founder runs &lt;code&gt;prisma migrate dev&lt;/code&gt; against the local DB and forgets to push the migration. Staging, which runs the same migration command on a real network DB at deploy time, catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two, environment-variable assumptions.&lt;/strong&gt; Your code reads &lt;code&gt;process.env.STRIPE_KEY&lt;/code&gt;. Locally it lives in &lt;code&gt;.env.local&lt;/code&gt;. In production you set it in your platform's UI. In staging, you forgot. Catching this on staging is two minutes. Catching it on prod is a refund email and a tweet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three, cold-start and platform-specific failures.&lt;/strong&gt; Your code works locally because Node never restarts. It breaks in production because the platform recycles containers and your in-memory session store is empty for the first request. Staging on the same platform as prod reproduces the cold-start the second you deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four, webhook and OAuth callback URL mismatches.&lt;/strong&gt; Your Stripe webhook is pointed at &lt;code&gt;prod.example.com/webhook&lt;/code&gt;. Your local &lt;code&gt;.env&lt;/code&gt; has &lt;code&gt;localhost:3000/webhook&lt;/code&gt;. Staging exposes whatever URL is hardcoded somewhere it should not be, because the staging app gets a different domain from prod.&lt;/p&gt;

&lt;p&gt;If you have ever shipped a fix on Friday and woken up Saturday to one of these, you already paid the staging tax. You just paid it as a bug instead of as 30 minutes of setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-minute setup
&lt;/h2&gt;

&lt;p&gt;Five pieces. None of them require a YAML file longer than a haiku.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A separate branch (3 minutes)
&lt;/h3&gt;

&lt;p&gt;Create a long-lived &lt;code&gt;staging&lt;/code&gt; branch in your repo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; staging
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule from now on: feature branches merge into &lt;code&gt;staging&lt;/code&gt;, you smoke-test on the staging URL, then &lt;code&gt;staging&lt;/code&gt; merges into &lt;code&gt;main&lt;/code&gt; which deploys to production. Most clients I migrate already think they have this workflow but actually do not, because they merge feature branches directly into &lt;code&gt;main&lt;/code&gt; and skip the staging hop.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A separate web service (5 minutes)
&lt;/h3&gt;

&lt;p&gt;In your platform of choice (Render, Fly, Railway, Vercel, or anything else that deploys from GitHub), create a second service that deploys from the &lt;code&gt;staging&lt;/code&gt; branch. Same repo, same build command, different branch.&lt;/p&gt;

&lt;p&gt;If you are on a platform that exposes a YAML config, the diff is one line:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-staging&lt;/span&gt;
    &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
    &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;starter&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-prod&lt;/span&gt;
    &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hobby&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are on a UI-based platform, you click "New service," select the same repo, change the branch to &lt;code&gt;staging&lt;/code&gt;, and you are done.&lt;/p&gt;

&lt;p&gt;The cost is usually $0 or close to it. Most platforms have a free or near-free tier for the staging service because staging traffic is tiny. If prod runs on a $19 plan, staging usually runs on a $0 or $7 plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A separate database (8 minutes)
&lt;/h3&gt;

&lt;p&gt;This is the part founders cut and later regret. The point of staging is to catch schema drift, and schema drift cannot be caught if staging and prod share the same database. They must be separate physical databases.&lt;/p&gt;

&lt;p&gt;Spin up a new Postgres instance, same provider as prod, smallest plan. Give it a sensible name like &lt;code&gt;myapp-staging-db&lt;/code&gt;. Copy the production schema once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;--schema-only&lt;/span&gt; &lt;span class="nv"&gt;$PROD_URL&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; schema.sql
psql &lt;span class="nv"&gt;$STAGING_URL&lt;/span&gt; &amp;lt; schema.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then seed it with fake data, never with a copy of production. You will be tempted to copy prod because then "it looks real." Do not. Copying prod into staging is how PII leaks happen. Use a seed script.&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;// scripts/seed-staging.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@prisma/client&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;db&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;PrismaClient&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&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="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;staging-1@example.test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&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;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;staging-2@example.test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&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="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="s1"&gt;Test project A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ownerEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;staging-1@example.test&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="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;finally&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$disconnect&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 once after creating the staging DB. Done.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Separate secrets (7 minutes)
&lt;/h3&gt;

&lt;p&gt;This is where most "staging environments" leak into production. The founder sets up staging, copies the production env vars into the staging service, and now staging is talking to the live Stripe account, the live Resend account, and the live OpenAI key. A confused click on staging sends a real email to a real customer.&lt;/p&gt;

&lt;p&gt;The rule is: every external service has a test or sandbox version. Use the sandbox version everywhere on staging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Production env vars
&lt;/span&gt;&lt;span class="n"&gt;STRIPE_SECRET_KEY&lt;/span&gt;=&lt;span class="n"&gt;sk_live_xxx&lt;/span&gt;
&lt;span class="n"&gt;RESEND_API_KEY&lt;/span&gt;=&lt;span class="n"&gt;re_prod_xxx&lt;/span&gt;
&lt;span class="n"&gt;OPENAI_API_KEY&lt;/span&gt;=&lt;span class="n"&gt;sk&lt;/span&gt;-&lt;span class="n"&gt;prod&lt;/span&gt;-&lt;span class="n"&gt;xxx&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;prod&lt;/span&gt;...

&lt;span class="c"&gt;# Staging env vars
&lt;/span&gt;&lt;span class="n"&gt;STRIPE_SECRET_KEY&lt;/span&gt;=&lt;span class="n"&gt;sk_test_xxx&lt;/span&gt;
&lt;span class="n"&gt;RESEND_API_KEY&lt;/span&gt;=&lt;span class="n"&gt;re_test_xxx&lt;/span&gt;
&lt;span class="n"&gt;OPENAI_API_KEY&lt;/span&gt;=&lt;span class="n"&gt;sk&lt;/span&gt;-&lt;span class="n"&gt;staging&lt;/span&gt;-&lt;span class="n"&gt;xxx&lt;/span&gt; (&lt;span class="n"&gt;separate&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;, $&lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="n"&gt;monthly&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;)
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;staging&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For OpenAI specifically, create a second project in your OpenAI dashboard with a $5 hard cap. That way a runaway loop on staging cannot eat your real budget. (See my earlier piece on AI apps silently burning money for what that looks like in production.)&lt;/p&gt;

&lt;p&gt;For Stripe, switch the dashboard to test mode and you get a separate set of keys, a separate set of webhooks, and a separate set of customers. The webhooks are the part founders miss. You need to create a &lt;em&gt;separate&lt;/em&gt; webhook endpoint in Stripe test mode pointed at your staging URL. Otherwise your staging app silently fails every payment flow and you assume it is your code.&lt;/p&gt;

&lt;p&gt;For Resend, Postmark, or whatever transactional email provider you use, send staging mail to a sandbox inbox (Mailtrap, Ethereal Email). The risk is not the provider charge, it is that you accidentally send a "Welcome to MyApp Pro!" email to a real user from a staging test run.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A separate URL (2 minutes)
&lt;/h3&gt;

&lt;p&gt;Default to whatever the platform gives you (&lt;code&gt;myapp-staging.platform.app&lt;/code&gt;). If you care, add a subdomain like &lt;code&gt;staging.myapp.com&lt;/code&gt;. The point is that the staging URL is different enough from prod that no human ever confuses the two. I have seen founders deploy prod from a staging branch by mistake because both URLs lived on adjacent subdomains. Do not be them.&lt;/p&gt;

&lt;p&gt;That is it. Five steps, 25 to 35 minutes if you have not done it before, much less if you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes about your workflow
&lt;/h2&gt;

&lt;p&gt;Before staging: &lt;code&gt;git push origin main&lt;/code&gt;, hope for the best.&lt;/p&gt;

&lt;p&gt;After staging: push to &lt;code&gt;staging&lt;/code&gt;, open &lt;code&gt;staging.myapp.com&lt;/code&gt;, click around for two minutes, check the route you changed, verify any new migration ran, then merge &lt;code&gt;staging&lt;/code&gt; into &lt;code&gt;main&lt;/code&gt; for production.&lt;/p&gt;

&lt;p&gt;The extra step is the two-minute smoke test. That is the entire ongoing cost. Once you do this for a week, it becomes muscle memory and you stop noticing the overhead. The compounding return is that the 4 bugs above stop happening. The ratio of "Saturday morning bug-hunts" to "uneventful Saturdays" flips within a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on CI
&lt;/h2&gt;

&lt;p&gt;I deliberately did not include "set up CI tests" in the 30-minute version. CI is good and you should add it eventually, but it is its own setup project and it is not the part that catches schema drift. The staging hop catches the bugs CI cannot (real-DB migrations, real-platform cold starts, real env-var presence). CI catches the bugs staging does not (logic regressions, type errors). Complementary, not substitutes. Get staging in first, add CI when you have a test suite worth running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap nobody warns you about: stale staging
&lt;/h2&gt;

&lt;p&gt;The most common way a staging environment dies is not deletion, it is decay. Six weeks in, staging stops matching prod. A different Node version. A different env var you only added to prod and forgot to mirror. A migration that ran on prod through a manual &lt;code&gt;psql&lt;/code&gt; command but never on staging.&lt;/p&gt;

&lt;p&gt;You only find out staging is stale the day a "tested on staging" deploy crashes prod. To prevent this, write a one-page &lt;code&gt;STAGING.md&lt;/code&gt; in your repo listing every env var and external integration. Anytime you change one in prod, you change it in staging in the same commit. The README is the source of truth, the platform UI is a reflection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I built Belmo, a managed PaaS aimed at solo founders and small teams, in part because I kept setting up the five pieces above manually for clients. On Belmo, a staging service is a checkbox during repo connect: pick which branch is staging, pick which one is prod, get two URLs and two databases. Sandbox env vars and per-environment secrets are first-class, so you do not accidentally copy prod keys into staging. We run from German and Oregon data centers, ISO 27001, GDPR-compliant. The free Starter tier never sleeps, which makes it usable as a staging environment without paying anything.&lt;/p&gt;

&lt;p&gt;That is the one mention. The setup above works on Render, Fly, Railway, Vercel, Supabase compute, your own VPS, or anywhere else. Pick the platform you are happiest paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight, regardless of platform
&lt;/h2&gt;

&lt;p&gt;If you do not have a staging environment, do these in order, and stop whenever you run out of energy.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;staging&lt;/code&gt; branch in your repo and push it.&lt;/li&gt;
&lt;li&gt;On your platform, create a second web service that deploys from the &lt;code&gt;staging&lt;/code&gt; branch.&lt;/li&gt;
&lt;li&gt;Provision a second database. Do not share it with prod.&lt;/li&gt;
&lt;li&gt;Copy your prod env vars into staging, then go down the list and swap every &lt;code&gt;*_live_*&lt;/code&gt; or &lt;code&gt;*_prod_*&lt;/code&gt; for the corresponding test value. Anything that does not have a test mode (rare, but it happens) gets a separate account.&lt;/li&gt;
&lt;li&gt;Write a small seed script. Run it once.&lt;/li&gt;
&lt;li&gt;Push a small change (a footer copy edit) to &lt;code&gt;staging&lt;/code&gt; first and confirm it deploys cleanly.&lt;/li&gt;
&lt;li&gt;Add one sentence to your README: "We deploy via &lt;code&gt;staging&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt;. Never push to &lt;code&gt;main&lt;/code&gt; directly."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole loop should fit in an evening. If it does not, stop at step 4 and finish tomorrow. The first four steps already catch three of the four bugs above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing question
&lt;/h2&gt;

&lt;p&gt;The funny thing about staging is that the engineers who most often skip it are the ones who would benefit the most: solo founders shipping fast, vibe-coders launching their first SaaS, agencies running 20 client projects on shoestring infra.&lt;/p&gt;

&lt;p&gt;If you have shipped a SaaS in the last 6 months without a staging environment, I am curious what your single worst production-only bug has been. Drop it in the comments. I will probably write the next post on the most common pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Previous posts in this series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Telegram alerts for any production app, a 5-minute setup.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;How I built a Discord 'ship-tracker' bot in a weekend.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I migrated 12 client projects off Heroku. Here's the playbook.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;The Claude Code to production checklist: 15 things that aren't obvious.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your Stripe webhook is going to silently drop a paid customer.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I deployed 12 vibe-coded apps to production. The same 6 things broke every single time.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your .env file is probably already in your Git history.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your Postgres will die at 50 concurrent users, not 50,000. Here is the connection pooling guide nobody handed you.&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devops</category>
      <category>indiehackers</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Postgres will die at 50 concurrent users, not 50,000. Here is the connection pooling guide nobody handed you.</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:23:56 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-postgres-will-die-at-50-concurrent-users-not-50000-here-is-the-connection-pooling-guide-4oad</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-postgres-will-die-at-50-concurrent-users-not-50000-here-is-the-connection-pooling-guide-4oad</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am a senior backend tech lead in Paris and I run Belmo, a small European PaaS for solo founders and small teams. This article mentions Belmo once near the end. The fix works on any platform: Render, Fly, Railway, Supabase, your own VPS, whatever you ship on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You launch on Product Hunt. By 11h Pacific you have 87 people poking around your dashboard. Your error rate starts climbing. Sentry begins shouting &lt;code&gt;PrismaClientInitializationError: Can't reach database server&lt;/code&gt;. Your homepage returns blank. You open your Postgres metrics expecting to see CPU pinned at 100%. CPU is at 3%. Memory is barely touched. But &lt;code&gt;pg_stat_activity&lt;/code&gt; shows 100 active connections, and you cannot make a new one.&lt;/p&gt;

&lt;p&gt;Your database did not run out of compute. It ran out of seats.&lt;/p&gt;

&lt;p&gt;This is the single most common production outage I see in indie SaaS, and it almost never gets blamed correctly. Founders blame the framework, the host, "scaling", or the AI model that wrote the code. The real cause is two paragraphs deep in the Postgres docs and nobody reads them until 11h on Product Hunt day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres has a seat limit at all
&lt;/h2&gt;

&lt;p&gt;Every Postgres connection is a real OS process. Not a thread. A process, with its own memory footprint, typically 5 to 15 MB on a default config. The reason for this design is historical (Postgres predates threads being good at anything in 1996) and operational (process isolation makes one crashed query much less likely to take down the whole server). The cost is that connections are expensive.&lt;/p&gt;

&lt;p&gt;So Postgres caps them with &lt;code&gt;max_connections&lt;/code&gt;. The defaults you will see in the wild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase free tier: 60 (direct), but with their Supavisor pooler in front&lt;/li&gt;
&lt;li&gt;Neon free tier: 100 via their built-in pooler, ~20 direct&lt;/li&gt;
&lt;li&gt;Heroku Postgres Mini: 20&lt;/li&gt;
&lt;li&gt;Heroku Postgres Basic: 20&lt;/li&gt;
&lt;li&gt;RDS db.t4g.micro: 81&lt;/li&gt;
&lt;li&gt;Self-hosted Postgres on a 2 GB VPS, default config: 100&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hit the cap and Postgres does not slow down. It refuses. &lt;code&gt;FATAL: too_many_connections for role "app"&lt;/code&gt;. Your application's retry logic kicks in. Cold-start serverless functions fan out, each opening its own connection, each getting rejected, each retrying. Your queue backs up. Within 90 seconds the system is fully wedged and the only fix is to restart enough of your app servers to drop the held connections.&lt;/p&gt;

&lt;p&gt;I have watched this happen to three clients in the last six months. One of them was a Bolt-built B2B tool that survived its private beta and died on its public launch with 41 concurrent users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bites indie SaaS specifically
&lt;/h2&gt;

&lt;p&gt;Big companies do not hit this because they paid a platform team to set up connection pooling five years ago. Indie founders hit it because three things stack:&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;serverless and edge runtimes&lt;/strong&gt;. Every Vercel function, every Lambda, every Netlify function that touches your database wants its own connection. A single page load on a modern Next.js app can spin up 4 to 8 functions in parallel (your loader, your action, your middleware, your API route, your auth check). Multiply by 50 concurrent users and you are at 400 simultaneous connection attempts to a database that allows 100.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;ORM defaults&lt;/strong&gt;. Prisma, by default, opens a connection pool sized to your CPU count, per process. Run that on 10 Vercel functions and you have 10 separate pools, each sized to whatever Vercel reports as CPU count, often 4 or 8. Drizzle's &lt;code&gt;node-postgres&lt;/code&gt; driver defaults to a pool of 10. SQLAlchemy defaults to &lt;code&gt;pool_size=5&lt;/code&gt;, &lt;code&gt;max_overflow=10&lt;/code&gt;. Stack a few worker dynos and a web server and you can blow past your &lt;code&gt;max_connections&lt;/code&gt; limit without doing anything wrong.&lt;/p&gt;

&lt;p&gt;The third is &lt;strong&gt;hot reload during development&lt;/strong&gt;. Run &lt;code&gt;npm run dev&lt;/code&gt; with Prisma and every save spawns a new client without closing the old one (this got better in recent Prisma versions but is still common). On a small free-tier database, three hours of local dev can hold 30 connections that the database never sees you releasing.&lt;/p&gt;

&lt;p&gt;If any of those three apply to you (and at least one does), you are one Product Hunt feature away from the outage I just described.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math you should actually do tonight
&lt;/h2&gt;

&lt;p&gt;Before reaching for PgBouncer or Supavisor or any pooling layer, do the arithmetic. The formula is brutal but honest.&lt;/p&gt;

&lt;p&gt;Take your real concurrency target. For most indie SaaS that is 50 simultaneously active users, not 50,000. Multiply by 2 to account for the fact that a single user action often hits 2 to 4 endpoints (page load, auth check, data fetch, mutation). Add a buffer of 20% for background workers, cron jobs, the Postgres backup process, and your own psql sessions. That is your connection demand.&lt;/p&gt;

&lt;p&gt;Now look at your &lt;code&gt;max_connections&lt;/code&gt;. Subtract 5 for Postgres's own internal processes (autovacuum, wal writer, etc). What is left is your real budget.&lt;/p&gt;

&lt;p&gt;For a Heroku Postgres Basic at 20 max_connections, your real budget is 15. Divide by your per-process pool size, and you get the number of app processes you can safely run. If Prisma is opening 10 connections per process, you can run 1.5 processes. That is your headroom. One Heroku web dyno plus one worker dyno will already be over the limit, before any user shows up.&lt;/p&gt;

&lt;p&gt;This is the math nobody runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need PgBouncer, and when you just need to fix your app
&lt;/h2&gt;

&lt;p&gt;PgBouncer (or Supavisor, or PgCat, or your provider's built-in pooler) lets a small number of real Postgres connections back a much larger number of fake-looking client connections. Your 500 Next.js functions all open a connection to PgBouncer on port 6432. PgBouncer holds 15 real connections to Postgres on port 5432 and multiplexes them. As long as no single query holds a connection for too long, your fan-out problem disappears.&lt;/p&gt;

&lt;p&gt;But pooling is not the first fix. It is the third.&lt;/p&gt;

&lt;p&gt;Before adding a pooler, do these in order:&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;lower your ORM's per-process pool size&lt;/strong&gt;. Most Prisma deployments do not need 10 connections per process. Set &lt;code&gt;connection_limit=2&lt;/code&gt; in your &lt;code&gt;DATABASE_URL&lt;/code&gt; query string. For Drizzle/node-postgres, pass &lt;code&gt;{ max: 3 }&lt;/code&gt; to the pool constructor. For SQLAlchemy, set &lt;code&gt;pool_size=2, max_overflow=0&lt;/code&gt;. This alone solves the issue for most indie SaaS.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;stop opening new clients on hot reload&lt;/strong&gt;. The Prisma docs have a singleton pattern for Next.js dev mode that uses &lt;code&gt;globalThis&lt;/code&gt; to reuse the client across reloads. Use it. SQLAlchemy users should make sure they are not calling &lt;code&gt;create_engine()&lt;/code&gt; inside a request handler.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;find your connection leaks&lt;/strong&gt;. Run &lt;code&gt;SELECT pid, query, state, backend_start, state_change FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY state_change ASC;&lt;/code&gt; against your production database. Any row older than 5 minutes is a leak. It is almost always a missing &lt;code&gt;await&lt;/code&gt; on a transaction, or an error path that does not call &lt;code&gt;client.release()&lt;/code&gt;. Fix those before adding any infrastructure.&lt;/p&gt;

&lt;p&gt;If after all three you still need more headroom, then add a pooler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PgBouncer config that actually works
&lt;/h2&gt;

&lt;p&gt;If you self-host PgBouncer, the minimum useful config is shorter than people think:&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;[databases]&lt;/span&gt;
&lt;span class="py"&gt;appdb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;host=10.0.0.5 port=5432 dbname=appdb&lt;/span&gt;

&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;
&lt;span class="py"&gt;listen_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0&lt;/span&gt;
&lt;span class="py"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6432&lt;/span&gt;
&lt;span class="py"&gt;auth_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
&lt;span class="py"&gt;auth_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/pgbouncer/userlist.txt&lt;/span&gt;
&lt;span class="py"&gt;pool_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;500&lt;/span&gt;
&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;15&lt;/span&gt;
&lt;span class="py"&gt;reserve_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;reserve_pool_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;server_idle_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two settings that matter are &lt;code&gt;pool_mode&lt;/code&gt; and &lt;code&gt;default_pool_size&lt;/code&gt;. &lt;code&gt;pool_mode = transaction&lt;/code&gt; is what makes the multiplexing actually work: PgBouncer hands a real Postgres connection to a client only for the duration of a single transaction, then reclaims it. &lt;code&gt;default_pool_size = 15&lt;/code&gt; means PgBouncer will open up to 15 real connections to Postgres per (user, database) pair. That number, plus a buffer, must stay under your &lt;code&gt;max_connections&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_client_conn = 500&lt;/code&gt; is the lie you tell your application. From the app's perspective there are 500 connections available. From Postgres's perspective there are 15. PgBouncer mediates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prepared statement footgun
&lt;/h2&gt;

&lt;p&gt;Here is the part that bites everyone, including me, the first time.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;pool_mode = transaction&lt;/code&gt;, a single client connection can land on different real Postgres connections from one transaction to the next. Prepared statements (&lt;code&gt;PREPARE name AS SELECT...&lt;/code&gt;) live on a specific Postgres connection. So if your ORM prepares a statement on connection A and then tries to execute it on connection B because PgBouncer rotated, you get &lt;code&gt;prepared statement "p1" does not exist&lt;/code&gt;. Your app starts throwing 500s on perfectly correct queries.&lt;/p&gt;

&lt;p&gt;This hits Prisma users hard because Prisma uses prepared statements by default. The fix is either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use Prisma's &lt;code&gt;?pgbouncer=true&lt;/code&gt; flag in the connection string, which disables prepared statements&lt;/li&gt;
&lt;li&gt;Or upgrade to Prisma 5.10+ and use &lt;code&gt;?statement_cache_size=0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Or run Prisma against Postgres directly and use PgBouncer only for non-Prisma workloads&lt;/li&gt;
&lt;li&gt;Or use Supabase's Supavisor in "session mode" instead of transaction mode (less efficient, but compatible)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Django users with &lt;code&gt;psycopg2&lt;/code&gt; need &lt;code&gt;DISABLE_SERVER_SIDE_CURSORS = True&lt;/code&gt; in their database config. Rails with the &lt;code&gt;pg&lt;/code&gt; gem is mostly fine because Rails does not use prepared statements by default in production unless you opted in via &lt;code&gt;prepared_statements: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;SQLAlchemy users need &lt;code&gt;pool_pre_ping=True&lt;/code&gt; and to make sure they are not using server-side cursors across transactions. The default is fine.&lt;/p&gt;

&lt;p&gt;This is the one chunk of this article I would tape to your monitor. The error message &lt;code&gt;prepared statement does not exist&lt;/code&gt; after introducing PgBouncer is not a bug in PgBouncer. It is your ORM assuming &lt;code&gt;pool_mode = session&lt;/code&gt; when you gave it &lt;code&gt;pool_mode = transaction&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Supabase, Neon, and the managed providers
&lt;/h2&gt;

&lt;p&gt;Most of them already do this for you, kind of.&lt;/p&gt;

&lt;p&gt;Supabase ships a pooler called Supavisor in front of every project. Your &lt;code&gt;DATABASE_URL&lt;/code&gt; for "direct" connections hits Postgres on port 5432. Your "transaction" pooler URL hits Supavisor on port 6543. Use the latter from any serverless context. The former is fine for migrations, long-running scripts, and Studio.&lt;/p&gt;

&lt;p&gt;Neon does similar with a pgbouncer-derived layer. Append &lt;code&gt;?pgbouncer=true&lt;/code&gt; to your connection string and you are using their pooled endpoint.&lt;/p&gt;

&lt;p&gt;Render's managed Postgres has a built-in pooler since 2024, configured via the dashboard.&lt;/p&gt;

&lt;p&gt;Heroku Postgres has no built-in pooler. You either run PgBouncer yourself on a separate dyno, or you switch providers.&lt;/p&gt;

&lt;p&gt;The trap: founders use the pooled URL in their &lt;code&gt;.env&lt;/code&gt; for everything, then run Prisma migrations through it, and the migration fails because migrations need session-mode features like advisory locks and &lt;code&gt;SET search_path&lt;/code&gt;. So you keep two &lt;code&gt;DATABASE_URL&lt;/code&gt; values: one for the app (pooled, transaction mode), one for migrations (direct, session mode). Most ORMs let you point migrations at a different URL via env var. Do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I kept solving this manually for clients, then I packaged it. I run Belmo, a small European PaaS where the managed Postgres ships with a pooler in front by default on every plan, and the dashboard surfaces &lt;code&gt;pg_stat_activity&lt;/code&gt; so you can see your idle-in-transaction count before it kills you. There is also AI-driven log monitoring that pattern-matches &lt;code&gt;too_many_connections&lt;/code&gt; and &lt;code&gt;prepared statement does not exist&lt;/code&gt; and pings you on Telegram with the likely root cause. EU and US data centers, GDPR baked in. The free Starter tier never sleeps, which matters when your Product Hunt launch happens at 02h Paris time.&lt;/p&gt;

&lt;p&gt;That is the one mention. The actual fix in this article works the same on Render, Fly, Supabase, or your own VPS. Pick the provider that fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight regardless of which platform you use
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;SELECT count(*), state FROM pg_stat_activity GROUP BY state;&lt;/code&gt; against your production database right now. If &lt;code&gt;idle in transaction&lt;/code&gt; is non-zero, you have a leak.&lt;/p&gt;

&lt;p&gt;Open your &lt;code&gt;.env&lt;/code&gt; or your platform's env vars page. If your &lt;code&gt;DATABASE_URL&lt;/code&gt; has no &lt;code&gt;connection_limit&lt;/code&gt; parameter (Prisma) or no pool size in the connection options (other ORMs), add one. Set it to 2 or 3.&lt;/p&gt;

&lt;p&gt;Check whether your managed Postgres provider offers a pooled connection URL. Switch your serverless functions to use it. Keep the direct URL for migrations only.&lt;/p&gt;

&lt;p&gt;Add an alert (Sentry, your log aggregator, Telegram, whatever you use) for the exact strings &lt;code&gt;too_many_connections&lt;/code&gt; and &lt;code&gt;prepared statement does not exist&lt;/code&gt;. Both indicate you are about to have a bad day.&lt;/p&gt;

&lt;p&gt;If you self-host, install PgBouncer with &lt;code&gt;pool_mode = transaction&lt;/code&gt; and the config above, and set &lt;code&gt;default_pool_size&lt;/code&gt; to no more than &lt;code&gt;max_connections - 5&lt;/code&gt; divided by however many user-database pairs you have.&lt;/p&gt;

&lt;p&gt;Document somewhere (a README, a Notion page, your project's &lt;code&gt;ARCHITECTURE.md&lt;/code&gt;) what your real &lt;code&gt;max_connections&lt;/code&gt; is and how many connections your app is allowed to take. Future-you on Product Hunt day will thank present-you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not know
&lt;/h2&gt;

&lt;p&gt;The interesting open question for me is whether the next generation of "thin client" Postgres protocols (the HTTP-based ones like Neon's Serverless Driver and Supabase's PostgREST) make all of this obsolete. They sidestep the connection-per-process model entirely by going stateless. They also break every ORM that assumes a persistent connection.&lt;/p&gt;

&lt;p&gt;My current bet is that the connection pool will remain the right abstraction for the next 3 to 5 years, because the alternative is rewriting your data layer for a protocol that has no transactions in the traditional sense. But I would love to hear from anyone running an indie SaaS on the new thin-client protocols. What broke? What got easier? Drop a comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Previous posts in this series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Telegram alerts for any production app, a 5-minute setup.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;How I built a Discord 'ship-tracker' bot in a weekend.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I migrated 12 client projects off Heroku. Here's the playbook.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;The Claude Code to production checklist: 15 things that aren't obvious.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your Stripe webhook is going to silently drop a paid customer.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I deployed 12 vibe-coded apps to production. The same 6 things broke every single time.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your .env file is probably already in your Git history.&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>devops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Your .env file is probably already in your Git history. The 15-minute audit (and the 5 habits that stop new leaks for good).</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:21:48 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-env-file-is-probably-already-in-your-git-history-the-15-minute-audit-and-the-5-habits-that-5dkg</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-env-file-is-probably-already-in-your-git-history-the-15-minute-audit-and-the-5-habits-that-5dkg</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am a senior backend tech lead in Paris and I run Belmo, a small European PaaS. This article mentions Belmo once near the end. Everything else works regardless of where you host.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A founder I advise forwarded me a GitHub email last Sunday. Subject line: "We found a secret in one of your repositories." It was an OpenAI key, six commits old, in a repo she had flipped from private to public on Friday so a designer could browse it. The key was still active. She had been billed €240 in API calls between Friday evening and Sunday morning by someone running what looked like a CSV-to-embeddings job out of a residential IP block in Brazil.&lt;/p&gt;

&lt;p&gt;This is the most common preventable production incident I see in indie SaaS. Not a database outage, not a Stripe bug, not a deploy gone wrong. A key in a place it should not be. Usually a &lt;code&gt;.env&lt;/code&gt; file committed before &lt;code&gt;.gitignore&lt;/code&gt; was set up. Sometimes a hardcoded &lt;code&gt;OPENAI_API_KEY = "sk-..."&lt;/code&gt; in a Python script the founder forgot they pushed during early prototyping. Almost always findable in two minutes if you know where to look.&lt;/p&gt;

&lt;p&gt;If you have ever pasted an API key into Claude Code, Cursor, Lovable, or Bolt, or if you have ever committed a project before you knew what &lt;code&gt;.gitignore&lt;/code&gt; did, this article is the audit you have been putting off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why secrets leak more in 2026 than they did in 2020
&lt;/h2&gt;

&lt;p&gt;Three things changed. AI coding tools often hardcode keys directly, because the model is trying to make the snippet work in front of it. The number of repos a solo founder touches per year has roughly tripled since vibe-coding caught on. And the providers each app talks to (Stripe, OpenAI, Anthropic, Resend, Twilio, Supabase, Cloudflare R2) have gone from two or three to ten or fifteen.&lt;/p&gt;

&lt;p&gt;More repos times more providers times more "just paste it in" equals more leaks. The audit is cheap, the prevention is mostly free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute audit you can run tonight
&lt;/h2&gt;

&lt;p&gt;This works on any repo. Run it on every repo you have pushed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: scan the full Git history, not just the working tree
&lt;/h3&gt;

&lt;p&gt;A common mistake is to &lt;code&gt;grep&lt;/code&gt; for keys in current files. If you committed a secret six months ago and deleted it, it is still in history. Anyone who clones the repo gets it.&lt;/p&gt;

&lt;p&gt;The tool I reach for is &lt;code&gt;gitleaks&lt;/code&gt;. Install it once (&lt;code&gt;brew install gitleaks&lt;/code&gt; on macOS, or grab a binary from GitHub).&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;cd&lt;/span&gt; ~/code/your-app
gitleaks detect &lt;span class="nt"&gt;--source&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scans your entire commit history with a set of regex rules that match common provider patterns (Stripe, GitHub, AWS, OpenAI, Anthropic, Slack, and many more). On a typical indie project I have audited, this finds something in 6 cases out of 10. The classic findings are an old &lt;code&gt;.env&lt;/code&gt; from before the project had a &lt;code&gt;.gitignore&lt;/code&gt;, a forgotten &lt;code&gt;test.py&lt;/code&gt; with a hardcoded key, or a &lt;code&gt;docker-compose.override.yml&lt;/code&gt; that someone committed by accident.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;gitleaks&lt;/code&gt; reports nothing, run &lt;code&gt;trufflehog&lt;/code&gt; as a second opinion. It has slightly different heuristics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trufflehog filesystem &lt;span class="nt"&gt;--directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--no-update&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: check GitHub's secret scanning UI
&lt;/h3&gt;

&lt;p&gt;If the repo lives on GitHub, GitHub itself has been scanning your pushes for known secret formats since 2023. Even on free private repos. Go to your repo, Security tab, Secret scanning alerts. Anything GitHub already caught will be listed there with the exact commit and line.&lt;/p&gt;

&lt;p&gt;If you see anything there, treat it as real. GitHub notifies the provider (Stripe, OpenAI, etc.) so the key may be auto-revoked, but do not rely on that. Some providers do, some do not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: rotate every key the audit found
&lt;/h3&gt;

&lt;p&gt;This is the part people skip because it is annoying. You log in to each provider, generate a new key, swap it in your hosting platform's env var settings, redeploy, and verify the old key is dead.&lt;/p&gt;

&lt;p&gt;A habit that helps: keep a &lt;code&gt;secrets-rotation.md&lt;/code&gt; in a private vault listing every provider, the last rotation date, and the URL of that dashboard's "API keys" page. When you audit, you walk down the list. The hardest part of rotation is not the rotation itself, it is remembering all the places a key lives.&lt;/p&gt;

&lt;p&gt;For OpenAI and Anthropic specifically, do not just create a new key and leave the old one active. Revoke the old one explicitly. A leaked key that has been rotated but not revoked is the same as a leaked key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: purge the secret from Git history (optional but worth it for public repos)
&lt;/h3&gt;

&lt;p&gt;If your repo is private and you trust everyone with read access, rotating is enough. The leaked key is dead, anyone who finds it later gets a 401.&lt;/p&gt;

&lt;p&gt;If your repo is public, or was public at any point, or you simply do not want a paper trail of "things I shipped that I should not have," you can rewrite history with &lt;code&gt;git filter-repo&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;pip &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo
git filter-repo &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; .env
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rewrites every commit and force-pushes. Do not do this on a repo where other people have open branches without warning them. For a solo project, it is fine and takes about 30 seconds.&lt;/p&gt;

&lt;p&gt;A note: even after a force push, GitHub keeps orphan commits accessible by SHA for a while. The only real fix for a leaked credential is rotation. Rewriting history is cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 habits that stop the next leak
&lt;/h2&gt;

&lt;p&gt;Auditing is the fire drill. Habits are how you stop the next fire.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habit 1: &lt;code&gt;.gitignore&lt;/code&gt; is the first file in every new repo
&lt;/h3&gt;

&lt;p&gt;Before you write any code, before the first commit, the first file is &lt;code&gt;.gitignore&lt;/code&gt; and it includes &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.local&lt;/code&gt;, &lt;code&gt;.env.*.local&lt;/code&gt;, and anything provider-specific like &lt;code&gt;firebase-adminsdk-*.json&lt;/code&gt; or &lt;code&gt;gcp-key.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you use AI coding tools, add this to your starter prompt or to your &lt;code&gt;CLAUDE.md&lt;/code&gt;/&lt;code&gt;.cursorrules&lt;/code&gt;: "Never write secrets or API keys directly in source files. Always read them from &lt;code&gt;process.env.X&lt;/code&gt; or &lt;code&gt;os.getenv('X')&lt;/code&gt;. Never commit &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;.env.local&lt;/code&gt;." Models will mostly respect this. They will occasionally forget, which is why you also need habit 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habit 2: a pre-commit hook that runs &lt;code&gt;gitleaks&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The cheapest possible defense. Install &lt;code&gt;pre-commit&lt;/code&gt; (&lt;code&gt;pip install pre-commit --break-system-packages&lt;/code&gt;) and drop a &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt; in your repo:&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;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/gitleaks/gitleaks&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v8.18.0&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;pre-commit install&lt;/code&gt; once. From that point on, every &lt;code&gt;git commit&lt;/code&gt; runs &lt;code&gt;gitleaks&lt;/code&gt; against the staged diff. If you paste a key into a file by accident, the commit is blocked.&lt;/p&gt;

&lt;p&gt;This single setup has saved at least four founders I work with from an embarrassing rotation. It takes 90 seconds to install and you forget it exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habit 3: prod env vars live in the hosting platform, never in a file on disk
&lt;/h3&gt;

&lt;p&gt;Every modern hosting platform (Vercel, Fly, Railway, Render, Belmo, you name it) has an "environment variables" section in the dashboard. Production secrets go there. They never live in a file that gets deployed. They are injected at build time or at process start.&lt;/p&gt;

&lt;p&gt;The mental model I push founders toward: your repo contains the code and a &lt;code&gt;.env.example&lt;/code&gt; (no real values, just keys with empty strings). The hosting platform contains the real values. Local dev uses a &lt;code&gt;.env&lt;/code&gt; file that is in &lt;code&gt;.gitignore&lt;/code&gt;. Three places. No overlap.&lt;/p&gt;

&lt;p&gt;A subtler version: do not screenshot your env vars dashboard, and do not paste that screenshot into Slack or Discord. I have seen Stripe keys leak through Loom recordings shared with a contractor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habit 4: scope keys aggressively, and prefer short-lived ones
&lt;/h3&gt;

&lt;p&gt;Most providers let you create restricted keys. Stripe has restricted keys with read-only or specific-resource access. OpenAI has project-scoped keys. AWS has IAM roles with specific policies. The default behavior is to create a key with full account access because it is one click. Resist that.&lt;/p&gt;

&lt;p&gt;A production API key should be able to do exactly what your app does and nothing more. If it is compromised, the blast radius is small. A founder I worked with last quarter had an AWS key with full admin access in a repo. The leak resulted in a small bitcoin-mining incident on her account that AWS reversed, but only after a week of stress. A scoped S3-only key would have made it "someone uploaded weird files to a bucket." Recoverable in 20 minutes.&lt;/p&gt;

&lt;p&gt;For human access, use SSO and a password manager. Do not share login passwords with contractors. Use the vendor's "invite teammate" flow even if it costs a few euros a month. The audit trail alone is worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habit 5: a monthly 5-minute rotation calendar event
&lt;/h3&gt;

&lt;p&gt;Once a month, on the same day, you spend 5 minutes rotating one or two keys. Not all of them, just the highest-risk ones (the ones that can spend money). OpenAI, Anthropic, Stripe, your cloud provider. Over a year you rotate everything at least once.&lt;/p&gt;

&lt;p&gt;This sounds like security theater. It is not. The point is to keep the rotation muscle warm so that when you actually need to rotate in a hurry (someone leaks a key on a Friday night), you already know which dashboard pages to open and what app code reads which env var. Without the muscle, you spend the first 45 minutes of an incident just orienting yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built (the one Belmo mention)
&lt;/h2&gt;

&lt;p&gt;I built Belmo in part because I kept solving this problem manually for clients. The platform has encrypted env vars by default on the Hobby tier (€19/month), restricted access logs that show which deploy injected which secret, AI monitoring that catches anomalous token usage patterns the moment a leaked OpenAI key starts being used by someone else, and Telegram alerts when a deploy starts behaving unlike its baseline. None of that is unique to Belmo. Most modern platforms have at least the env var encryption part. The piece I cared most about was making the secret-rotation flow take 30 seconds instead of 10 minutes, because friction is why founders skip rotation.&lt;/p&gt;

&lt;p&gt;If you are happy on Vercel, Fly, Railway, Render, or anywhere else, this article still works. The point is the habit, not the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight regardless of which platform you use
&lt;/h2&gt;

&lt;p&gt;Five concrete moves you can make in the next hour.&lt;/p&gt;

&lt;p&gt;First, install &lt;code&gt;gitleaks&lt;/code&gt; and run it against every repo in &lt;code&gt;~/code&lt;/code&gt; (or wherever you keep them). A one-liner for macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;gitleaks
find ~/code &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;".git"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;d&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
      gitleaks detect &lt;span class="nt"&gt;--source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-banner&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
    &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, log in to GitHub and check the Secret scanning alerts tab on each of your repos. Anything sitting there has been visible to GitHub (and possibly to others) for as long as it has been there. Address it.&lt;/p&gt;

&lt;p&gt;Third, rotate any key the audit found. Do not skip this. Revoke the old key explicitly in the provider's dashboard.&lt;/p&gt;

&lt;p&gt;Fourth, install the &lt;code&gt;pre-commit&lt;/code&gt; hook with &lt;code&gt;gitleaks&lt;/code&gt; on your primary working repo. 90 seconds of setup, infinite future leak protection on that repo.&lt;/p&gt;

&lt;p&gt;Fifth, put a recurring calendar event titled "Secret rotation, 5 minutes" on the first Monday of every month. When it fires, you rotate one provider key. Pick the highest-value one you have not rotated this year.&lt;/p&gt;

&lt;p&gt;If you do these five things tonight, you have closed the biggest preventable security gap in your stack. The rest is just keeping the habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest closing
&lt;/h2&gt;

&lt;p&gt;I have leaked a key in my own career. Twice. Once in 2017 when I committed an AWS key to a public-ish demo repo and got billed €80 of EC2 inside 6 hours. Once in 2022 when a Stripe test key ended up in a Loom recording sent to a client (test key, thankfully). Both times I learned the same lesson: the question is not "will a key leak" but "what is your detection and rotation time when it does." The audit and habits above bring that time from "weeks" to "minutes."&lt;/p&gt;

&lt;p&gt;Here is the question I will leave you with. When was the last time you actually rotated a production API key, on purpose, without an incident forcing you to? If the answer is "never" or "I do not remember," your monthly calendar event starts tonight.&lt;/p&gt;

&lt;p&gt;Drop a comment if you have a different rotation cadence that works for you, or if you have a horror story to share. I read all of them. We learn from each other's leaks more than from any best-practices doc.&lt;/p&gt;




&lt;h2&gt;
  
  
  Previous posts in this series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;5 Heroku alternatives whose free tier doesn't sleep&lt;/li&gt;
&lt;li&gt;I built my MVP with Claude Code. Now I need to deploy it.&lt;/li&gt;
&lt;li&gt;Your AI app is silently burning $2,000/month&lt;/li&gt;
&lt;li&gt;Telegram alerts for any production app in 5 minutes&lt;/li&gt;
&lt;li&gt;How I built a Discord ship-tracker bot in a weekend&lt;/li&gt;
&lt;li&gt;I migrated 12 client projects off Heroku. Here's the playbook.&lt;/li&gt;
&lt;li&gt;The Claude Code production checklist: 15 things that aren't obvious&lt;/li&gt;
&lt;li&gt;Your indie SaaS has zero working Postgres backups&lt;/li&gt;
&lt;li&gt;Your Stripe webhook is going to silently drop a paid customer&lt;/li&gt;
&lt;li&gt;Your crontab is silently failing: 5 silent killers of VPS cron&lt;/li&gt;
&lt;li&gt;I deployed 12 vibe-coded apps. The same 6 things broke.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I deployed 12 vibe-coded apps to production. The same 6 things broke every single time.</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Wed, 20 May 2026 08:01:06 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/i-deployed-12-vibe-coded-apps-to-production-the-same-6-things-broke-every-single-time-341f</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/i-deployed-12-vibe-coded-apps-to-production-the-same-6-things-broke-every-single-time-341f</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am a senior backend tech lead in Paris and I run Belmo, a small European PaaS. This article mentions Belmo once near the end. The content works on any platform you choose.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Last month, a non-tech founder messaged me at 23h on a Tuesday. Her Lovable-built waitlist had hit Product Hunt at noon Pacific, racked up 4,000 signups by dinner, and the dashboard was now returning a blank white screen. She had pushed nothing. Nothing changed. She also could not log in to her own admin panel.&lt;/p&gt;

&lt;p&gt;I have seen this exact failure mode roughly 12 times in the last 8 months. Different founder, different tool (sometimes Lovable, sometimes Bolt, sometimes Cursor, sometimes Claude Code), same six holes. The code the model wrote was fine. Production exposed assumptions the model could not see.&lt;/p&gt;

&lt;p&gt;This is a field guide to those six holes. If you have shipped (or are about to ship) anything you built with an AI coding tool, you will recognize at least three of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why vibe-coded apps fail in production specifically
&lt;/h2&gt;

&lt;p&gt;A coding model writes the code in front of it. It does not see your DNS, your env vars, your cold-start behavior, your database connection limits, your timezone, or the fact that your "production" environment is actually your laptop with ngrok pointed at it.&lt;/p&gt;

&lt;p&gt;Models are also trained on a corpus where "it works locally" usually means "it works." For a side project on a single laptop, that is true. For a deployed app with real users on real networks across real timezones, it is wildly false. The model is not lying. It just has no production telemetry. You have to add that yourself.&lt;/p&gt;

&lt;p&gt;Here are the six patterns. Each one I have personally pulled out of a vibe-coded codebase in the last year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: The API key that shipped in the JS bundle
&lt;/h2&gt;

&lt;p&gt;Roughly 7 out of 12 vibe-coded apps I audited had at least one API key directly in client-side code. The model put it there because the model was told "call the Stripe API from this React form." The model did not say "by the way, this key is now visible to every browser on Earth via View Source."&lt;/p&gt;

&lt;p&gt;The tell: you grep the &lt;code&gt;dist/&lt;/code&gt; or &lt;code&gt;.next/&lt;/code&gt; build output for the first few characters of your key, and you find 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="c"&gt;# Run this against your built output before deploying&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;dist &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Clean"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; .next &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Clean"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: every secret call goes through a backend route. The client sends a request to your server. Your server, holding the secret in an env var, calls the third party. Even for a static site, you can run a tiny serverless function or a single Express route for this. Costs nothing, takes 15 minutes.&lt;/p&gt;

&lt;p&gt;If you only learn one thing from this post, learn this one. Leaked Stripe keys, leaked OpenAI keys, and leaked Resend keys cost real money and real customer trust. I have seen one founder rack up $1,800 in OpenAI charges in 11 hours because a leaked key got scraped off a GitHub mirror.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: The unbounded query that nukes the database
&lt;/h2&gt;

&lt;p&gt;The model writes &lt;code&gt;SELECT * FROM users WHERE org_id = ?&lt;/code&gt; and it works fine because in development you have 4 users. The same query in production, when your largest customer has 80,000 users, returns 80,000 rows over the wire, into your Node process, into JSON, and onto the network. Your API server's memory spikes. Your database connection pool gets exhausted. Your dashboard returns the blank white screen.&lt;/p&gt;

&lt;p&gt;This is the most common Lovable / Bolt failure I see, by a wide margin.&lt;/p&gt;

&lt;p&gt;The fix is paginate everything and put an upper bound on every query. Pick a default limit (50 is fine for most things), enforce it at the query layer, and only allow the client to ask for more in explicit chunks.&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;// Bad (what the model wrote)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;db&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="s1"&gt;SELECT * FROM users WHERE org_id = $1&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;orgId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Better&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&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="nf"&gt;parseInt&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;50&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;db&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="s1"&gt;SELECT * FROM users WHERE org_id = $1 ORDER BY id LIMIT $2 OFFSET $3&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;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&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;While you are in there, add an index on the column you are filtering by. Most vibe-coded apps have zero indexes outside the ones the ORM created automatically on primary keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: The CORS wildcard that opens the front door
&lt;/h2&gt;

&lt;p&gt;Every model I have worked with, when asked "I am getting a CORS error," will reliably suggest setting &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. It removes the error. It also opens your API to every site on the public internet, which is a problem the moment you have a session cookie or an auth token.&lt;/p&gt;

&lt;p&gt;The right answer is to whitelist your own domain (and your staging domain, and &lt;code&gt;localhost:3000&lt;/code&gt; for dev).&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;// Express&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedOrigins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.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="s1"&gt;https://staging.yourapp.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="s1"&gt;http://localhost:3000&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;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="na"&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;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&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="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;allowedOrigins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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="nf"&gt;callback&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="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="nf"&gt;callback&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="s1"&gt;Not allowed by CORS&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="na"&gt;credentials&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="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your AI tool wrote &lt;code&gt;app.use(cors())&lt;/code&gt; with no options, you have a wildcard. Audit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: The timezone mismatch nobody notices until billing
&lt;/h2&gt;

&lt;p&gt;Your server is on UTC. Your dev laptop is on Paris time, or PST, or Bangalore. Your users are everywhere. The model probably wrote &lt;code&gt;new Date()&lt;/code&gt; in a dozen places and the dates look "correct" to you because you are the only person who has looked at them.&lt;/p&gt;

&lt;p&gt;Then a customer in Tokyo says "your invoice has the wrong month" and you discover that your billing cron, which runs at "midnight" your time, actually fires on the wrong day for half your customer base.&lt;/p&gt;

&lt;p&gt;Three rules that catch most of these:&lt;/p&gt;

&lt;p&gt;Store everything in UTC in the database. Always. ISO 8601, with the &lt;code&gt;Z&lt;/code&gt;. If your ORM is doing anything else, fight it.&lt;/p&gt;

&lt;p&gt;Display in the user's timezone, computed on the client side or with the user's stored preference. Never assume server-local.&lt;/p&gt;

&lt;p&gt;For scheduled jobs (billing runs, daily digests, weekly emails), use cron-style scheduling that is explicit about timezone, or schedule them in UTC and document the offset.&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;// Use Intl on the client for display&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userTz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;timeZone&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;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&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;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userTz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short&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="nf"&gt;format&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isoStringFromAPI&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bug compounds with Pattern 2. If you have an unbounded query plus a bad timezone, you can ship invoices in the wrong currency to the wrong country with no audit trail. I have seen it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 5: The file path that works on your laptop and nowhere else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fs.readFileSync('./data/seed.json')&lt;/code&gt; works perfectly when you run &lt;code&gt;npm start&lt;/code&gt; from your project root. It explodes when your deploy runs from a different working directory, or when the file got excluded by your &lt;code&gt;.gitignore&lt;/code&gt;, or when your build step did not copy it into the production bundle.&lt;/p&gt;

&lt;p&gt;Same pattern: &lt;code&gt;import config from '../../config/local.json'&lt;/code&gt;. The relative path resolves in development. In production, after the bundler did its thing, the path no longer exists.&lt;/p&gt;

&lt;p&gt;The fix is to treat config as environment variables (the 12-factor way) and to load runtime data from a real data store, not from disk.&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;// Bad&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&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;./config/local.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Better&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apiUrl&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;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dbUrl&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;stripeKey&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Validate at boot so you fail loudly, not silently&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API_URL&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="s1"&gt;DATABASE_URL&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="s1"&gt;STRIPE_SECRET_KEY&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;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;required&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;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;k&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;missing&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing env vars:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;process.exit(1)&lt;/code&gt; is doing a lot of work. It is the difference between "the app crashes loudly at boot with a clear error" and "the app appears to boot fine but then 500s on the first request that needs Stripe."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 6: The background work that never runs
&lt;/h2&gt;

&lt;p&gt;Sending an email after signup. Charging a customer's card on the 1st of each month. Cleaning up expired sessions. The model wrote these as inline calls inside your HTTP handler, or as &lt;code&gt;setTimeout&lt;/code&gt; calls that get dropped when the process restarts.&lt;/p&gt;

&lt;p&gt;The first time you redeploy, the queued work disappears. The first time your server cold-starts, the in-memory timers vanish. The first time an email fails to send (because Mailgun returned a 429), your signup handler returns 500 and the user thinks the app is broken.&lt;/p&gt;

&lt;p&gt;You need three things: a real background process (a worker), a real schedule (cron, not setTimeout), and retry logic with a dead-letter behavior.&lt;/p&gt;

&lt;p&gt;Lightweight options that have worked for me on small teams: BullMQ on top of Redis, RQ for Python, Sidekiq for Rails, GoCron for Go. Or use your platform's managed worker tier if it has one.&lt;/p&gt;

&lt;p&gt;The pattern in code:&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;// In the HTTP handler, do NOT send the email inline&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hashedPassword&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;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send-welcome&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;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="c1"&gt;// In the worker process&lt;/span&gt;
&lt;span class="nx"&gt;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send-welcome&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="nx"&gt;job&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;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;mailer&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="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;welcome&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="c1"&gt;// BullMQ retries automatically with exponential backoff&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something fails repeatedly, it goes to the dead-letter queue and you see it in a dashboard. The user's signup never broke. The email gets retried until it succeeds, or you decide it is hopeless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built, because I kept solving these manually
&lt;/h2&gt;

&lt;p&gt;After roughly the eighth time a non-tech founder messaged me at midnight with a vibe-coded app on fire, I built Belmo. It is a small European PaaS that runs your app from a GitHub repo, gives you a worker process if you need one, and watches your logs with a pattern-detection layer that catches the failure modes above (retry loops, hot Sentry fingerprints, latency spikes, silent cron failures) and pings me on Telegram. Free tier never sleeps, EU and US regions, encrypted env vars by default.&lt;/p&gt;

&lt;p&gt;I am not telling you to use it. The patterns above work on Render, Railway, Fly, Vercel, AWS, or a Hetzner box you set up yourself. The point is: pick a platform that lets you have a worker process, real env vars, and log access. If your current setup does not, fix that before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight, regardless of which platform you use
&lt;/h2&gt;

&lt;p&gt;If you have a vibe-coded app in production right now, run this sweep. 30 minutes, maybe 45.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;grep -r "sk_live_\|api_key\|secret" dist/ .next/ public/ build/&lt;/code&gt; against your built output. If anything matches, move the call to a backend route before you sleep.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open your three most-used database queries. Check for &lt;code&gt;LIMIT&lt;/code&gt;. If it is missing, add it and a default of 50.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search your codebase for &lt;code&gt;cors()&lt;/code&gt; with no arguments, or &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. Replace with a whitelist of the 2 or 3 origins you actually need.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pick one date-handling code path (signup, billing, scheduled email). Verify it stores UTC and displays in user-local. If you cannot tell, log the &lt;code&gt;Z&lt;/code&gt; suffix on the stored string and the &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; output on the client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;List your env vars. For each one, ask: would my app boot, or would it boot and then 500 on the first relevant request? Add a boot-time validator that crashes the process loudly if anything is missing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Find the one most important background job (welcome email, daily report, billing run). Verify it runs in a worker process or scheduled cron, not inside an HTTP handler.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set up one alert. Telegram, email, Slack, anything. The trigger does not need to be smart. "Error rate above 1% for 5 minutes" or "background job failed 3 times in a row" is enough to catch 80% of the bad nights.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest closing
&lt;/h2&gt;

&lt;p&gt;Vibe-coded apps are not bad. They get founders shipping who otherwise could not ship, and that is wonderful. But the production gap is real, and every model I have used (Claude included) will confidently generate code that hides the six holes above. The model is doing what it was asked. You have to ask the next question.&lt;/p&gt;

&lt;p&gt;I am curious: which of these six bit you the hardest? Or did you hit one I did not list? I have a strong suspicion there is a seventh I have not seen yet, and the comments here have been a good source of bug reports in the past.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Previous posts in this Belmo series:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/li&gt;
&lt;li&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/li&gt;
&lt;li&gt;Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.&lt;/li&gt;
&lt;li&gt;Telegram alerts for any production app, a 5-minute setup (no SaaS, no signup, just curl)&lt;/li&gt;
&lt;li&gt;How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)&lt;/li&gt;
&lt;li&gt;I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).&lt;/li&gt;
&lt;li&gt;The Claude Code → production checklist: 15 things that aren't obvious until they bite you&lt;/li&gt;
&lt;li&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.&lt;/li&gt;
&lt;li&gt;Your Stripe webhook is going to silently drop a paid customer. Here are the 4 patterns that catch it before they chargeback.&lt;/li&gt;
&lt;li&gt;Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>webdev</category>
      <category>beginners</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs (and the modern setup that fixes them in an afternoon).</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Sun, 17 May 2026 07:34:10 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-crontab-is-silently-failing-the-5-silent-killers-of-vps-based-cron-jobs-and-the-modern-setup-29g6</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-crontab-is-silently-failing-the-5-silent-killers-of-vps-based-cron-jobs-and-the-modern-setup-29g6</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo. This article mentions Belmo once at the end, but the patterns and the fix work on any platform. The point is to stop losing scheduled jobs in silence, not to sell you anything.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's Monday, 9:17 AM. The weekly digest email didn't go out. The Discord channel you wired to announce new signups is suspiciously quiet. You SSH into the $12 droplet that runs your scheduled jobs. &lt;code&gt;crontab -l&lt;/code&gt; looks fine. &lt;code&gt;/var/log/syslog&lt;/code&gt; says the job ran at 06:00. You open the output log. Empty.&lt;/p&gt;

&lt;p&gt;You don't know if the script crashed, if it sent the emails but logged nothing, or if cron started a second run before the first one finished and the two are now stepping on each other. You spend an hour digging instead of writing code.&lt;/p&gt;

&lt;p&gt;I've onboarded thirty-something teams off VPS-based cron in the last 14 months. The story is always a variation of the above. Cron itself isn't broken (it's been working since 1975). What's broken is the operational layer around cron when you run it on a generic Linux box you maintain yourself. Below are the 5 silent failure modes I see most often, and the modern setup that retires all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Killer #1: you edited the wrong crontab
&lt;/h2&gt;

&lt;p&gt;There are at least four places on a stock Ubuntu box where a cron job can live: the user's crontab (&lt;code&gt;crontab -e&lt;/code&gt;), the root crontab (&lt;code&gt;sudo crontab -e&lt;/code&gt;), &lt;code&gt;/etc/crontab&lt;/code&gt;, and any file inside &lt;code&gt;/etc/cron.d/&lt;/code&gt;. They use slightly different syntax (&lt;code&gt;/etc/crontab&lt;/code&gt; and &lt;code&gt;/etc/cron.d/*&lt;/code&gt; require a username field, the user-level crontab does not). Copy a snippet from a blog post into the wrong file and it silently does nothing. No error, no warning, no run.&lt;/p&gt;

&lt;p&gt;I once watched a senior engineer (ex-Oney, plenty of Linux years on him) lose an hour to this on a Friday afternoon. The fix isn't "be more careful". The fix is to not have four places where the same job could live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Killer #2: the environment your job runs in isn't your shell
&lt;/h2&gt;

&lt;p&gt;Cron jobs run with a near-empty environment. No &lt;code&gt;.bashrc&lt;/code&gt;, no &lt;code&gt;.profile&lt;/code&gt;, no &lt;code&gt;nvm&lt;/code&gt;, no &lt;code&gt;pyenv&lt;/code&gt;. &lt;code&gt;PATH&lt;/code&gt; is usually &lt;code&gt;/usr/bin:/bin&lt;/code&gt;, which means your job can't find &lt;code&gt;node&lt;/code&gt; if you installed it through nvm, can't find &lt;code&gt;python3.11&lt;/code&gt; if you're on a pyenv setup, and can't find &lt;code&gt;psql&lt;/code&gt; if you installed Postgres client tools through a custom apt repo.&lt;/p&gt;

&lt;p&gt;The classic symptom: the job runs interactively when you test it from your shell, runs silently as cron, and you discover three weeks later that &lt;code&gt;which node&lt;/code&gt; returned a different path under cron than under your login session.&lt;/p&gt;

&lt;p&gt;People work around it with line one of every crontab being:&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;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
&lt;span class="nv"&gt;SHELL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/bin/bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That helps with binaries. It doesn't help with secrets. Your &lt;code&gt;DATABASE_URL&lt;/code&gt; is in &lt;code&gt;~/.env&lt;/code&gt; and you assumed &lt;code&gt;source ~/.env&lt;/code&gt; runs. It doesn't (cron uses &lt;code&gt;/bin/sh&lt;/code&gt; by default, not your interactive bash). You end up wrapping every job in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /srv/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; ./.env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node scripts/digest.js &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/digest.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a lot of ceremony for "run this script every morning."&lt;/p&gt;

&lt;h2&gt;
  
  
  Killer #3: output goes to /dev/null, errors go nowhere
&lt;/h2&gt;

&lt;p&gt;The default cron behavior is to email the user the script's stdout and stderr. On a fresh VPS, there's no mail transport configured, so the output gets discarded. Most people add &lt;code&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt; to silence the warning messages. Now you have no error output anywhere.&lt;/p&gt;

&lt;p&gt;When the job crashes, you find out because a customer complains. Then you SSH in, scroll back through &lt;code&gt;/var/log/syslog&lt;/code&gt;, see that the cron daemon dutifully started your command, and have zero idea what happened inside it.&lt;/p&gt;

&lt;p&gt;The minimum fix is to log everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /srv/app/scripts/digest.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/digest.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better: pipe to a tool like &lt;code&gt;logger&lt;/code&gt; so it ends up in journald with proper tagging, so &lt;code&gt;journalctl -t digest&lt;/code&gt; returns history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /srv/app/scripts/digest.sh 2&amp;gt;&amp;amp;1 | logger &lt;span class="nt"&gt;-t&lt;/span&gt; digest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Best: have your script itself report its own success or failure to somewhere you actually read, like a Telegram chat or a Slack channel. I wrote a whole post on the Telegram side of that (linked at the bottom).&lt;/p&gt;

&lt;h2&gt;
  
  
  Killer #4: your VPS is a single point of failure
&lt;/h2&gt;

&lt;p&gt;The droplet reboots after a kernel update. The cron jobs run again as soon as the box comes back up, on the OS's clock. Fine, except your provider's clock skewed two minutes during the reboot and now your "every minute" health-check job and your "every five minutes" reconciliation job briefly overlap. Or the reboot took 8 minutes and the 06:00 digest didn't fire at all because the box wasn't on.&lt;/p&gt;

&lt;p&gt;Worse case: the disk fills up because nobody rotates &lt;code&gt;/var/log/digest.log&lt;/code&gt;. Cron itself starts failing because it can't write its own log file. You don't notice until a customer tells you the app is acting strange.&lt;/p&gt;

&lt;p&gt;I have a slide I use in onboarding calls that just says "production scheduled jobs should not depend on the uptime of a Linux box you personally maintain." Half the room nods. The other half says "but it's so cheap." It is cheap, until the day it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Killer #5: overlapping runs corrupt your data
&lt;/h2&gt;

&lt;p&gt;This is the one that bit a client most recently. SaaS doing inventory sync for indie ecommerce shops. The reconciliation job runs every 15 minutes. Normally it takes 3 minutes. Then their database hit a slow week, the job started taking 18 minutes, and now job N+1 starts while job N is still writing. Two simultaneous writers on the same Postgres rows. Half the inventory counts ended up wrong. They didn't notice for 4 days because the symptom was "some products show wrong stock", not "the script crashes."&lt;/p&gt;

&lt;p&gt;The fix is locking. The simplest version is &lt;code&gt;flock&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="k"&gt;*&lt;/span&gt;/15 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/bin/flock &lt;span class="nt"&gt;-n&lt;/span&gt; /tmp/reconcile.lock /srv/app/scripts/reconcile.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flock -n&lt;/code&gt; exits immediately if another instance holds the lock, instead of queuing. That prevents the overlap. It does not tell you that an overlap happened, so you can silently skip every 15-minute job for an hour and never notice. You only know if you instrument the script itself.&lt;/p&gt;

&lt;p&gt;At the database level, &lt;code&gt;pg_try_advisory_lock&lt;/code&gt; does the same thing inside Postgres:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;54321&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ... do work ...&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;54321&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either is fine. Neither shows up in the tutorial that taught you cron in the first hour. They become obvious only after you've been bitten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The modern setup, in three pieces
&lt;/h2&gt;

&lt;p&gt;I'll describe this generically because every reasonable PaaS in 2026 supports it. The three pieces are:&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;a scheduled job primitive your platform manages.&lt;/strong&gt; You declare "this command, this schedule" in config or a UI. The platform handles when the box reboots, when the cron daemon misbehaves, when overlapping runs need to be prevented. Render and Railway both call them Cron Jobs. Fly has scheduled Machines. The naming varies. The pattern is the same: the platform owns scheduling, you own the script.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;per-run logs you can actually read.&lt;/strong&gt; Not a log file you have to SSH and &lt;code&gt;tail&lt;/code&gt;. Logs in a UI, by run, with timestamps and exit codes. This is the single biggest quality-of-life upgrade over &lt;code&gt;crontab&lt;/code&gt;. You stop debugging "did it run?" and start debugging "what did it print?".&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;alerts on failure that go to a channel you check.&lt;/strong&gt; If exit code is non-zero, ping Telegram or Slack. Not email. Email gets buried (see article 4 in the series).&lt;/p&gt;

&lt;p&gt;That's the minimum. You can add nice-to-haves: retries with backoff, run history, manual "run now" buttons for debugging, isolation so a runaway job doesn't take down your web service. But scheduling, logs, and alerts is the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real before/after
&lt;/h2&gt;

&lt;p&gt;I'll show you the kind of script most indie SaaS use for a weekly digest. The "before" is what I see on VPS boxes. The "after" is the same script with proper instrumentation that works on any platform that gives you the three pieces above.&lt;/p&gt;

&lt;p&gt;Before (&lt;code&gt;digest.sh&lt;/code&gt; on a VPS, dropped into &lt;code&gt;crontab -e&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;0 9 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 1 &lt;span class="nb"&gt;cd&lt;/span&gt; /srv/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; /usr/bin/node scripts/digest.js &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After (&lt;code&gt;digest.js&lt;/code&gt;, run by the platform's scheduler, logs and alerts built in):&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;// scripts/digest.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&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;TELEGRAM_TOKEN&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;TELEGRAM_TOKEN&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;TELEGRAM_CHAT&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;TELEGRAM_CHAT_ID&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;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;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;TELEGRAM_TOKEN&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="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="s2"&gt;`https://api.telegram.org/bot&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage`&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;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TELEGRAM_CHAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&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;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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;digest.start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;at&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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;sent&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;sendWeeklyDigest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// your code&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&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;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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;digest.done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;sent&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="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;sent&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Weekly digest sent 0 emails (took &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="s2"&gt;ms). Investigate.`&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;run&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;async &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="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="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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;digest.error&lt;/span&gt;&lt;span class="dl"&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;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stack&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;stack&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="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Weekly digest 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;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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things changed: structured JSON logs (your platform's log viewer can filter them), a Telegram ping on actual failure or zero-send anomaly, and an explicit non-zero exit code so the platform marks the run as failed. None of this is exotic. All of it was inconvenient enough on a VPS that most people skipped it.&lt;/p&gt;

&lt;p&gt;The Python version is the same shape. &lt;code&gt;logging.info(json.dumps({...}))&lt;/code&gt;, &lt;code&gt;requests.post&lt;/code&gt; to Telegram, &lt;code&gt;sys.exit(1)&lt;/code&gt; from the exception handler. Six extra lines around your real script. The win is that you can answer "did Monday's digest succeed?" in under 10 seconds for the next two years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about background workers vs cron?
&lt;/h2&gt;

&lt;p&gt;Quick clarifier because I see this confused. A scheduled job runs on a clock (every 15 minutes, every Monday at 9 AM). A background worker runs continuously and consumes a queue (process this Stripe webhook now, render this PDF on request). Different primitives. You usually need both. Running both off the same cron line is a different conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I built Belmo partly because I was tired of writing the same flock plus logger plus Telegram wrapper for client after client. On the Pro tier, scheduled scripts are first-class with per-run logs, exit-code tracking, and an AI monitor that pings my Telegram bot when something looks off (silent cron failures are one of the patterns it flags, alongside the retry loops and token spikes from article 3). EU (Germany) and US (Oregon) regions, ISO 27001 and GDPR compliant. The Free Starter tier doesn't sleep if you want to try a scheduled script without paying.&lt;/p&gt;

&lt;p&gt;You don't need Belmo for this. Render, Railway, Fly, and Heroku all offer scheduled jobs as a primitive. The point of the article is that "scheduled jobs as a primitive" should be table stakes for your stack in 2026, regardless of which platform you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight regardless of which platform you use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List every cron line on every VPS you own. A one-liner: &lt;code&gt;for u in $(cut -d: -f1 /etc/passwd); do echo "=== $u ==="; sudo crontab -u $u -l 2&amp;gt;/dev/null; done&lt;/code&gt; plus &lt;code&gt;cat /etc/crontab&lt;/code&gt; plus &lt;code&gt;ls /etc/cron.d/&lt;/code&gt;. Write them down. You probably have more than you remember.&lt;/li&gt;
&lt;li&gt;For each one, ask: when did it last succeed? If you can't answer in under 30 seconds, you have no observability. That's the actual problem to fix first.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;flock -n&lt;/code&gt; to every job that writes to a shared resource. Five-minute change. Prevents overlap-induced data corruption.&lt;/li&gt;
&lt;li&gt;Add structured logging to the script itself. JSON line on start, JSON line on finish, JSON line on error. Make &lt;code&gt;grep digest.error your.log&lt;/code&gt; useful.&lt;/li&gt;
&lt;li&gt;Add a failure ping to a channel you actually read. Telegram is the cheapest option and there's a tutorial in this series (article 4).&lt;/li&gt;
&lt;li&gt;Pick one job, the most important one (the billing reconciliation, the daily backup, the weekly digest), and move it off the VPS to a managed scheduler this week. Don't migrate everything at once. Migrate the one that would hurt most if it silently failed for a week.&lt;/li&gt;
&lt;li&gt;Set a calendar reminder for 30 days from today to check the success rate of that migrated job. The whole point of the migration is observability. Test that it actually delivered.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Honest closing question
&lt;/h2&gt;

&lt;p&gt;The thing that keeps me up about cron isn't that it fails. It's that it fails silently and the discovery latency is measured in days. You find out when a customer complains, or the bill arrives, or the inventory is off.&lt;/p&gt;

&lt;p&gt;What's the longest a scheduled job has been silently broken in your stack before you noticed? I want to know whether the "I lost a week on a digest that wasn't sending" story is unusually bad or whether every indie SaaS has one in the closet.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Previous posts in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;Telegram alerts for any production app, a 5-minute setup (no SaaS, no signup, just curl)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e"&gt;The Claude Code to production checklist: 15 things that aren't obvious until they bite you&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-zero-working-postgres-backups-heres-the-20-minute-fix-and-the-drill-you-need-2hpj"&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/your-stripe-webhook-is-going-to-silently-drop-a-paid-customer-here-are-the-4-patterns-that-catch-1l0d"&gt;Your Stripe webhook is going to silently drop a paid customer. Here are the 4 patterns that catch it.&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>cron</category>
      <category>devops</category>
      <category>indiehackers</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your Stripe webhook is going to silently drop a paid customer. Here are the 4 patterns that catch it before they chargeback.</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Fri, 15 May 2026 07:34:55 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-stripe-webhook-is-going-to-silently-drop-a-paid-customer-here-are-the-4-patterns-that-catch-1l0d</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-stripe-webhook-is-going-to-silently-drop-a-paid-customer-here-are-the-4-patterns-that-catch-1l0d</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo. This article mentions Belmo once near the end. The four patterns below work on any platform, any framework, with any webhook provider (Stripe, GitHub, Twilio, Postmark). I want it useful even if you never become a customer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two weekends ago a founder I do code reviews for sent me a panicked DM at 11pm: "A customer just opened a chargeback because we never unlocked their Pro plan. Stripe says the subscription is active. What's happening?"&lt;/p&gt;

&lt;p&gt;His webhook handler had crashed three days earlier at 3:14am on a Sunday. Stripe retried for 72 hours, gave up, marked the event as failed in their dashboard. Nobody was watching. The customer paid $49, got the same free-tier experience, asked for a refund, didn't get a fast reply, opened a dispute. By the time we caught it, his MRR was down one customer and his Stripe account had a $15 dispute fee on top. The fix took an hour. The damage took weeks to unwind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks are the load-bearing wall of indie SaaS, and they're built on sand
&lt;/h2&gt;

&lt;p&gt;If you run a SaaS that takes payments, webhooks are how Stripe tells you a customer paid, upgraded, churned, or failed to pay. The redirect after checkout is a UX nicety. The webhook is the source of truth. Without it, your database and Stripe go out of sync, and that gap is where money disappears.&lt;/p&gt;

&lt;p&gt;The problem is that webhooks are the worst combination of "load-bearing" and "unreliable". The network blips. Your server returns 502 for the 30 seconds a deploy is booting. Your DB locks because some other query is doing a full table scan. Stripe sends duplicates on purpose (their delivery guarantee is at-least-once, not exactly-once). The webhook can arrive before your DB has the customer record if &lt;code&gt;checkout.session.completed&lt;/code&gt; beats your post-checkout redirect.&lt;/p&gt;

&lt;p&gt;Stripe will retry on a backoff for about 3 days, then quietly stop. After that, your state is permanently divergent unless you reconcile.&lt;/p&gt;

&lt;p&gt;Here are the four patterns I install on every indie SaaS I touch, usually after the first dropped customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: verify signature, acknowledge fast, process async
&lt;/h2&gt;

&lt;p&gt;The most common mistake I see in vibe-coded webhook handlers is doing the actual work inside the HTTP request. The handler verifies the signature, then runs through 15 lines of "if event.type is &lt;code&gt;invoice.paid&lt;/code&gt;, update the DB, send an email, hit the analytics API, set a feature flag." All synchronous. All in the request that Stripe is waiting on.&lt;/p&gt;

&lt;p&gt;If any of those steps is slow or fails, Stripe sees a timeout or 500 and retries. If your code is not idempotent (more on that next), you double-process. If the DB is locked, the whole request times out.&lt;/p&gt;

&lt;p&gt;The pattern is: do the minimum in the HTTP handler. Verify the signature. Persist the raw event. Acknowledge 200. Process asynchronously.&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;// webhook-handler.js (Express + Postgres)&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="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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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;stripe&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;Stripe&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;STRIPE_SECRET_KEY&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;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;router&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="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&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;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&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;event&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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="nx"&gt;sig&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;STRIPE_WEBHOOK_SECRET&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="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;400&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="s2"&gt;`Invalid signature: &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="s2"&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;// Persist the raw event. That's it. Process later.&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;db&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="s2"&gt;`INSERT INTO webhook_events (id, type, payload, received_at, status)
         VALUES ($1, $2, $3, NOW(), 'pending')
         ON CONFLICT (id) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;id&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="nx"&gt;type&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="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;status&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook_insert_failed&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;event_id&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="nx"&gt;id&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="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;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;persist failed&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler now does two things: verify, store. If the DB is reachable, you ack 200. If it isn't, you return 500 and Stripe retries, which is fine because step one (verify) is idempotent and step two has an &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;The actual work (granting Pro access, sending the welcome email, firing analytics) happens in a worker, which I'll get to in pattern 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: idempotency via the event ID
&lt;/h2&gt;

&lt;p&gt;Stripe will deliver the same event more than once. Sometimes because of a retry. Sometimes because of their internal failover. Sometimes because of you, when you return 200 after the work succeeded but before the response made it back through your CDN.&lt;/p&gt;

&lt;p&gt;The fix is one line of DDL:&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;webhook_events_pkey&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe event IDs are globally unique. If you make the primary key of your &lt;code&gt;webhook_events&lt;/code&gt; table the Stripe event ID, the &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; in pattern 1 becomes your dedupe layer. The second time the same event arrives, the insert silently noops and you return 200.&lt;/p&gt;

&lt;p&gt;The mistake to avoid: using your internal ID as primary key and the Stripe event ID as a regular column without a unique index. I've seen a team spend a week debugging "why did this customer get three welcome emails."&lt;/p&gt;

&lt;p&gt;Idempotency in the processing layer matters just as much. When the worker picks up an event, it should write a "processed" flag in the same transaction as whatever side effect it produces. If the worker crashes in between, the next run reruns. So side effects need to be safe to repeat: granting Pro twice should be a noop, sending the welcome email twice is survivable, sending a $50 refund twice is a Tuesday-morning incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: a dead-letter queue and a worker with backoff
&lt;/h2&gt;

&lt;p&gt;Once the raw events are in the table, you need a worker to drain it. The worker is simple:&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;// webhook-worker.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleStripeEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./handlers/stripe.js&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;BACKOFF&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// seconds&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;processNext&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&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;db&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="s2"&gt;`
    SELECT id, type, payload, attempts
    FROM webhook_events
    WHERE status = 'pending'
      AND (next_attempt_at IS NULL OR next_attempt_at &amp;lt;= NOW())
    ORDER BY received_at ASC
    LIMIT 1
    FOR UPDATE SKIP LOCKED
  `&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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="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;handleStripeEvent&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&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="s2"&gt;`UPDATE webhook_events SET status = 'done', processed_at = NOW() WHERE id = $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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempts&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;attempts&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;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BACKOFF&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="nx"&gt;attempts&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;BACKOFF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;dead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;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;await&lt;/span&gt; &lt;span class="nx"&gt;db&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="s2"&gt;`UPDATE webhook_events
       SET attempts = $1,
           last_error = $2,
           next_attempt_at = NOW() + (INTERVAL '1 second' * $3),
           status = $4
       WHERE id = $5`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;attempts&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;backoff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dead&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dead&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="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;id&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook_process_failed&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;event_id&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempts&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="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="nf"&gt;setInterval&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;while &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;processNext&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="mi"&gt;2000&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;FOR UPDATE SKIP LOCKED&lt;/code&gt; lets you run multiple workers without them stepping on each other. The exponential backoff prevents a poison-pill event from hammering your downstream services. After 10 attempts an event moves to "dead" and triggers an alert (pattern 4 handles the visibility on that).&lt;/p&gt;

&lt;p&gt;This worker is a separate process from your web service. It is not optional. If you run it inside your web service as a &lt;code&gt;setInterval&lt;/code&gt;, it dies when the web service redeploys or scales to zero. That is exactly the gap where the founder's chargeback story happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: the daily reconciliation cron
&lt;/h2&gt;

&lt;p&gt;Patterns 1 to 3 keep drift small. Pattern 4 fixes the drift that already happened.&lt;/p&gt;

&lt;p&gt;Once a day, fetch the source of truth and compare to your DB. For Stripe, that means iterating active subscriptions and verifying each one matches what your app thinks the customer has.&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;// reconcile-subscriptions.js (scheduled daily at 04:00 UTC)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sendAlert&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./alerts.js&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;stripe&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;Stripe&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;STRIPE_SECRET_KEY&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;reconcile&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;drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;sub&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&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="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&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;planTier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lookup_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. 'pro_monthly'&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;rows&lt;/span&gt; &lt;span class="p"&gt;}&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;db&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="s2"&gt;`SELECT plan FROM users WHERE stripe_customer_id = $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;customerId&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;drift&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing_user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;planTier&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="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;rows&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;plan&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;planTier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;drift&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plan_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;dbPlan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&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;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;stripePlan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;planTier&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;db&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="s2"&gt;`UPDATE users SET plan = $1, plan_synced_at = NOW() WHERE stripe_customer_id = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;planTier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;await&lt;/span&gt; &lt;span class="nf"&gt;sendAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Reconciliation drift: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;drift&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="s2"&gt; accounts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;drift&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;reconcile&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="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;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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time you run this on a 6-month-old codebase, you will find drift. I've never seen this fail to surface at least one mismatch. Log it loudly, repair the divergence, notify yourself.&lt;/p&gt;

&lt;p&gt;The reconciliation cron is the safety net that catches whatever the previous three patterns miss: the 3-day Stripe retry window that expired during a holiday, the customer who used the Customer Portal to switch plans while your webhook handler was returning 500, the silent failure that nobody noticed because your error tracking is muted on weekends.&lt;/p&gt;

&lt;p&gt;For GitHub, Twilio, Postmark and the rest, the same shape applies: if the provider has a list API for the resources its webhooks describe, you can reconcile. Fetch the truth, diff against your DB, repair, alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built (and why this matters)
&lt;/h2&gt;

&lt;p&gt;I run Belmo, a managed PaaS for developers, agencies, and non-tech founders. I built it because I was tired of solving the same shape of problem on every client engagement: a 3-process app (web service, worker, scheduled job) usually requires a Kubernetes setup or a $200/month bill for that to work reliably. Belmo gives you all three primitives on the €35/month Pro tier, with a free tier that does not sleep.&lt;/p&gt;

&lt;p&gt;This article exists because the AI log monitoring in Belmo flagged exactly this pattern for me last month: a client's webhook worker was silently 500ing on a specific event type for 4 hours before our Telegram alert fired. The pattern detection caught the cluster of "stripe_handle_failed" log lines, classified them as a hot Sentry fingerprint, and pinged me. I want every founder to have that net, even the ones who never become customers.&lt;/p&gt;

&lt;p&gt;That's the only Belmo mention in this article. The patterns above stand on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight, regardless of platform
&lt;/h2&gt;

&lt;p&gt;If you have not done these seven things, do them before you sleep:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open your webhook handler. Move every database write that isn't "insert the raw event" out of the HTTP request. The HTTP handler should be 20 lines or less.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add a unique constraint on the Stripe event ID column (or whichever upstream ID you use). If you cannot enforce idempotency at the DB layer, you do not have it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Schedule a daily reconciliation cron against your payment provider's API. Even if it is a 30-line script that just logs drift, you need the visibility.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add monitoring on webhook 5xx responses. Stripe shows this in their dashboard, but it is a polled view. Push it to whatever alerts you actually read (Telegram, Slack, Discord, email at minimum).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Test the failure path: stop your worker for 10 minutes, fire a test event from the Stripe CLI, confirm it ends up in the dead-letter state correctly and not lost. If you have never tested this, you do not have it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add a "dead" status alert. When any event moves to dead, page yourself. These are real customers in a bad state.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Audit how your post-checkout redirect handles the race condition with the webhook. If the user hits the success page before your webhook has fired, what do they see? "Upgrade pending" is correct; "Welcome to Pro" with no Pro access is the dispute path.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list takes an evening if you don't have any of it. Mine took half a Saturday on a small Express app, including the reconciliation cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  The closing question
&lt;/h2&gt;

&lt;p&gt;What's the worst webhook failure you've shipped? I'm collecting horror stories for a follow-up post on the rarer edge cases (idempotency keys colliding across test mode and live mode is one I lost a weekend to). Drop yours in the comments and I'll write up the patterns that fix them. The chargeback that prompted this article was a "we'll think about it later" hole in pattern 3, and "later" turned out to be a $64 mistake plus three sleepless days. I'd rather argue about edge cases now than wake up to another DM at 11pm.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Telegram alerts for any production app, a 5-minute setup (no SaaS, no signup, just curl)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;The Claude Code production checklist: 15 things that aren't obvious until they bite you&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-zero-working-postgres-backups-heres-the-20-minute-fix-and-the-drill-you-need-2hpj"&gt;Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix (and the drill you need to run before you sleep tonight).&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>devops</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix (and the drill you need to run before you sleep tonight).</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Thu, 14 May 2026 08:05:30 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-zero-working-postgres-backups-heres-the-20-minute-fix-and-the-drill-you-need-2hpj</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/your-indie-saas-has-zero-working-postgres-backups-heres-the-20-minute-fix-and-the-drill-you-need-2hpj</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm a senior backend tech lead in Paris and I run Belmo, a managed PaaS. I'll mention HG exactly once near the end. Everything else in this article works on any platform you ship on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A founder DMed me last month about a Render Postgres instance that had been humming along for nine months without a hiccup. Stripe charges going through, customers happy, MRR pushing past €4k. He wanted my opinion on something else, but during the call I asked how often his database backed up. He paused, opened the Render dashboard, clicked Postgres, then Backups, and saw exactly one snapshot from the day he provisioned the instance.&lt;/p&gt;

&lt;p&gt;This is not rare. I review side projects and small SaaS stacks every week, and "zero working backups" is the single most common operational bug in the deployment of someone who shipped fast and learned to operate later. It's also the cheapest bug in the world to fix. You can wire up a working strategy in twenty minutes. The reason most solo founders don't is that the canonical advice (configure AWS RDS, set up S3 lifecycle policies, write a Lambda, install Datadog) is written for a team of four. So we're skipping all of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lie we tell ourselves about "managed" databases
&lt;/h2&gt;

&lt;p&gt;Almost every managed Postgres provider has a "backups" tab. Render does. Supabase does. Railway does. Neon does. AWS RDS does. People glance at this tab once, see the word "automatic," and assume the problem is solved.&lt;/p&gt;

&lt;p&gt;It is not solved, for three reasons.&lt;/p&gt;

&lt;p&gt;First, the default retention window on free and hobby tiers is much shorter than founders think. Render's hobby Postgres retains daily snapshots, but only for a few days, and the snapshots stop the moment you exceed plan limits. Supabase free retention covers a single day on the current tier. Neon has branching but no automatic point-in-time recovery on the free plan past 24 hours. None of this is hidden, but I have not yet met an indie founder who has read the fine print on the plan they signed up for in 2024.&lt;/p&gt;

&lt;p&gt;Second, "managed" backups are usually stored on the same vendor as your live database. If your account is suspended (billing failure, terms-of-service trigger, a misunderstanding on a 2am support ticket), your backups vanish with the rest of the instance. I have watched this happen twice in two years. Both founders had paying customers. Both lost data they would have paid me five figures to recover. There was nothing to recover.&lt;/p&gt;

&lt;p&gt;Third, the restore path is almost never tested. A snapshot you cannot restore from in under fifteen minutes is not a backup. It is hope.&lt;/p&gt;

&lt;p&gt;Working backups for a solo founder satisfy three properties: they run automatically, they live somewhere your primary vendor cannot touch, and you have personally restored from them at least once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real backup strategy looks like for a 1-person SaaS
&lt;/h2&gt;

&lt;p&gt;You want three things in place. None of them require a DevOps hire.&lt;/p&gt;

&lt;p&gt;A nightly logical dump of your production database, pushed to an external store. "Logical" means &lt;code&gt;pg_dump&lt;/code&gt;, not a filesystem snapshot. Logical dumps are slower than snapshots, but they are portable: you can restore them onto any Postgres of compatible major version, on any provider, from any laptop. For a SaaS with a database under 10 GB (which is most indie SaaS in their first two years), this is the right primitive.&lt;/p&gt;

&lt;p&gt;A retention policy of at least thirty days, daily. For most products, the urgent question is not "what was the data five minutes ago" (that's what your live database is for). The question is "what did the data look like before the migration I shipped on Tuesday that quietly nuked the &lt;code&gt;users.timezone&lt;/code&gt; column." Thirty daily snapshots covers nearly every realistic incident I've seen in fifteen years. If you need point-in-time recovery within seconds, you are past the threshold of this article and you should pay an ops person.&lt;/p&gt;

&lt;p&gt;A restore drill, run once, written down. The single most useful operational habit I've ever developed is restoring a backup to a scratch database, running a quick query against it, and timing how long the whole thing took. The first time I did this on a project at koodos labs back in NYC, the restore "worked" but the encoding settings on the receiving instance differed enough that a couple of emoji-heavy columns came back mangled. Better to find that out on a Sunday afternoon than during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-line cron job that gets you 80% there
&lt;/h2&gt;

&lt;p&gt;Here is the smallest setup that satisfies all three properties. Drop it on any host with cron and &lt;code&gt;pg_dump&lt;/code&gt; available, plus credentials to write to one external object store (Backblaze B2, Cloudflare R2, Wasabi, or AWS S3 if you must).&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/backup-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

pg_dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

rclone copyto &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"b2:my-bucket/postgres/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four substantive lines. Put it in &lt;code&gt;/etc/cron.daily&lt;/code&gt; (or your platform's scheduled-jobs feature), set &lt;code&gt;DATABASE_URL&lt;/code&gt; and the &lt;code&gt;rclone&lt;/code&gt; config in env vars, and you have a daily off-vendor backup. &lt;code&gt;rclone&lt;/code&gt; is one binary, no dependencies, and it talks to virtually every cloud storage provider with the same syntax.&lt;/p&gt;

&lt;p&gt;For retention, give the bucket a lifecycle rule: keep objects for 30 days, then delete. Cloudflare R2 and Backblaze B2 both have these in their UI under "Bucket settings." You don't need to write code for the rotation, just configure it once.&lt;/p&gt;

&lt;p&gt;What this setup does not do: it does not protect you against a backup that is silently broken (a dump that says "succeeded" but is missing tables because of a permission issue, or a &lt;code&gt;gzip&lt;/code&gt; that truncated because the disk filled up). The simplest defense is a size sanity check. If today's compressed dump is dramatically smaller than yesterday's, something is wrong, and you want a Telegram message before you find out the hard way.&lt;/p&gt;

&lt;p&gt;Here is the version I actually use, which adds that check.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/backup-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;
&lt;span class="nv"&gt;LAST_SIZE_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/lib/backups/last-size"&lt;/span&gt;

pg_dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;%s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;LAST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAST_SIZE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 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;"&lt;/span&gt;&lt;span class="nv"&gt;$SIZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RATIO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN{print a/b}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s2"&gt;"BEGIN {exit !(&lt;/span&gt;&lt;span class="nv"&gt;$RATIO&lt;/span&gt;&lt;span class="s2"&gt; &amp;lt; 0.8)}"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TG_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;chat_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TG_CHAT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Backup shrank to &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RATIO&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x of yesterday. Investigate."&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAST_SIZE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
rclone copyto &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"b2:my-bucket/postgres/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't have Telegram alerting wired up yet, see article #4 in this series. The Telegram piece takes five minutes and is the thing I'd put on every project before I'd install Sentry.&lt;/p&gt;

&lt;p&gt;A note for the Postgres pedants: &lt;code&gt;--format=plain&lt;/code&gt; is intentional. Custom format (&lt;code&gt;-Fc&lt;/code&gt;) is faster and smaller, and &lt;code&gt;pg_restore&lt;/code&gt; is more flexible against it, but plain SQL is human-readable. I have personally done a partial restore by opening a backup in &lt;code&gt;vim&lt;/code&gt; and copying out the rows I needed. You will not regret choosing plain text the first time you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring is the test you actually need to run
&lt;/h2&gt;

&lt;p&gt;This is the step almost everyone skips. It is the step that turns "I have backups" into "I have working backups." They are not the same thing.&lt;/p&gt;

&lt;p&gt;Once a quarter, do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pull yesterday's backup down&lt;/span&gt;
rclone copyto b2:my-bucket/postgres/&amp;lt;yesterday&amp;gt;.sql.gz /tmp/restore.sql.gz

&lt;span class="c"&gt;# 2. Spin up a scratch Postgres locally&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; scratch-pg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5433:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16

&lt;span class="c"&gt;# 3. Restore&lt;/span&gt;
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; /tmp/restore.sql.gz | psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; postgres

&lt;span class="c"&gt;# 4. Sanity-check a row count&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; postgres &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select count(*) from users;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time it. Write the elapsed minutes down. The number you want in your head is your recovery time: how long from "database is gone" to "product is back up." For most indie SaaS the answer should be under an hour, and most of that should be data transfer. If it takes you four hours to figure out how to restore, your backups are doing less for you than you think.&lt;/p&gt;

&lt;p&gt;The first time you run this drill, you will hit one of these problems. The dump is missing a schema you didn't know about (Postgres has &lt;code&gt;public&lt;/code&gt; plus often a &lt;code&gt;pg_catalog&lt;/code&gt;, plus extension schemas like &lt;code&gt;pgvector&lt;/code&gt; or &lt;code&gt;pg_trgm&lt;/code&gt;). The Postgres major versions are incompatible because you upgraded the live instance and forgot the dump tooling. The &lt;code&gt;gunzip&lt;/code&gt; produces a corrupted file because last night's S3 upload timed out and you only stored the truncated piece. The role definitions clash because you used &lt;code&gt;--no-owner&lt;/code&gt; but a function depends on a specific role.&lt;/p&gt;

&lt;p&gt;Every one of those is easier to debug on a Sunday afternoon than at 3am during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three mistakes I see every single week
&lt;/h2&gt;

&lt;p&gt;The first is using only the vendor's built-in backups. We covered this above. If your provider's account suspends, your billing card expires, or the region has a bad day, the backups go with the database. Off-vendor storage is not optional.&lt;/p&gt;

&lt;p&gt;The second is backing up only the database. Indie SaaS often has uploaded user files (avatars, generated PDFs, CSV exports, AI-generated images) sitting on the same disk as the app, or in the vendor's local volume. If you are already using S3-compatible object storage for uploads, you are fine: those buckets have their own durability and you can mirror them with &lt;code&gt;rclone sync&lt;/code&gt; on the same schedule as your DB. If you are storing uploads on the dyno's local disk, you have unbacked-up state, and the day the dyno is recycled you discover this. Move that to object storage first.&lt;/p&gt;

&lt;p&gt;The third is the one I want to spend a paragraph on, because it bites people who think they did everything right. If your app stores PII encrypted at the column level (which it should, especially under GDPR), the database dump is useless without the encryption key. The key lives in an env var or a secrets manager. Back that up too. Store it separately, in a password manager or a dedicated secrets vault, and write down the recovery procedure. I lost an afternoon to this exact configuration drift on a client project a few years ago. The database came back fine and we still couldn't read half the columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I run Belmo because I spent fifteen years (Oney, BeReal, Ringover, koodos labs, agency contracts) writing variations of the cron above for every new project. At some point I got tired of rebuilding the same scaffolding and shipped a managed PaaS where daily off-vendor Postgres backups, encrypted env vars, and AI-driven Telegram alerts (including the "your backup shrank" pattern from above) are part of the default experience. EU and US data centers, GDPR, ISO 27001, the routine. The free tier doesn't sleep. That's the only mention you'll get. Everything in this article works on Render, Railway, Fly.io, Supabase, a raw VPS, or your own Kubernetes cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight regardless of which platform you use
&lt;/h2&gt;

&lt;p&gt;Five steps, in order. Block off an hour.&lt;/p&gt;

&lt;p&gt;Step one. Pull your live database with &lt;code&gt;pg_dump&lt;/code&gt; from your laptop right now. Time it. If you can do it at all, you have a baseline. If you cannot (credentials are wrong, network rules block you, you've forgotten the password to the live DB role), fix that first. You need this skill to exist before you automate anything.&lt;/p&gt;

&lt;p&gt;Step two. Create a bucket on Backblaze B2 or Cloudflare R2. Both have free tiers that cover a 10 GB SaaS for years. Generate an access key, store it in your password manager, and verify you can &lt;code&gt;rclone copyto&lt;/code&gt; a test file into it.&lt;/p&gt;

&lt;p&gt;Step three. Wire up the cron script from this article. Put it on whatever scheduler your platform exposes (Render scheduled jobs, Fly cron, GitHub Actions on a &lt;code&gt;schedule:&lt;/code&gt;, your PaaS's on-demand script feature, or a &lt;code&gt;/etc/cron.daily&lt;/code&gt; entry on a VPS). Run it once manually. Confirm the file lands in the bucket.&lt;/p&gt;

&lt;p&gt;Step four. Set a lifecycle rule on the bucket: retain for 30 days, delete after. This step takes 90 seconds in the B2 or R2 UI. Without it, you'll pay for storage forever and the bucket will become a haystack.&lt;/p&gt;

&lt;p&gt;Step five. Block off ninety minutes next weekend for the restore drill. Restore yesterday's dump to a scratch Postgres, run a row-count query, write down the elapsed time. That number is your recovery time objective. Now you can answer the customer who asks "what happens if you lose my data" without lying.&lt;/p&gt;

&lt;p&gt;If you only do step one tonight, you've already moved the needle. Most founders haven't.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;I'd be curious from anyone reading: have you ever actually restored from a backup in production, not as a drill but because something went wrong? What broke in the restore that you didn't expect? The interesting failure modes are not in the docs, and I'd love to read them in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.&lt;/li&gt;
&lt;li&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/li&gt;
&lt;li&gt;Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.&lt;/li&gt;
&lt;li&gt;Telegram alerts for any production app: a 5-minute setup (no SaaS, no signup, just curl)&lt;/li&gt;
&lt;li&gt;How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)&lt;/li&gt;
&lt;li&gt;I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).&lt;/li&gt;
&lt;li&gt;The Claude Code → production checklist: 15 things that aren't obvious until they bite you&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>devops</category>
      <category>indiehackers</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The Claude Code production checklist: 15 things that aren't obvious until they bite you</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Tue, 12 May 2026 12:14:16 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/the-claude-code-production-checklist-15-things-that-arent-obvious-until-they-bite-you-3p7n</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/the-claude-code-production-checklist-15-things-that-arent-obvious-until-they-bite-you-3p7n</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo. This list applies to any platform; Belmo happens to handle a few of these for you automatically, which I'll flag honestly when relevant.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I've helped about a dozen non-technical founders take their first Claude Code MVP from &lt;code&gt;localhost:3000&lt;/code&gt; to a real production URL in the last six months. They are &lt;em&gt;much&lt;/em&gt; better at shipping than the same founders would have been two years ago.&lt;/p&gt;

&lt;p&gt;But the same 15 things keep biting them in the first two weeks after launch. Almost none of these are about the code being wrong. They are about production being a different environment with its own rules, rules nobody warned anyone about because everyone assumed you already knew them.&lt;/p&gt;

&lt;p&gt;This is the checklist I now send to every founder before they go live. If you went live in the last 30 days and didn't go through it, you almost certainly have at least four of these issues right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your &lt;code&gt;.env&lt;/code&gt; file is in your git history
&lt;/h2&gt;

&lt;p&gt;Even if your current code has &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt;, check git history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; .env
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; .env.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything shows up, your API keys were exposed at some point. They are still exposed, because git history is forever. &lt;strong&gt;Rotate every key in that file.&lt;/strong&gt; Today. Yes, even if the repo is private. Future you who makes the repo public for an open-source moment will thank present you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. You have one set of API keys for dev and prod
&lt;/h2&gt;

&lt;p&gt;Same &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, same &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt;, same &lt;code&gt;SENDGRID_API_KEY&lt;/code&gt; in your laptop and on the production server. The first time you accidentally run a test script that fires 500 emails or charges 200 cards, you'll wish you had a &lt;code&gt;*_DEV&lt;/code&gt; and a &lt;code&gt;*_PROD&lt;/code&gt;. Make separate keys per environment. Today.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your Stripe webhook is unsigned
&lt;/h2&gt;

&lt;p&gt;When Stripe POSTs to &lt;code&gt;/api/webhooks/stripe&lt;/code&gt;, you should verify the signature header before trusting the payload. If your code just reads &lt;code&gt;req.body.amount&lt;/code&gt; and credits the user's account, anyone on the internet can hit that URL with fake events and give themselves credits.&lt;/p&gt;

&lt;p&gt;The fix is three lines:&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;sig&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Now use event.type, event.data — verified.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Required reading: &lt;a href="https://stripe.com/docs/webhooks/signatures" rel="noopener noreferrer"&gt;Stripe's webhook signature docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. You're using Stripe test keys in production
&lt;/h2&gt;

&lt;p&gt;Your &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; starts with &lt;code&gt;sk_test_...&lt;/code&gt; instead of &lt;code&gt;sk_live_...&lt;/code&gt;. Real payments hit Stripe's test environment, which... doesn't charge anybody. You launch, you celebrate the first sale, three days later you realize Stripe has $0 from you.&lt;/p&gt;

&lt;p&gt;Same with &lt;code&gt;STRIPE_PUBLISHABLE_KEY&lt;/code&gt; and the frontend &lt;code&gt;pk_test_*&lt;/code&gt; vs &lt;code&gt;pk_live_*&lt;/code&gt;. Match them. Double-check after every deploy in the first week.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Your database has no backup strategy
&lt;/h2&gt;

&lt;p&gt;"I'll set up backups later" is a sentence I have heard about 100 times. Approximately zero of those people set up backups later. Then someone (often Claude) runs a migration that drops a table, and the conversation is over.&lt;/p&gt;

&lt;p&gt;Most managed databases (Supabase, Neon, managed Postgres on any PaaS) have automatic daily backups built in but &lt;strong&gt;only if you turn it on&lt;/strong&gt;. Click around your database dashboard now. If you don't see "Backups enabled," fix it before reading item 6.&lt;/p&gt;

&lt;p&gt;For self-hosted: &lt;code&gt;pg_dump&lt;/code&gt; to S3 / Cloudflare R2 nightly via a cron. Test the restore. Once. The 5 minutes you spend testing is the difference between "we recovered" and "we lost everything."&lt;/p&gt;

&lt;h2&gt;
  
  
  6. You have no rate limiting on AI endpoints
&lt;/h2&gt;

&lt;p&gt;You have a &lt;code&gt;/api/chat&lt;/code&gt; route that calls OpenAI. Someone (a scraper, a bored teen, your competitor) discovers it and hits it in a &lt;code&gt;for&lt;/code&gt; loop. By the time you notice, your OpenAI bill is up by $400 and the abuser has stopped.&lt;/p&gt;

&lt;p&gt;Even a stupid rate limit is much better than no rate limit:&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;// Crude but works&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ipHits&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="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="s1"&gt;/api/chat&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&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;ip&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;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ipHits&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;ip&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;recentHits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hits&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;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;recentHits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&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;slow down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;recentHits&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;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ipHits&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;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recentHits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... your real handler&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10 calls/minute per IP. Most legitimate users won't hit it. Most abusers will. For real production, use a library (express-rate-limit, slowapi, etc.) with Redis-backed counters.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. CORS is wide open
&lt;/h2&gt;

&lt;p&gt;You have &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; in your headers or &lt;code&gt;cors({ origin: '*' })&lt;/code&gt; in your Express setup. For a public read-only API, fine. For anything with auth, this means any random website can make authenticated requests as your logged-in users.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;origin&lt;/code&gt; to your specific frontend domain. If you need both &lt;code&gt;https://yourapp.com&lt;/code&gt; and &lt;code&gt;https://www.yourapp.com&lt;/code&gt;, use an allowlist:&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;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.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="s1"&gt;https://www.yourapp.com&lt;/span&gt;&lt;span class="dl"&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;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="na"&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;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="o"&gt;=&amp;gt;&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="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;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. You haven't set up error tracking
&lt;/h2&gt;

&lt;p&gt;Sentry takes 25 minutes to install. Until you have it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your users find bugs before you do&lt;/li&gt;
&lt;li&gt;You spend hours guessing what broke from screenshots&lt;/li&gt;
&lt;li&gt;You miss the bugs that don't generate user complaints (and there are a lot of those)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Install it tonight. Free tier covers 5K errors/month, plenty for any startup under 10K users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save&lt;/span&gt; @sentry/nextjs   &lt;span class="c"&gt;# or @sentry/node, @sentry/python, etc.&lt;/span&gt;
npx @sentry/wizard &lt;span class="nt"&gt;-i&lt;/span&gt; nextjs        &lt;span class="c"&gt;# follows a guided setup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Your app ships source maps to production
&lt;/h2&gt;

&lt;p&gt;Source maps make stack traces readable, but if they're served publicly, anyone can open Chrome DevTools and read your original (TypeScript / unminified) code. This includes your API logic, your prompts to OpenAI, your business rules.&lt;/p&gt;

&lt;p&gt;For Next.js:&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;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;productionBrowserSourceMaps&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload source maps to Sentry instead (so YOU can debug stack traces) and exclude them from the public bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. You have no &lt;code&gt;/healthz&lt;/code&gt; endpoint
&lt;/h2&gt;

&lt;p&gt;Most hosting platforms periodically ping a health endpoint to know if your app is alive. If you don't have one, the platform pings your homepage, which loads your full app stack including AI calls, which is slow and expensive.&lt;/p&gt;

&lt;p&gt;Add one line:&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;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;/healthz&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;status&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure your hosting platform's health check to point at &lt;code&gt;/healthz&lt;/code&gt;. Cheap, fast, useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Your DNS TTL is too high
&lt;/h2&gt;

&lt;p&gt;Most domain registrars default to a 24-hour or 4-hour TTL (Time To Live) on DNS records. This means when you change your domain to point at a new host, browsers and ISPs cache the old DNS for up to 24 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before any DNS migration&lt;/strong&gt;, log into your registrar (Namecheap, OVH, Cloudflare) and set TTL to &lt;strong&gt;300 seconds&lt;/strong&gt; (5 minutes). Wait one TTL period. Then make your changes. Propagation will be minutes, not hours.&lt;/p&gt;

&lt;p&gt;Do this &lt;em&gt;before&lt;/em&gt; you need to migrate, not the day of.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Your runtime version isn't pinned
&lt;/h2&gt;

&lt;p&gt;Claude Code generates code targeting whatever Node / Python / Ruby version it's currently aware of (often the latest). Your hosting platform might run an older default. Result: subtle bugs that work on your laptop and break in prod.&lt;/p&gt;

&lt;p&gt;For Node, in your &lt;code&gt;package.json&lt;/code&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&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;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20.x"&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;p&gt;For Python, create &lt;code&gt;runtime.txt&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;python-3.11.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Ruby, &lt;code&gt;.ruby-version&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;3.2.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin once. Forget about it. Your future self never debugs "works on my laptop" again.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Server secrets are in your frontend bundle
&lt;/h2&gt;

&lt;p&gt;In Next.js, environment variables prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; are sent to the browser. If you accidentally name your server secret &lt;code&gt;NEXT_PUBLIC_STRIPE_SECRET_KEY&lt;/code&gt;, you have just published it to every user's Chrome.&lt;/p&gt;

&lt;p&gt;Rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; → safe for the browser (analytics IDs, Stripe &lt;strong&gt;publishable&lt;/strong&gt; keys, feature flags)&lt;/li&gt;
&lt;li&gt;Any actual &lt;em&gt;secret&lt;/em&gt; → no prefix. Server-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same idea in Vite (&lt;code&gt;VITE_*&lt;/code&gt;), Create React App (&lt;code&gt;REACT_APP_*&lt;/code&gt;), Astro, etc. &lt;strong&gt;Audit your &lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt; for any "PUBLIC" variable that shouldn't be.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. You have no monitoring on cron jobs
&lt;/h2&gt;

&lt;p&gt;You set up a nightly job at &lt;code&gt;0 3 * * *&lt;/code&gt;. It worked for the first three days. It hasn't run in two weeks because of a &lt;code&gt;node_modules&lt;/code&gt; issue you didn't notice. You only realize when you check the database and see no new data.&lt;/p&gt;

&lt;p&gt;Two ways to know your crons are running:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easy way&lt;/strong&gt;: every cron pings a service like &lt;a href="https://healthchecks.io" rel="noopener noreferrer"&gt;healthchecks.io&lt;/a&gt; (free for solo use). If the ping doesn't arrive within the expected window, it emails you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /opt/myapp/nightly.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://hc-ping.com/&amp;lt;uuid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Harder way&lt;/strong&gt;: hosted platforms with built-in cron observability. Belmo does this via its AI monitoring layer (more on that at the end). Render and Railway expose cron logs but you have to remember to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  15. Your app crashes silently on startup, restarts forever
&lt;/h2&gt;

&lt;p&gt;A crash on boot looks like this: your platform starts your container, your app throws an uncaught exception within 2 seconds, platform restarts, repeat. Externally, your domain just returns 503s.&lt;/p&gt;

&lt;p&gt;If you don't have a &lt;code&gt;process.on('uncaughtException')&lt;/code&gt; handler that logs to your error tracker AND alerts you on Telegram/Slack/email, this can go on for hours before you notice.&lt;/p&gt;

&lt;p&gt;Minimum viable setup:&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;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="s1"&gt;uncaughtException&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FATAL:&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="c1"&gt;// Optionally: send to Sentry, post to Telegram, etc.&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="c1"&gt;// let the platform restart cleanly&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make sure your hosting platform's health check is configured (item 10), so when crashes happen it actually stops trying to restart endlessly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The faster path through this list
&lt;/h2&gt;

&lt;p&gt;Some of these are handled for you on managed PaaS platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Belmo&lt;/strong&gt; (full disclosure: I build it): encrypted env vars (so item 13's blast radius is smaller if you misname), AI-monitored crash loops (so item 15 pings you on Telegram automatically), built-in cron job monitoring with the same Telegram alert (item 14). The list of &lt;em&gt;what you still need to do yourself&lt;/em&gt; remains long though: items 1–4 (key hygiene), 5 (backups), 6–7 (rate limiting + CORS), 9 (source maps), 11 (DNS), 12 (runtime pinning). The platform can't fix code-level decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render / Railway / Fly.io&lt;/strong&gt;: similar pattern. Some items (env vars in dashboard, basic process restart logic) are handled. The code-level items remain yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPS + Coolify or Dokku&lt;/strong&gt;: you handle all 15 yourself. That's fine if you have the time and discipline. Most solo founders don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right move is: pick a platform that handles a few of these so you can focus on the rest, then &lt;em&gt;actually go through the rest&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;git log -- .env&lt;/code&gt; (item 1) — 30 seconds. If anything appears, rotate keys immediately.&lt;/li&gt;
&lt;li&gt;Check your Stripe key prefix (item 4) — 10 seconds. &lt;code&gt;echo $STRIPE_SECRET_KEY | head -c 10&lt;/code&gt;. Should be &lt;code&gt;sk_live_...&lt;/code&gt; in prod.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;/healthz&lt;/code&gt; endpoint (item 10) — 2 minutes.&lt;/li&gt;
&lt;li&gt;Add an &lt;code&gt;uncaughtException&lt;/code&gt; handler (item 15) — 5 minutes.&lt;/li&gt;
&lt;li&gt;Verify your database has backups enabled (item 5) — 2 minutes in your DB dashboard.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's 10 minutes for the five most critical items. The other 10 you can do over the week.&lt;/p&gt;

&lt;p&gt;If you build with Claude Code, this list is also a useful prompt: &lt;em&gt;"Audit my repo against the following 15 items..."&lt;/em&gt; Claude will go through each one and tell you which apply, which don't, and give you a fix for the ones that do. It catches most of them. The audit takes maybe 15 minutes total.&lt;/p&gt;

&lt;p&gt;What's the most embarrassing thing you've shipped to production that this list would have caught? I'm collecting horror stories for v2.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/heroku-just-went-into-sustaining-engineering-mode-here-are-5-alternatives-whose-free-tier-58id"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;4. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Telegram alerts for any production app — a 5-minute setup.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;5. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;How I built a Discord 'ship-tracker' bot in a weekend.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;6. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/i-migrated-12-client-projects-off-heroku-heres-the-playbook-and-the-7-things-that-bit-me-every-1j4j"&gt;I migrated 12 client projects off Heroku. Here's the playbook.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 11 May 2026 13:18:26 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/i-migrated-12-client-projects-off-heroku-heres-the-playbook-and-the-7-things-that-bit-me-every-1j4j</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/i-migrated-12-client-projects-off-heroku-heres-the-playbook-and-the-7-things-that-bit-me-every-1j4j</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo. Six of these 12 migrations landed on Belmo. The other six went to Render, Railway, Fly.io, or back to a VPS. The playbook below works regardless of destination — it's the Heroku-side problems I want you to skip.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Since Heroku announced "sustaining engineering mode" in February 2026, I've been the person clients call when they need to get off the platform without breaking production. As of this week I've done 12 migrations — Rails apps, Django apps, Node services, Python workers — for clients ranging from 200-MAU side projects to 80K-MAU consumer apps.&lt;/p&gt;

&lt;p&gt;If you're staring at your Heroku dashboard wondering when to leave, this is the post I wish someone had written before I started doing these.&lt;/p&gt;

&lt;p&gt;It's two parts: &lt;strong&gt;the playbook&lt;/strong&gt; (the order of operations that works), and &lt;strong&gt;the 7 things that bit me&lt;/strong&gt; (the stuff nobody warns you about until you hit it at 11pm on launch night).&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — The playbook
&lt;/h2&gt;

&lt;p&gt;Every migration I've done follows the same eight-step order. I've tried shuffling it. The shuffling always costs me. Just do it in this order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 0: Decide the destination first
&lt;/h3&gt;

&lt;p&gt;Don't start migrating until you know where you're going. Each destination changes step 4 and step 7 significantly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt;: closest to old Heroku ergonomics, web service sleeps on free tier, Postgres is solid&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Railway&lt;/strong&gt;: best DX for small projects, usage-based pricing surprises at scale, Postgres reliability has been variable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt;: best if you need multi-region, requires a &lt;code&gt;fly.toml&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belmo&lt;/strong&gt;: managed PaaS, EU + US, AI monitoring built in, predictable pricing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS / GCP&lt;/strong&gt;: only if you have a real DevOps person on the team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPS + Coolify/Dokku&lt;/strong&gt;: cheapest, you maintain the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the rest of this playbook I'll be platform-agnostic except where it matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Inventory what's actually running
&lt;/h3&gt;

&lt;p&gt;Before touching anything, list every dyno, every add-on, every config var, every scheduled job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku ps &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp        &lt;span class="c"&gt;# web + worker dynos&lt;/span&gt;
heroku addons &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp     &lt;span class="c"&gt;# databases, redis, monitoring, etc.&lt;/span&gt;
heroku config &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp     &lt;span class="c"&gt;# env vars (don't paste this in Slack)&lt;/span&gt;
heroku features &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp   &lt;span class="c"&gt;# any legacy/labs features still on&lt;/span&gt;
heroku scheduler:jobs &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp  &lt;span class="c"&gt;# if using Heroku Scheduler&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pipe these into a markdown file in your repo (&lt;code&gt;MIGRATION.md&lt;/code&gt;). You will need to refer to it 6 times.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Provision the destination
&lt;/h3&gt;

&lt;p&gt;On the new platform, create everything that needs to exist &lt;em&gt;before&lt;/em&gt; the cutover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The web service (don't deploy real code yet — a placeholder is fine)&lt;/li&gt;
&lt;li&gt;Workers if you have them&lt;/li&gt;
&lt;li&gt;Database (always Postgres for me; we'll cover the dump-restore in step 5)&lt;/li&gt;
&lt;li&gt;Redis if you have Sidekiq/BullMQ&lt;/li&gt;
&lt;li&gt;Object storage if you use S3-equivalent&lt;/li&gt;
&lt;li&gt;Any external API webhooks (you'll need to update their target URLs in step 7)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Goal at end of step 2: every "thing" exists on the new side, empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Copy env vars carefully
&lt;/h3&gt;

&lt;p&gt;This is where every migration gets a paper cut. Heroku's &lt;code&gt;heroku config&lt;/code&gt; output looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;: &lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;user&lt;/span&gt;:&lt;span class="n"&gt;pass&lt;/span&gt;@&lt;span class="n"&gt;host&lt;/span&gt;:&lt;span class="m"&gt;5432&lt;/span&gt;/&lt;span class="n"&gt;db&lt;/span&gt;?&lt;span class="n"&gt;sslmode&lt;/span&gt;=&lt;span class="n"&gt;require&lt;/span&gt;
&lt;span class="n"&gt;REDIS_URL&lt;/span&gt;:    &lt;span class="n"&gt;redis&lt;/span&gt;://...
&lt;span class="n"&gt;RAILS_MASTER_KEY&lt;/span&gt;: ...
&lt;span class="n"&gt;STRIPE_SECRET_KEY&lt;/span&gt;: &lt;span class="n"&gt;sk_live_&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to watch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DATABASE_URL&lt;/strong&gt; will need to change to the new DB's URL. Don't paste the Heroku one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the trailing &lt;code&gt;?sslmode=require&lt;/code&gt;&lt;/strong&gt; — this is the #1 silent migration killer (more on this in Part 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heroku auto-generates &lt;code&gt;PORT&lt;/code&gt; for you&lt;/strong&gt;; on most platforms you do too, so don't manually copy it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 4: Deploy code to the new platform — get "hello world" working
&lt;/h3&gt;

&lt;p&gt;Push your code to the new platform. Don't migrate the database yet. Just confirm the platform can build your code and your &lt;code&gt;/healthz&lt;/code&gt; endpoint returns 200.&lt;/p&gt;

&lt;p&gt;If you have a deploy config file (&lt;code&gt;render.yaml&lt;/code&gt;, &lt;code&gt;fly.toml&lt;/code&gt;, &lt;code&gt;Belmo.yml&lt;/code&gt;, whatever), put it in your repo BEFORE the migration and merge it. Don't be discovering it works at 11pm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Database migration (the scary part)
&lt;/h3&gt;

&lt;p&gt;Backup → restore. The commands are basically the same on every platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From Heroku&lt;/span&gt;
heroku pg:backups:capture &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp
heroku pg:backups:download &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp &lt;span class="nt"&gt;--output&lt;/span&gt; /tmp/dump.tar

&lt;span class="c"&gt;# To new platform (psql connection details from the new DB)&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; NEW_HOST &lt;span class="nt"&gt;-p&lt;/span&gt; NEW_PORT &lt;span class="nt"&gt;-U&lt;/span&gt; NEW_USER &lt;span class="nt"&gt;-d&lt;/span&gt; NEW_DB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DROP SCHEMA public CASCADE; CREATE SCHEMA public;"&lt;/span&gt;

pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; NEW_HOST &lt;span class="nt"&gt;-p&lt;/span&gt; NEW_PORT &lt;span class="nt"&gt;-U&lt;/span&gt; NEW_USER &lt;span class="nt"&gt;-d&lt;/span&gt; NEW_DB /tmp/dump.tar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Then verify with row counts&lt;/strong&gt;, every single table:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'users'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare to Heroku. If anything mismatches, stop, investigate, don't proceed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Put Heroku in maintenance mode + flip DNS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku maintenance:on &lt;span class="nt"&gt;--app&lt;/span&gt; yourapp
&lt;span class="c"&gt;# users see a "we'll be back" page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update your DNS provider (Namecheap, OVH, whatever) to point at the new host. Set TTL low (300 seconds) before this if you remember — DNS propagation will go faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Final dump + restore
&lt;/h3&gt;

&lt;p&gt;Yes, dump the database again. Heroku has been writing data for the last few hours while you set up. Do a fresh capture, restore on the new side. Then row-count verify again.&lt;/p&gt;

&lt;p&gt;Yes, this means downtime equal to the dump-restore time. For most apps under 5GB, this is 5–15 minutes. Schedule the migration window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Reroute external webhooks, then drop maintenance mode
&lt;/h3&gt;

&lt;p&gt;Anything that calls &lt;em&gt;into&lt;/em&gt; your app from outside needs its target URL updated to the new host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe webhooks&lt;/li&gt;
&lt;li&gt;SendGrid event webhooks&lt;/li&gt;
&lt;li&gt;OAuth callback URLs (Google, GitHub, Slack)&lt;/li&gt;
&lt;li&gt;Custom integrations&lt;/li&gt;
&lt;li&gt;Cron jobs hitting your &lt;code&gt;/api/...&lt;/code&gt; endpoints from external services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;code&gt;heroku maintenance:off&lt;/code&gt;. Then watch your logs for 10 minutes straight. If the logs are quiet and your &lt;code&gt;/healthz&lt;/code&gt; is green, you're done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2 — The 7 things that bit me
&lt;/h2&gt;

&lt;p&gt;This is the part nobody warns you about. Every one of these has cost me at least 45 minutes of debugging at least once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 1: The &lt;code&gt;heroku_ext&lt;/code&gt; schema doesn't exist anywhere else
&lt;/h3&gt;

&lt;p&gt;Heroku installs Postgres extensions in a special &lt;code&gt;heroku_ext&lt;/code&gt; schema (not &lt;code&gt;public&lt;/code&gt;). When you &lt;code&gt;pg_restore&lt;/code&gt; to a non-Heroku Postgres, the restore tries to install extensions in &lt;code&gt;heroku_ext&lt;/code&gt; and fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;pg_restore: error: could not execute query: ERROR:  schema "heroku_ext" does not exist
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restore often &lt;em&gt;appears&lt;/em&gt; to complete despite the error, but the extensions (uuid-ossp, unaccent, pg_trgm, etc.) aren't installed. Then your app boots and gets a &lt;code&gt;function uuid_generate_v4() does not exist&lt;/code&gt; 500 error in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: after restore, recreate the extensions manually:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"uuid-ossp"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"unaccent"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"pg_trgm"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List of likely-needed extensions: &lt;code&gt;heroku pg:psql --app yourapp -c "SELECT extname FROM pg_extension;"&lt;/code&gt; before you migrate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 2: &lt;code&gt;pg_stat_statements&lt;/code&gt; is enabled by default on Heroku, often not elsewhere
&lt;/h3&gt;

&lt;p&gt;If you have any internal slow-query monitoring (e.g. PgHero, a custom admin dashboard), it queries &lt;code&gt;pg_stat_statements&lt;/code&gt;. On the new platform this extension is often not enabled by default. Your slow-query dashboard silently shows zero queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: enable it on the new platform. On Render/Railway/Belmo it's a one-line config flag. On a VPS with your own Postgres, edit &lt;code&gt;postgresql.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt; = &lt;span class="s1"&gt;'pg_stat_statements'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;CREATE EXTENSION pg_stat_statements;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 3: Heroku's &lt;code&gt;DATABASE_URL&lt;/code&gt; bakes in &lt;code&gt;sslmode=require&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Heroku's connection string format always includes &lt;code&gt;?sslmode=require&lt;/code&gt;. Most apps rely on this being implicit. When you move, some platforms don't add it by default, and your Rails / Django / Node app starts logging cryptic SSL errors at higher load (when the connection pool churns):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PG::ConnectionBad: SSL connection has been closed unexpectedly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: explicitly add &lt;code&gt;?sslmode=require&lt;/code&gt; to your new &lt;code&gt;DATABASE_URL&lt;/code&gt;. Or set &lt;code&gt;PGSSLMODE=require&lt;/code&gt; as an env var. Diff the connection strings character-by-character before flipping traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 4: Scheduled jobs run on different time zones
&lt;/h3&gt;

&lt;p&gt;Heroku Scheduler runs jobs in &lt;strong&gt;UTC&lt;/strong&gt;. Some platforms default to UTC, some default to the platform's region timezone, some default to whatever timezone the cron entry doesn't specify.&lt;/p&gt;

&lt;p&gt;If you have a &lt;code&gt;0 9 * * *&lt;/code&gt; job that ran at 9am UTC on Heroku, and you migrate to a platform that interprets it as 9am local-time-of-some-California-datacenter, your "daily report at 9am Paris time" now runs at 6pm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: always explicitly check the new platform's cron timezone before migrating. Most modern platforms (Render, Railway, Belmo) are UTC. AWS EventBridge defaults to UTC. Older or self-hosted setups vary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 5: Heroku's &lt;code&gt;DYNO&lt;/code&gt; env var doesn't exist anywhere else
&lt;/h3&gt;

&lt;p&gt;Heroku sets &lt;code&gt;DYNO=web.1&lt;/code&gt; or &lt;code&gt;DYNO=worker.1&lt;/code&gt; automatically. Some apps use this to determine "am I a web process or a worker?" so they can skip certain initialization. After migration, &lt;code&gt;DYNO&lt;/code&gt; is unset, and your worker process tries to bind to a port (because the "am I web?" check defaults to true).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: grep your codebase for &lt;code&gt;process.env.DYNO&lt;/code&gt;, &lt;code&gt;ENV["DYNO"]&lt;/code&gt;, &lt;code&gt;os.environ.get('DYNO')&lt;/code&gt;. Replace with explicit env vars you set per process (&lt;code&gt;PROCESS_TYPE=web&lt;/code&gt; vs &lt;code&gt;worker&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 6: Heroku Postgres "follower" replicas don't survive
&lt;/h3&gt;

&lt;p&gt;If you used Heroku Postgres followers (read replicas) for analytics queries, those don't migrate. The new platform may or may not have a managed replica option, and the connection string format is always different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: if you don't use the replica heavily, just point all queries at primary for the first week post-migration. Then add a replica on the new platform if needed. Don't try to bring the replica online during the migration itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bite 7: Buildpacks vs Docker — your build will behave subtly differently
&lt;/h3&gt;

&lt;p&gt;Heroku auto-detects your stack via buildpacks (e.g. heroku/python, heroku/ruby). Some new platforms also use buildpacks (Render uses Nixpacks, Railway uses Nixpacks). Some require a &lt;code&gt;Dockerfile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Even when both use buildpacks, the specific buildpack version differs. I've seen the same &lt;code&gt;requirements.txt&lt;/code&gt; install Python 3.11.7 on Heroku and Python 3.12.1 on Render — and then a library breaks because Python 3.12 deprecated something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: pin your runtime version explicitly. For Python, that's a &lt;code&gt;.python-version&lt;/code&gt; or &lt;code&gt;runtime.txt&lt;/code&gt;. For Ruby, that's &lt;code&gt;.ruby-version&lt;/code&gt;. For Node, that's &lt;code&gt;"engines": { "node": "20.11.x" }&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The full migration timeline I quote clients
&lt;/h2&gt;

&lt;p&gt;For a "small to medium" Rails or Django app (1 web service, 1 worker, 1 Postgres &amp;lt; 5GB, &amp;lt; 20 env vars):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Step 0 (decide destination): 1–3 days (mostly waiting for client approval)&lt;/li&gt;
&lt;li&gt;Step 1–4 (prep): half a day of focused work&lt;/li&gt;
&lt;li&gt;Step 5 (DB migration dry-run): 1 hour&lt;/li&gt;
&lt;li&gt;Step 6–8 (cutover window): 30–60 minutes with the team on standby&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total: roughly &lt;strong&gt;2–3 hours of execution time&lt;/strong&gt; in a 1-week calendar window. The week is for sanity, not because the work takes that long.&lt;/p&gt;

&lt;p&gt;If anyone quotes you "we'll migrate Heroku in 30 minutes," they haven't done it. There's always something you didn't expect — most often one of the 7 bites above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do if I were migrating today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the inventory&lt;/strong&gt; (&lt;code&gt;heroku ps&lt;/code&gt;, &lt;code&gt;addons&lt;/code&gt;, &lt;code&gt;config&lt;/code&gt;, &lt;code&gt;scheduler:jobs&lt;/code&gt;) and put it in a markdown file &lt;em&gt;today&lt;/em&gt;. You'll need it whether you migrate this month or in 6 months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a destination&lt;/strong&gt; that fits your team's operational maturity. If you don't have a DevOps person, pick a managed PaaS. If you have someone who already runs Kubernetes, you have more options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin your runtime versions&lt;/strong&gt; in your repo &lt;em&gt;before&lt;/em&gt; the migration. This is the cheapest insurance policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do one dry run&lt;/strong&gt; of the database migration to a throwaway DB on the new platform. Verify row counts. Then do the real one later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule the cutover&lt;/strong&gt; for a Tuesday or Wednesday morning, not a Friday. If something breaks, you want a full work week to fix it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A note on the platform I run
&lt;/h2&gt;

&lt;p&gt;If you're picking a destination and you want one that ships with the operational stuff (Telegram alerts, log-pattern detection, EU data center, predictable pricing), Belmo is built around exactly that. Pro tier is €35/mo for 10 services with workers and on-demand scripts included (the same primitives Heroku had with &lt;code&gt;worker:&lt;/code&gt; dynos and &lt;code&gt;Heroku Scheduler&lt;/code&gt;). Free tier never sleeps.&lt;/p&gt;

&lt;p&gt;If you pick another destination, the playbook above still applies — just translate "step 4: deploy code" to whatever that platform's deploy flow is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Heroku is in maintenance mode, not gone. Your app will keep working there. But every month you delay migrating, you're betting that nothing breaks on a platform that's stopped investing in fixing things. That's not crazy in 2026. It just gets less crazy with time, not more.&lt;/p&gt;

&lt;p&gt;Whenever you do migrate, the order of operations matters more than which destination you pick. Pick the order. Execute Tuesday morning. Watch the logs for 10 minutes. Then take Wednesday off — you've earned it.&lt;/p&gt;

&lt;p&gt;What's the dumbest thing that broke during your migration? I'm collecting these for v2 of this post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/heroku-just-went-into-sustaining-engineering-mode-here-are-5-alternatives-whose-free-tier-58id"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;4. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Telegram alerts for any production app — a 5-minute setup.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;5. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;How I built a Discord 'ship-tracker' bot in a weekend.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>heroku</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Fri, 08 May 2026 17:54:27 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/how-i-built-a-discord-ship-tracker-bot-in-a-weekend-and-the-3-process-architecture-that-keeps-it-a71</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/how-i-built-a-discord-ship-tracker-bot-in-a-weekend-and-the-3-process-architecture-that-keeps-it-a71</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo. This bot runs on Belmo's Pro tier — but the architecture (web service + worker + scheduled job) works on any platform that supports those three primitives. I'll point out where each piece runs.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I co-run a small Discord community for indie founders building dev tools. About 220 members, mostly early-stage SaaS people, lots of Claude Code / Cursor enthusiasts. Every Monday I used to manually scroll through the previous week's &lt;code&gt;#i-shipped&lt;/code&gt; channel and write a digest message: "this week we shipped X, Y, Z."&lt;/p&gt;

&lt;p&gt;It took 30 minutes every Monday morning. After 5 weeks of it I did the math — 30 min × 52 weeks = 26 hours a year of me doing what a bot could do better. So one Saturday I built &lt;strong&gt;ShipTrack&lt;/strong&gt;, the bot that's been keeping my Mondays free for 6 months now.&lt;/p&gt;

&lt;p&gt;This is the build log. It's mostly about an architecture decision (3 separate processes instead of 1) that turned out to be the difference between "bot keeps crashing" and "bot just works."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bot does
&lt;/h2&gt;

&lt;p&gt;Three things, in order of complexity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listens for the &lt;code&gt;/ship&lt;/code&gt; slash command.&lt;/strong&gt; When a member runs &lt;code&gt;/ship "Launched my AI todo app — feedback welcome: link.com"&lt;/code&gt;, the bot logs the launch into a database and reacts with 🚀 in the channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracks &lt;code&gt;#i-shipped&lt;/code&gt; channel messages.&lt;/strong&gt; When anyone posts in that channel (without slash command), the bot detects launch-shaped content (heuristic: contains a URL + at least one of "shipped", "launched", "live"), logs it, reacts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Posts a weekly digest&lt;/strong&gt; every Monday at 9am UTC. The bot pulls all launches from the last 7 days, formats them into a nice list, and posts it to &lt;code&gt;#announcements&lt;/code&gt; with @-mentions of the founders.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Three things. But they map to three completely different &lt;em&gt;kinds&lt;/em&gt; of computation, which is where v1 went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: the naive setup that crashed in 15 minutes
&lt;/h2&gt;

&lt;p&gt;I started simple. One Node.js file. &lt;code&gt;node bot.js&lt;/code&gt;. Deploy to a Render free web service. Done in 30 minutes.&lt;/p&gt;

&lt;p&gt;It worked on my laptop. It worked for the first 14 minutes after deploy. Then Render's free tier put the service to sleep due to no incoming HTTP traffic — and a Discord bot &lt;strong&gt;doesn't get HTTP traffic&lt;/strong&gt; by default. It maintains a long-lived WebSocket connection to Discord's gateway. Render couldn't see that traffic. To Render, my bot was idle. So Render killed it.&lt;/p&gt;

&lt;p&gt;When the bot came back from sleep 30 seconds later, it tried to reconnect to Discord's gateway. Discord saw two sessions for the same bot. The old session got disconnected with a &lt;code&gt;4008 Reconnect&lt;/code&gt; and the new one inherited some weird state. Members started seeing the bot react to messages twice. Slash commands timed out.&lt;/p&gt;

&lt;p&gt;This is the kind of bug that takes a long time to diagnose if you've never seen it before, because &lt;strong&gt;everything looks fine in your logs&lt;/strong&gt;. There's no error, just slightly wrong behavior. I wasted 4 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Discord bots are weirder than they look
&lt;/h2&gt;

&lt;p&gt;The thing nobody tells you when you start: a Discord bot has &lt;em&gt;two&lt;/em&gt; completely different communication channels with Discord's servers, and they have totally different operational requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Channel 1: the gateway (WebSocket, persistent).&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bot opens a WebSocket to &lt;code&gt;wss://gateway.discord.gg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stays open forever&lt;/li&gt;
&lt;li&gt;Receives every event in real time (member joined, message posted, reaction added)&lt;/li&gt;
&lt;li&gt;Sends heartbeats every 41.25 seconds&lt;/li&gt;
&lt;li&gt;If the connection drops for &amp;gt;60 seconds, you have to fully re-authenticate and resync state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Channel 2: slash commands (HTTP, on-demand).&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discord POSTs to YOUR endpoint when a user runs a slash command&lt;/li&gt;
&lt;li&gt;You have &lt;strong&gt;3 seconds&lt;/strong&gt; to respond or Discord shows "interaction failed" to the user&lt;/li&gt;
&lt;li&gt;Public HTTP endpoint with signed payload verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These two channels don't fit on the same kind of host. The gateway needs &lt;strong&gt;always-on&lt;/strong&gt;. The slash command webhook needs &lt;strong&gt;public HTTPS that wakes up fast&lt;/strong&gt;. Most "deploy your Node app" flows assume one or the other, not both.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: three processes, three responsibilities
&lt;/h2&gt;

&lt;p&gt;The architecture I landed on has three pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────┐    ┌──────────────────────┐    ┌──────────────────────┐
│  WEB SERVICE          │   │  WORKER               │   │  SCHEDULED SCRIPT     │
│  HTTPS endpoint       │   │  Always-on process    │   │  Runs Monday 9am UTC  │
│  Slash command webhook│   │  Discord gateway      │   │  Generates weekly     │
│  /api/discord/interact│   │  WebSocket connection │   │  digest               │
└──────────────────────┘    └──────────────────────┘    └──────────────────────┘
              │                         │                          │
              └─────────────────────────┴──────────────────────────┘
                                        │
                                ┌──────────────┐
                                │  Postgres    │
                                │  (launches)  │
                                └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate deployments, one shared database. Each process does what it's good at and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process 1: the web service (slash commands)
&lt;/h3&gt;

&lt;p&gt;This is a tiny Express app. One endpoint. Returns under 1 second.&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;// web-service/server.js&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="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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discord-interactions&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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="na"&gt;verify&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="nx"&gt;buf&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;rawBody&lt;/span&gt; &lt;span class="o"&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="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="s1"&gt;/api/discord/interact&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="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="c1"&gt;// 1. Verify Discord signed the request&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&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="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;X-Signature-Ed25519&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;timestamp&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="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;X-Signature-Timestamp&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;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyKey&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;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&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;DISCORD_PUBLIC_KEY&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;valid&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Discord sometimes pings to check liveness&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;body&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="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="nx"&gt;res&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;type&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="c1"&gt;// 3. Slash command — log the launch and respond fast&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;body&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;data&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="s1"&gt;ship&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;userId&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;username&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&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;text&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&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;value&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;channel_id&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="nx"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;created_at&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;Date&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="nx"&gt;res&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`🚀 Logged your ship, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;username&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="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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown command&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="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="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;PORT&lt;/span&gt; &lt;span class="o"&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;Deploy this as a normal &lt;strong&gt;web service&lt;/strong&gt;. It can sleep on free tiers — Discord sends a request only when someone runs &lt;code&gt;/ship&lt;/code&gt;, and 1 second of cold start before responding is fine. (For Belmo, I picked the Hobby tier with the always-on free guarantee anyway, but the architecture works either way.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Process 2: the worker (gateway + reactions)
&lt;/h3&gt;

&lt;p&gt;This is the long-running part. It opens the WebSocket connection to Discord and listens for messages. It can't sleep. Ever.&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;// worker/bot.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Events&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discord.js&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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;client&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;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Guilds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GuildMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MessageContent&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHIPPED_CHANNEL_ID&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;SHIPPED_CHANNEL_ID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;client&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="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MessageCreate&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;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bot&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;SHIPPED_CHANNEL_ID&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="c1"&gt;// Heuristic: contains a URL + a "shipped"-ish word&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/\S&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;hasShipWord&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;shipped|launched|live|released&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;hasUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasShipWord&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&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="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;created_at&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;Date&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;react&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="nx"&gt;client&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="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ClientReady&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="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;`ShipTrack online as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&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;DISCORD_BOT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy this as a &lt;strong&gt;background worker&lt;/strong&gt;. On Belmo this is the Pro tier &lt;code&gt;worker&lt;/code&gt; process type — same &lt;code&gt;Procfile&lt;/code&gt;-style declaration as Heroku's old &lt;code&gt;worker:&lt;/code&gt; line:&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;# Belmo.yml (or similar config)&lt;/span&gt;
&lt;span class="na"&gt;processes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node worker/bot.js&lt;/span&gt;
    &lt;span class="na"&gt;always_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform keeps it running. If it crashes, it restarts. If you push new code, it gracefully reconnects. &lt;strong&gt;No HTTP traffic required to keep it alive&lt;/strong&gt; — that's the whole point of a worker process type vs a web service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process 3: the scheduled script (weekly digest)
&lt;/h3&gt;

&lt;p&gt;This one runs once a week. It's an "on-demand" script — runs, finishes, exits. Costs almost nothing.&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;// scripts/weekly-digest.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discord.js&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&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;client&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;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Guilds&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&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;DISCORD_BOT_TOKEN&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;since&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;Date&lt;/span&gt;&lt;span class="p"&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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;launches&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;since&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;launches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;launches&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`• &amp;lt;@&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="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt; shipped: &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="nx"&gt;text&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;200&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="nf"&gt;join&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;.&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;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;ANNOUNCEMENTS_CHANNEL_ID&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;channel&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="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`**📦 This week we shipped (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;launches&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="s2"&gt; launches):**\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;formatted&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="na"&gt;allowedMentions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;users&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;// notify in formatting only, don't ping&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Belmo, this runs as an &lt;strong&gt;on-demand script&lt;/strong&gt; triggered by a schedule:&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;processes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weekly-digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;script&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/weekly-digest.js&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&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;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;  &lt;span class="c1"&gt;# every Monday at 9:00 UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform spins up an ephemeral container at the scheduled time, runs the script, captures the output, exits. You pay for ~3 seconds of compute per week. If you've ever fought with Heroku Scheduler, you'll appreciate that the script lives in your repo, version-controlled, with the same env vars as the rest of your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this architecture matters
&lt;/h2&gt;

&lt;p&gt;The naive temptation is to put all three in one Node process: HTTP server + Discord client + a &lt;code&gt;setInterval&lt;/code&gt; for the digest. &lt;strong&gt;Don't.&lt;/strong&gt; Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Crashes blast radius.&lt;/strong&gt; If your slash-command handler throws, the gateway connection survives. If your gateway disconnects mid-deploy, the slash commands keep working. Each process is independently restartable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling differs.&lt;/strong&gt; If you have 5,000 slash commands an hour, you scale the web service. The worker stays at 1 instance (you only need one Discord gateway connection per bot). Different processes, different scaling profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs differ.&lt;/strong&gt; The worker burns CPU cycles 24/7 just maintaining a heartbeat. The script runs 3 seconds a week. Putting them on the same dyno is paying always-on prices for a once-a-week task.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This three-process pattern is what you want for &lt;strong&gt;any bot or background-heavy service&lt;/strong&gt;: not just Discord. Slack apps. Telegram bots. Webhook receivers with async fanout. The shape repeats.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket gateway = needs a worker, not a web service.&lt;/strong&gt; Free web tiers will sleep your bot. Workers don't sleep on platforms that respect the worker primitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slash commands ≠ gateway.&lt;/strong&gt; They're HTTP, you can host them anywhere, but the 3-second response cap is real. Don't do heavy work inline — log to DB, respond, finish processing async.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a real database, not in-memory state.&lt;/strong&gt; I tried "just use a JSON file" for v0. Workers restart, files vanish, members lost their launch history once. Two days later I wired Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled scripts &amp;gt; setInterval.&lt;/strong&gt; A &lt;code&gt;setInterval&lt;/code&gt; in your worker tied to wall-clock time will drift, miss runs during deploys, and double-fire if you scale to 2 instances. A scheduled script run as a separate process is exactly-once, exactly-on-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always reply within 3 seconds.&lt;/strong&gt; If your slash command handler does anything slow (database query &amp;gt; 1s, external API), respond with a deferred response (&lt;code&gt;type: 5&lt;/code&gt;) and follow up later via Discord's webhook.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When this architecture is overkill
&lt;/h2&gt;

&lt;p&gt;If you're building a bot for 5 friends to play a Discord trivia game, run it in one Node process on your laptop. You don't need three processes. You don't need a database. You probably don't need slash commands.&lt;/p&gt;

&lt;p&gt;The three-process pattern starts paying off when &lt;strong&gt;at least one of these is true&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bot is mission-critical (community would notice if it's down).&lt;/li&gt;
&lt;li&gt;The bot has &amp;gt; ~50 users sending it traffic.&lt;/li&gt;
&lt;li&gt;You need scheduled jobs that must run even if the bot crashed yesterday.&lt;/li&gt;
&lt;li&gt;You're deploying it on a platform where free web tiers sleep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For ShipTrack at 220 members + weekly digest, all four were true. So the three-process setup paid for itself the first time the worker crashed at 2am and the slash commands kept working through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on Belmo
&lt;/h2&gt;

&lt;p&gt;The reason I'm building on Belmo (besides the obvious "I run it" disclosure at the top) is that the three primitives I needed — &lt;strong&gt;web service&lt;/strong&gt;, &lt;strong&gt;worker&lt;/strong&gt;, &lt;strong&gt;on-demand scheduled script&lt;/strong&gt; — are first-class citizens on the platform. Same repo, same env vars, three lines of YAML config. No fighting with Heroku Scheduler vs Heroku dynos vs Heroku one-off &lt;code&gt;heroku run&lt;/code&gt; jobs. No spinning up separate ECS task definitions on AWS.&lt;/p&gt;

&lt;p&gt;If you're building anything with these three shapes — and most bots, webhook receivers, and background-heavy services have them — Pro tier (€35/mo) gets you 10 services with workers and on-demand scripts included. The free Starter tier supports the web service piece if you want to wire your worker elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;ShipTrack v3 is on my todo list. I want to add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An LLM-powered launch summary at the bottom of each digest ("this week's theme: AI productivity tools")&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;/profile @user&lt;/code&gt; command that shows someone's all-time launches&lt;/li&gt;
&lt;li&gt;A leaderboard of "most active shipper this quarter"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built a Discord bot recently and have hard-won lessons, I'd love to hear them in the comments. Especially the embarrassing v1 stories.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/heroku-just-went-into-sustaining-engineering-mode-here-are-5-alternatives-whose-free-tier-58id"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Your AI app is silently burning $2,000/month and you don't know it.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;4. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Telegram alerts for any production app — a 5-minute setup.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discord</category>
      <category>javascript</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Telegram alerts for any production app — a 5-minute setup (no SaaS, no signup, just curl)</title>
      <dc:creator>Chalom Ellezam</dc:creator>
      <pubDate>Mon, 04 May 2026 12:36:33 +0000</pubDate>
      <link>https://dev.to/chalom_ellezam_5989bce65e/telegram-alerts-for-any-production-app-a-5-minute-setup-no-saas-no-signup-just-curl-3pgf</link>
      <guid>https://dev.to/chalom_ellezam_5989bce65e/telegram-alerts-for-any-production-app-a-5-minute-setup-no-saas-no-signup-just-curl-3pgf</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm a senior backend tech lead and I run Belmo, where Telegram alerts ship as a built-in feature. This tutorial works on any platform — it's the manual version of what Belmo does for you. Useful even if you never become a customer.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;There's a hierarchy of where production alerts go, ranked by how likely you are to actually see them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Email → 14% open rate within an hour, less at 3am.&lt;/li&gt;
&lt;li&gt;Slack → muted in 6 of 10 teams I've seen, especially "alerts" channels.&lt;/li&gt;
&lt;li&gt;Phone-call paging (PagerDuty, Opsgenie) → works, but $20+/user/month and overkill for solo founders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram&lt;/strong&gt; → notification on lock screen, no setup cost, works on every phone, you'll see it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a solo founder or a small team, Telegram alerts hit a sweet spot: the notification is &lt;strong&gt;annoying enough that you'll see it&lt;/strong&gt;, &lt;strong&gt;easy enough that you'll set it up&lt;/strong&gt;, and &lt;strong&gt;free&lt;/strong&gt;. After 8 years of trying every paging tool, this is what I default to for early-stage projects.&lt;/p&gt;

&lt;p&gt;Here's how to wire it up in 5 minutes, plus what I learned about &lt;em&gt;what&lt;/em&gt; to alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create a Telegram bot (60 seconds)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open Telegram, search for &lt;code&gt;@BotFather&lt;/code&gt;, start a chat.&lt;/li&gt;
&lt;li&gt;Send &lt;code&gt;/newbot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pick a name. Then a username (must end in &lt;code&gt;bot&lt;/code&gt;, e.g. &lt;code&gt;myapp_alerts_bot&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;BotFather replies with a token like &lt;code&gt;7234567890:AAFq...&lt;/code&gt;. &lt;strong&gt;Save it.&lt;/strong&gt; This is your &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The bot exists. Now we need somewhere to send messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Get your chat ID (60 seconds)
&lt;/h2&gt;

&lt;p&gt;You need the ID of the chat where alerts will land. Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — Personal alerts (just for you):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open a chat with your new bot. Send it any message ("hello").&lt;/li&gt;
&lt;li&gt;Visit &lt;code&gt;https://api.telegram.org/bot&amp;lt;YOUR_TOKEN&amp;gt;/getUpdates&lt;/code&gt; in your browser.&lt;/li&gt;
&lt;li&gt;Find the &lt;code&gt;chat.id&lt;/code&gt; field in the JSON response. It's a number like &lt;code&gt;123456789&lt;/code&gt;. &lt;strong&gt;That's your chat ID.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Option B — Group alerts (for the team):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a Telegram group (or use an existing one).&lt;/li&gt;
&lt;li&gt;Add your bot to the group.&lt;/li&gt;
&lt;li&gt;Send any message in the group.&lt;/li&gt;
&lt;li&gt;Visit the same &lt;code&gt;getUpdates&lt;/code&gt; URL. The chat ID for groups is negative (e.g. &lt;code&gt;-987654321&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save the chat ID as &lt;code&gt;TELEGRAM_CHAT_ID&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Send your first alert (30 seconds)
&lt;/h2&gt;

&lt;p&gt;The send API is one HTTP call. From a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"chat_id=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"text=Hello from production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your phone should buzz immediately. If it does, the wiring is done. Now we just need to call this from your app when something interesting happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Wire it into your app (3 minutes)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Node.js / TypeScript
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// alerts.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TG_TOKEN&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;TELEGRAM_BOT_TOKEN&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;TG_CHAT&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;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&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;alert&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="kr"&gt;string&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;TG_TOKEN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TG_CHAT&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="c1"&gt;// disabled in dev&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.telegram.org/bot&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TG_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage`&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="s1"&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="s1"&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="s1"&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;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TG_CHAT&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="nx"&gt;message&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;4000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Telegram cap&lt;/span&gt;
        &lt;span class="na"&gt;parse_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Markdown&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="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="c1"&gt;// never let alerting crash your app&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="s1"&gt;alert failed&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="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;Use it anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./alerts&lt;/span&gt;&lt;span class="dl"&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="s1"&gt;uncaughtException&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🚨 *Uncaught exception*\n&lt;/span&gt;&lt;span class="se"&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="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;\n\nstack:\n&lt;/span&gt;&lt;span class="se"&gt;\`\`\`&lt;/span&gt;&lt;span class="s2"&gt;\n&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;stack&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="se"&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="c1"&gt;// or in business logic&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;failed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;THRESHOLD&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`⚠️ Stripe webhook retry rate at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/min — investigate`&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;h3&gt;
  
  
  Python
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# alerts.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;TG_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;TG_CHAT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;TG_TOKEN&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;TG_CHAT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://api.telegram.org/bot&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TG_TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sendMessage&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chat_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TG_CHAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;parse_mode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Markdown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# never let alerting crash your app
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alert failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it to the unhandled exception hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;excepthook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_traceback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;🚨 *Uncaught exception*&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc_value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__excepthook__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_traceback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;excepthook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excepthook&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plain bash (great for cron jobs)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Save as /usr/local/bin/tg-alert&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"chat_id=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"text=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then any cron job can do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /opt/myapp/nightly-backup.sh &lt;span class="o"&gt;||&lt;/span&gt; tg-alert &lt;span class="s2"&gt;"❌ Nightly backup failed at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole infrastructure. You're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The rate-limiting trick (the part most tutorials skip)
&lt;/h2&gt;

&lt;p&gt;Here's the failure mode of every "I just wired alerts" project: a bug fires at 200/sec, your phone buzzes 200 times in 10 seconds, you mute the bot in frustration, you miss the next &lt;em&gt;real&lt;/em&gt; alert two days later.&lt;/p&gt;

&lt;p&gt;You need a &lt;strong&gt;rate limiter&lt;/strong&gt; between your code and Telegram. The simplest one: deduplicate identical messages within a window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js — in-memory dedupe
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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;DEDUPE_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&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;// 5 minutes&lt;/span&gt;

&lt;span class="k"&gt;export&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;alert&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="kr"&gt;string&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&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;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// use first 200 chars as dedupe key&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;seen&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;key&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;last&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;DEDUPE_WINDOW_MS&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="c1"&gt;// skip&lt;/span&gt;
  &lt;span class="nx"&gt;seen&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;key&lt;/span&gt;&lt;span class="p"&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="c1"&gt;// ... rest of the send logic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;em&gt;the same alert&lt;/em&gt; won't fire more than once every 5 minutes, but &lt;em&gt;different alerts&lt;/em&gt; still go through. A retry loop firing the same exception 800 times in 10 minutes will produce &lt;strong&gt;3 Telegram messages&lt;/strong&gt;, not 800. You'll still know it's happening.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis-backed (for multi-instance apps)
&lt;/h3&gt;

&lt;p&gt;If your app runs on multiple servers, in-memory dedupe doesn't work. Use Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&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;redis&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;Redis&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;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&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;alert&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="kr"&gt;string&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`tg-dedupe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hash&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="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;200&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="kd"&gt;set&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;redis&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&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="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NX&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="kd"&gt;set&lt;/span&gt; &lt;span class="o"&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already alerted in last 5 min&lt;/span&gt;

  &lt;span class="c1"&gt;// ... send to Telegram&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;EX 300 NX&lt;/code&gt; does the magic: set the key with a 5-minute TTL, but only if it doesn't already exist. If two servers try to send the same alert simultaneously, only one wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to alert on (the harder question)
&lt;/h2&gt;

&lt;p&gt;The tutorial above is the easy part. The hard part is &lt;em&gt;what&lt;/em&gt; to alert on. Bad alerts → alert fatigue → muted bot → you miss the real ones.&lt;/p&gt;

&lt;p&gt;Five things that are usually worth a Telegram ping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Uncaught exceptions in the main process&lt;/strong&gt; — these usually mean a process is about to die or has died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job queue depth above N&lt;/strong&gt; — if Sidekiq/BullMQ/Celery has more than e.g. 1000 jobs queued, something is producing faster than consuming. Investigate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5xx error rate above 1%&lt;/strong&gt; — not every 500 needs a ping, but the &lt;em&gt;rate&lt;/em&gt; exceeding a threshold does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific business events that mean money is leaking&lt;/strong&gt; — failed payment retries, stale webhook signatures, expired refresh tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs that didn't run&lt;/strong&gt; — if your nightly backup didn't fire by 3:30am, you want to know at 3:30am, not at 9am when you check email.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five things that are usually &lt;strong&gt;NOT&lt;/strong&gt; worth a ping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Individual 4xx errors (these are mostly user error or scrapers).&lt;/li&gt;
&lt;li&gt;Slow queries (log them, don't page on them).&lt;/li&gt;
&lt;li&gt;Anything you can't act on in the next 30 minutes.&lt;/li&gt;
&lt;li&gt;Anything where the action is "do nothing, it'll resolve itself" (most CDN hiccups, most rate-limit blips).&lt;/li&gt;
&lt;li&gt;Anything informational ("user X signed up" — use a different channel for celebration).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The test I use: &lt;strong&gt;if I'm at dinner and my phone buzzes, will I be glad I knew, or annoyed?&lt;/strong&gt; If the answer is "annoyed," it doesn't belong on Telegram.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on alert &lt;em&gt;resolution&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A subtle thing most tutorials skip: alerts should also tell you when something is &lt;em&gt;fixed&lt;/em&gt;. If your job queue depth crossed 1000 and you got pinged, you also want a "now back to 0" ping when it normalizes. Otherwise you spend 20 minutes manually checking the dashboard.&lt;/p&gt;

&lt;p&gt;The simplest pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inAlertState&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="k"&gt;export&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;checkQueueDepth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inAlertState&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🔴 Queue depth: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;depth&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;inAlertState&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="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;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;inAlertState&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🟢 Queue back to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;depth&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;inAlertState&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="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;Two messages per incident, not 200. Your bot stays usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stop building this and use a platform
&lt;/h2&gt;

&lt;p&gt;Everything above takes about an evening to wire up properly. For a small team, that's a great trade.&lt;/p&gt;

&lt;p&gt;There are three signs it's time to stop building this and use a platform that does it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You're spending more than 1 day a quarter maintaining your alerting setup.&lt;/li&gt;
&lt;li&gt;You realize you need &lt;em&gt;pattern&lt;/em&gt; detection (retry loops, token spikes, anomalous response times) — those are much harder to write yourself than threshold alerts.&lt;/li&gt;
&lt;li&gt;You've muted your own bot more than once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the gap &lt;strong&gt;Belmo&lt;/strong&gt; fills. The platform tails your production logs, runs pattern detection automatically (retry loops, token spikes, hot fingerprints, anomalous latency, silent cron failures), and pings you on Telegram with a link to the relevant logs. No code in your app, no Redis dedupe to maintain — it's part of the hosting layer. €19/mo Hobby, €35/mo Pro.&lt;/p&gt;

&lt;p&gt;If you're already deployed somewhere else, the homemade Telegram setup above is a fine start — and probably better than no alerts at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do after you finish reading this
&lt;/h2&gt;

&lt;p&gt;Concretely, in the next 30 minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the bot via BotFather.&lt;/li&gt;
&lt;li&gt;Get your chat ID with &lt;code&gt;getUpdates&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt; and &lt;code&gt;TELEGRAM_CHAT_ID&lt;/code&gt; to your env vars on whatever platform you use.&lt;/li&gt;
&lt;li&gt;Drop the &lt;code&gt;alert(message)&lt;/code&gt; helper into your codebase.&lt;/li&gt;
&lt;li&gt;Wire it to your top-level uncaught exception handler.&lt;/li&gt;
&lt;li&gt;Add the dedupe logic so a runaway loop doesn't spam you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the minimum viable production alerting setup for any app. It costs $0, works in any country, lives on your phone, and survives every channel rotation in your team.&lt;/p&gt;

&lt;p&gt;If you wire something interesting on top of this — anomaly detection, business event alerts, anything cool — drop it in the comments. I'm always looking for new patterns to steal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous posts in this series:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/heroku-just-went-into-sustaining-engineering-mode-here-are-5-alternatives-whose-free-tier-58id"&gt;Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. &lt;a href="https://dev.to/chalom_ellezam_5989bce65e/"&gt;Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
