<?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: DevKit</title>
    <description>The latest articles on DEV Community by DevKit (@devkit_shop).</description>
    <link>https://dev.to/devkit_shop</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%2F4200465%2Ff571e3e5-a6bf-4853-92d5-e974eea7f015.png</url>
      <title>DEV Community: DevKit</title>
      <link>https://dev.to/devkit_shop</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devkit_shop"/>
    <language>en</language>
    <item>
      <title>Alertmanager to Telegram without a bridge: severity routing, inhibit rules and runbook links that cut alert noise</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:21:16 +0000</pubDate>
      <link>https://dev.to/devkit_shop/alertmanager-to-telegram-without-a-bridge-severity-routing-inhibit-rules-and-runbook-links-that-amn</link>
      <guid>https://dev.to/devkit_shop/alertmanager-to-telegram-without-a-bridge-severity-routing-inhibit-rules-and-runbook-links-that-amn</guid>
      <description>&lt;p&gt;&lt;em&gt;Written with AI assistance; the alert pack itself was built and tested by the author's AI assistant.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If Prometheus pings your phone fifty times when one server reboots, you will soon mute the chat, and then the one real alert gets missed too. Most of that noise can be removed in Alertmanager itself, with no extra containers.&lt;/p&gt;

&lt;p&gt;This article walks through a small config: native Telegram delivery, routing by severity, inhibit rules, and a runbook link in every message. Everything here works with plain Alertmanager and nothing has to be bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  No bridge needed
&lt;/h2&gt;

&lt;p&gt;Older guides run a webhook bridge that converts Alertmanager JSON into Telegram messages. Recent Alertmanager versions have a built-in &lt;code&gt;telegram_configs&lt;/code&gt; receiver, so that is one less container to keep alive. Check your version's documentation to confirm it supports &lt;code&gt;telegram_configs&lt;/code&gt; and &lt;code&gt;bot_token_file&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bot token: a file, not inline
&lt;/h2&gt;

&lt;p&gt;Create a bot with &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt; and keep the token out of &lt;code&gt;alertmanager.yml&lt;/code&gt;, which tends to end up in git, backups and screenshots. Put it in a file and use &lt;code&gt;bot_token_file&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="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0750 /etc/alertmanager/secrets
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;TOKEN_FROM_BOTFATHER&amp;gt;'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/alertmanager/secrets/telegram_bot_token &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;alertmanager: /etc/alertmanager/secrets/telegram_bot_token
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0600 /etc/alertmanager/secrets/telegram_bot_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the user name to whatever account runs Alertmanager. I use &lt;code&gt;printf&lt;/code&gt; so the file has no trailing newline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the chat_id
&lt;/h2&gt;

&lt;p&gt;There is no menu that shows it. The generic method:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the bot to your group (or open a private chat with it).&lt;/li&gt;
&lt;li&gt;Send any message in that chat. In a group the bot may not see ordinary messages because of privacy mode, so send a &lt;code&gt;/command&lt;/code&gt; or mention the bot.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;https://api.telegram.org/bot&amp;lt;TOKEN&amp;gt;/getUpdates&lt;/code&gt; in a browser or with curl and look for &lt;code&gt;"chat":{"id":...}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Private chats have positive ids. Groups are negative. Supergroups, which is what most groups become, start with &lt;code&gt;-100&lt;/code&gt;. This article uses the placeholder &lt;code&gt;-1001234567890&lt;/code&gt;; use the number you actually see, including the minus sign. If &lt;code&gt;getUpdates&lt;/code&gt; returns an empty list, send another message and retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing by severity
&lt;/h2&gt;

&lt;p&gt;Not every alert deserves the same urgency. The routing tree sets different timings for &lt;code&gt;critical&lt;/code&gt; and &lt;code&gt;warning&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegram-warning&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;alertname&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;group_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12h&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity="critical"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegram-critical&lt;/span&gt;
      &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity="warning"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegram-warning&lt;/span&gt;
      &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12h&lt;/span&gt;
&lt;span class="na"&gt;receivers&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;telegram-critical&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;telegram-warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Receivers are stubs so the snippet stands alone; real ones are below.)&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;group_by&lt;/strong&gt; &lt;code&gt;[alertname, instance, namespace]&lt;/code&gt;: alerts with the same name, host and namespace are merged into one message. Without grouping, ten failing pods become ten messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;group_wait&lt;/strong&gt;: how long Alertmanager waits after the first alert of a new group before sending, so related alerts can join it. 30s for warnings gives a burst time to gather. Critical uses 10s because a few seconds of batching is worth less than a fast page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;group_interval&lt;/strong&gt;: how long to wait before sending notifications about new alerts added to a group that was already notified. 5m keeps a flapping group from producing a message every minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;repeat_interval&lt;/strong&gt;: how often an unresolved alert is re-sent. Critical repeats hourly, so it can't be forgotten. A warning repeats every 12 hours, which is a reminder and not a nag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat these numbers as a starting point and tune them to how quickly you can actually respond.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receiver: HTML, runbook link, escaping
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&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;telegram-critical&lt;/span&gt;
    &lt;span class="na"&gt;telegram_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;bot_token_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/alertmanager/secrets/telegram_bot_token&lt;/span&gt;
        &lt;span class="na"&gt;chat_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-1001234567890&lt;/span&gt;
        &lt;span class="na"&gt;parse_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTML&lt;/span&gt;
        &lt;span class="na"&gt;send_resolved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
          &lt;span class="s"&gt;🔥 &amp;lt;b&amp;gt;[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}&amp;lt;/b&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;{{ range .Alerts -}}&lt;/span&gt;
          &lt;span class="s"&gt;{{ .Annotations.summary }}&lt;/span&gt;
          &lt;span class="s"&gt;{{ .Annotations.description }}&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;a href="{{ .Annotations.runbook_url }}"&amp;gt;Runbook&amp;lt;/a&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;{{ end }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warning receiver is identical apart from the ⚠️ emoji; it could point at a different chat.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;parse_mode: HTML&lt;/code&gt; gives bold text and clickable links. The pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Telegram accepts only a small subset of HTML tags. Anything else, or an unbalanced tag, makes Telegram reject the message with a "can't parse entities" error, and you get no alert at all.&lt;/li&gt;
&lt;li&gt;A literal &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;amp;&lt;/code&gt; in text must be written as &lt;code&gt;&amp;amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;amp;&lt;/code&gt;. A description like &lt;code&gt;latency &amp;gt; 500ms&lt;/code&gt; or a label with &lt;code&gt;&amp;amp;&lt;/code&gt; in a URL is enough to break the message. As far as I know Alertmanager's notification templates are plain text templates and do not auto-escape, so this is your job.&lt;/li&gt;
&lt;li&gt;Escape the free-text fields you don't control. Template functions such as &lt;code&gt;reReplaceAll&lt;/code&gt; can do it, and the order matters because &lt;code&gt;&amp;amp;&lt;/code&gt; must be replaced first:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
  &lt;span class="s"&gt;{{ .Annotations.description | reReplaceAll "&amp;amp;" "&amp;amp;amp;" | reReplaceAll "&amp;lt;" "&amp;amp;lt;" | reReplaceAll "&amp;gt;" "&amp;amp;gt;" }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;If an alert has no &lt;code&gt;runbook_url&lt;/code&gt;, &lt;code&gt;&amp;lt;a href=""&amp;gt;&lt;/code&gt; is an empty link and may be rejected. Wrap it in &lt;code&gt;{{ if .Annotations.runbook_url }}…{{ end }}&lt;/code&gt; if some of your rules lack the annotation.&lt;/li&gt;
&lt;li&gt;Telegram limits a message to 4096 characters, so keep annotations short and groups small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always verify with a real test alert (below) after changing a template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runbook links in the alert itself
&lt;/h2&gt;

&lt;p&gt;The link has to come from the rule, so that the person reading the message at 3 a.m. gets a single tap to "what do I do". In the rules the annotation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DockerContainerRestartLoop&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes(container_start_time_seconds{name!=""}[15m]) &amp;gt;= &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Container&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;$labels.name&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;keeps&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restarting&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;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}})"&lt;/span&gt;
    &lt;span class="na"&gt;runbook_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://devkit-shop.surge.sh/runbook/en/#dockercontainerrestartloop"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The convention is simple: the anchor is the alert name in lower case, and the runbook has a section with that name. Each section follows the same order: what it means, common causes, diagnostics, how to fix, and when it is OK to silence. For example, the section for &lt;code&gt;NodeDown&lt;/code&gt; begins by saying that it is not necessarily a server outage, because only the exporter or the network path may be down, and then lists &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; against port 9100 and &lt;code&gt;systemctl status node_exporter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can write the same for your own alerts; even three lines per alert beat none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inhibit rules: where the noise actually goes
&lt;/h2&gt;

&lt;p&gt;Inhibition mutes a &lt;em&gt;target&lt;/em&gt; alert while a &lt;em&gt;source&lt;/em&gt; alert is firing, as long as the labels listed in &lt;code&gt;equal&lt;/code&gt; match. Here are the first two rules:&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;inhibit_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Same alert on the same host as critical and warning: keep critical.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity="critical"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity="warning"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;alertname&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# Host is unreachable: other node-exporter alerts from it are meaningless.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="NodeDown"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname=~"Node.+"'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname!="NodeDown"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Critical beats warning.&lt;/strong&gt; If a rule exists at two thresholds under the same &lt;code&gt;alertname&lt;/code&gt;, you see only the critical one. This only matters if you use the same alert name for both levels; otherwise it does nothing, and the specific rules below cover the pairs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead host silences its own symptoms.&lt;/strong&gt; When &lt;code&gt;NodeDown&lt;/code&gt; fires, disk, CPU and memory alerts for that &lt;code&gt;instance&lt;/code&gt; would be stale or misleading. Note that the target pattern is &lt;code&gt;Node.+&lt;/code&gt;, so alerts with other prefixes (for example &lt;code&gt;Docker…&lt;/code&gt;) are not covered by this rule. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The remaining three follow the same shape:&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;inhibit_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Whole site is down: skip slow-response and bad-status alerts.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="BlackboxProbeFailed"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname=~"BlackboxSlowResponse|BlackboxHttpStatusNotOk"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# PVC critically full: the "almost full" warning is redundant.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="KubePvcCritical"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="KubePvcAlmostFull"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;persistentvolumeclaim&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# API certificate expires within a day: drop the one-week warning.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="KubeApiCertExpiresCritical"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;target_matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname="KubeApiCertExpiresSoon"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;equal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each time, a more severe or more fundamental alert makes a weaker one about the same thing redundant. These alert names come from the pack's rules; replace them with yours, and make sure the &lt;code&gt;equal&lt;/code&gt; labels exist on both alerts, otherwise the rule never matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing before you trust it
&lt;/h2&gt;

&lt;p&gt;Validate syntax first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;amtool check-config alertmanager.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send a fake alert straight to the Alertmanager API (default port 9093):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:9093/api/v2/alerts &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'[{"labels":{"alertname":"TestAlert","severity":"critical","instance":"test:9100"},
        "annotations":{"summary":"Test","description":"a &amp;lt; b &amp;amp; c","runbook_url":"https://example.com/"}}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;amp;&lt;/code&gt; in the description are deliberate: they show whether your escaping works. Expect the message after &lt;code&gt;group_wait&lt;/code&gt; (10s for critical). Repeat with &lt;code&gt;"severity":"warning"&lt;/code&gt; to check the other route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silences: with a reason and an expiry
&lt;/h2&gt;

&lt;p&gt;For planned maintenance, silence instead of ignoring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;amtool silence add &lt;span class="nv"&gt;alertname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NodeDown &lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web1:9100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--comment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kernel update, planned reboot"&lt;/span&gt; &lt;span class="nt"&gt;--duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A silence without a comment tells the next person nothing, and one without an expiry is just switched-off monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;telegram_configs&lt;/code&gt; and &lt;code&gt;bot_token_file&lt;/code&gt; need a reasonably recent Alertmanager. Check your version's documentation instead of trusting this article.&lt;/li&gt;
&lt;li&gt;Telegram can be unreachable from your network, down, or rate-limiting you. If your alerts are bursty, rate limiting shows up as delayed or dropped messages. Grouping reduces volume but doesn't remove the risk.&lt;/li&gt;
&lt;li&gt;Don't make Telegram the only channel for critical alerts. Add a second receiver for them, such as email or a pager service, and ideally something that doesn't share a failure with the rest of your setup. If the server running Alertmanager is the one that died, nothing gets sent at all, so an external heartbeat check is worth having.&lt;/li&gt;
&lt;li&gt;Inhibition hides alerts. A badly chosen &lt;code&gt;equal&lt;/code&gt; can hide something real, so re-read every rule once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to get the rest
&lt;/h2&gt;

&lt;p&gt;The full pack has rules for Linux, Docker, Kubernetes and websites/TLS, this Alertmanager config, a runbook in English and Russian, and promtool tests. It is sold for 9 USDT via &lt;a href="https://t.me/devkit_shop_bot?start=devto_am" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. The runbook itself is free at &lt;a href="https://devkit-shop.surge.sh/runbook/en/" rel="noopener noreferrer"&gt;https://devkit-shop.surge.sh/runbook/en/&lt;/a&gt; , and there is a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;monitoring kit for a single VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>telegram</category>
    </item>
    <item>
      <title>A Telegram bot for restocks and price drops: JSON-LD first, CSS selectors as a fallback</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:28:54 +0000</pubDate>
      <link>https://dev.to/devkit_shop/a-telegram-bot-for-restocks-and-price-drops-json-ld-first-css-selectors-as-a-fallback-2o9n</link>
      <guid>https://dev.to/devkit_shop/a-telegram-bot-for-restocks-and-price-drops-json-ld-first-css-selectors-as-a-fallback-2o9n</guid>
      <description>&lt;p&gt;&lt;em&gt;Written with AI assistance; the template itself was built and tested by the author's AI assistant.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You want a ping when a sold-out item comes back, or when a price falls by a meaningful amount. You don't need a scraping farm for that. Most shops already publish the data you need in a machine-readable form, and a small asyncio process with SQLite can watch a few dozen product pages.&lt;/p&gt;

&lt;p&gt;This article walks through the design of a bot built as a reusable template (Python, aiogram 3, httpx, selectolax, SQLite). Every idea below can be reimplemented in an afternoon without buying anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: read structured data before touching CSS
&lt;/h2&gt;

&lt;p&gt;Most shops emit schema.org &lt;code&gt;Product&lt;/code&gt; markup in a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; tag, because search engines want it. That's far more stable than a CSS class that changes with each redesign. So the first adapter just looks for it.&lt;/p&gt;

&lt;p&gt;JSON-LD shows up in a few shapes: a single object, a list, or an object with &lt;code&gt;@graph&lt;/code&gt;. A small generator flattens them:&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;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;iter_json_ld_blocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html_parser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;html_parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;script[type=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/ld+json&lt;/span&gt;&lt;span class="sh"&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;raw&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;continue&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;@graph&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@graph&lt;/span&gt;&lt;span class="sh"&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="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broken JSON in one block is skipped rather than fatal, since real pages often carry several blocks from different plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Offers, availability and variants
&lt;/h3&gt;

&lt;p&gt;Availability is a URL such as &lt;code&gt;https://schema.org/InStock&lt;/code&gt;. Take the last path segment, normalise it, and map it to three states: in stock, out of stock, unknown.&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="n"&gt;AVAILABILITY_MAP&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;instock&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;in_stock&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;limitedavailability&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;in_stock&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;preorder&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;in_stock&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;backorder&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;out_of_stock&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;outofstock&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;out_of_stock&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;soldout&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;out_of_stock&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;discontinued&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;out_of_stock&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;AVAILABILITY_MAP&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&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;(The real map has a few more entries such as &lt;code&gt;OnlineOnly&lt;/code&gt;, &lt;code&gt;InStoreOnly&lt;/code&gt; and &lt;code&gt;PreSale&lt;/code&gt;. Whether a pre-order counts as "in stock" is a judgment call, so change it if you disagree.)&lt;/p&gt;

&lt;p&gt;The "unknown" state matters. If a page says nothing clear, you don't want to alert on it.&lt;/p&gt;

&lt;p&gt;Some &lt;code&gt;ProductGroup&lt;/code&gt; pages, commonly Shopify themes, carry no top-level offer. Price and availability sit on each variant under &lt;code&gt;hasVariant&lt;/code&gt;. The adapter handles this in the simplest possible way:&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="n"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first_offer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&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;offers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&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;offer&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&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;hasVariant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasVariant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;first_variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasVariant&lt;/span&gt;&lt;span class="sh"&gt;"&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;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first_offer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_variant&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;offers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offer&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;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;offer&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;lowPrice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offer&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;priceCurrency&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;Be aware of the limitation: it reads the &lt;strong&gt;first&lt;/strong&gt; variant only. If you care about one specific size or colour, point the bot at that variant's own URL, or extend this to match on SKU.&lt;/p&gt;

&lt;p&gt;If no Product JSON-LD exists, the adapter also tries OpenGraph tags (&lt;code&gt;product:price:amount&lt;/code&gt;, &lt;code&gt;product:availability&lt;/code&gt;) before giving up with a parse error.&lt;/p&gt;

&lt;p&gt;Prices need cleaning too, since &lt;code&gt;"1 234,56"&lt;/code&gt; and &lt;code&gt;"1,234.56"&lt;/code&gt; both occur. The JSON-LD adapter strips non-numeric characters and treats a lone comma as a decimal separator. It is a heuristic, so test it on your own sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: CSS selectors for sites without markup
&lt;/h2&gt;

&lt;p&gt;Some storefronts, usually hand-built ones, have no structured data. For those, you declare selectors once per site in a YAML file, matched by a glob on the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sites&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;example-generic-shop&lt;/span&gt;
    &lt;span class="na"&gt;url_pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://shop.example.com/product/*"&lt;/span&gt;
    &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;generic_css&lt;/span&gt;
    &lt;span class="na"&gt;interval_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1800&lt;/span&gt;
    &lt;span class="na"&gt;jitter_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
    &lt;span class="na"&gt;selectors&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h1.product-title"&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span.price"&lt;/span&gt;
      &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;div.stock-status"&lt;/span&gt;
    &lt;span class="na"&gt;in_stock_text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;stock"&lt;/span&gt;
    &lt;span class="na"&gt;out_of_stock_text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sold&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;out"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter extracts the text of each selector. Availability is a case-insensitive substring check, with your configured phrases tested first and a few common English and Russian phrases as defaults:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_stock_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_of_stock_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;is&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;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;out_of_stock_text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;out_of_stock_text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_of_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;in_stock_text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;in_stock_text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in stock&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;available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out of stock&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;sold out&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;unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_of_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the order: "out of stock" is checked before the generic "in stock" patterns would match, which avoids the classic bug where &lt;code&gt;"not available"&lt;/code&gt; reads as available. Keep your own &lt;code&gt;out_of_stock_text&lt;/code&gt; specific for the same reason. If none of your selectors match, the adapter raises a parse error that tells you to check the YAML, so a redesigned page fails loudly instead of silently reporting nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: change detection that alerts once
&lt;/h2&gt;

&lt;p&gt;The trick for "alert once" is boring: persist the last known state per product, compare the new reading to it, then overwrite it. The comparison itself is a pure function with no I/O, which makes it easy to unit test.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_availability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold_percent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;new_available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_availability&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;old_available&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_availability&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&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;old_available&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;back_in_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;old_available&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;new_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_of_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_price&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real function returns &lt;code&gt;ChangeEvent&lt;/code&gt; dataclasses carrying the URL, name and old/new price, but the logic is the same.) Details worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;old_available is None&lt;/code&gt; means this is the first check. There is no baseline, so no event. You won't get a flood when you add a product.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;unknown&lt;/code&gt; reading never triggers anything.&lt;/li&gt;
&lt;li&gt;The price threshold is a percentage (default &lt;code&gt;5.0&lt;/code&gt;, set with &lt;code&gt;PRICE_CHANGE_THRESHOLD_PERCENT&lt;/code&gt;), and it fires on rises as well as drops. The message text says which direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After each check, the new state is written back in SQLite, plus a row in a &lt;code&gt;price_history&lt;/code&gt; table:&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE products SET name = COALESCE(?, name), last_price = ?, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_currency = ?, last_available = ?, last_checked_at = ? WHERE id = ?&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&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="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO price_history (product_id, price, available, checked_at) VALUES (?, ?, ?, ?)&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;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;now&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="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the baseline moves forward on every check, a back-in-stock alert fires once per transition. A caveat I'd rather you hear from me: a slow drift, say 2% per day, never crosses a 5% threshold between two consecutive checks, so it never alerts. If that matters, compare against the price from your last &lt;em&gt;alert&lt;/em&gt;, not the last check.&lt;/p&gt;

&lt;p&gt;Another quirk: if one check returns &lt;code&gt;unknown&lt;/code&gt;, the stored availability becomes null, so the next definite reading is treated as a new baseline and does not alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Telegram alerts
&lt;/h2&gt;

&lt;p&gt;Subscriptions live in SQLite (&lt;code&gt;users&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt;), so one product can have several subscribers and gets fetched once. Sending is a loop around aiogram's &lt;code&gt;send_message&lt;/code&gt;, and one blocked user must not stop the rest:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscriber_tg_ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_message&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tg_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;subscriber_tg_ids&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="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TelegramAPIError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed to notify %s: %s&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_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users add products with &lt;code&gt;/add &amp;lt;url&amp;gt;&lt;/code&gt;, which also runs a first check. &lt;code&gt;/list&lt;/code&gt;, &lt;code&gt;/remove &amp;lt;id&amp;gt;&lt;/code&gt; and &lt;code&gt;/check&lt;/code&gt; cover the rest. Bot replies and alerts come from a small strings table with English and Russian versions, picked by a &lt;code&gt;LANGUAGE=en|ru&lt;/code&gt; setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: scheduling and polite fetching
&lt;/h2&gt;

&lt;p&gt;A single asyncio loop wakes every 30 seconds and asks the database which products are due: never checked, or older than their interval plus a random jitter. The defaults are 1800 s interval and up to 300 s jitter. Jitter keeps products from becoming due in lock-step.&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="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="n"&gt;jitter_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;jitter_seconds&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;due&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fetcher adds the following, all configurable through environment variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;robots.txt&lt;/code&gt; check per host, cached, enabled by default (&lt;code&gt;RESPECT_ROBOTS_TXT&lt;/code&gt;); a disallowed URL raises an error instead of being fetched. If robots.txt is missing or can't be fetched, the fetcher assumes allowed;&lt;/li&gt;
&lt;li&gt;a per-host limit, default 6 requests per minute;&lt;/li&gt;
&lt;li&gt;up to 3 attempts with linear backoff (2 s, then 4 s) on HTTP errors;&lt;/li&gt;
&lt;li&gt;a 15 s timeout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A failed fetch or parse is logged and skipped; the loop carries on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: JS-rendered prices (optional)
&lt;/h2&gt;

&lt;p&gt;If the price is injected by JavaScript, the HTML contains nothing to parse. The template includes a small Playwright fetcher that loads the page in Chromium and returns the rendered HTML:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;20_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;"&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Playwright is imported lazily, so it isn't required unless you use it. It is switched on with &lt;code&gt;USE_PLAYWRIGHT=true&lt;/code&gt; or per site with &lt;code&gt;playwright: true&lt;/code&gt; in &lt;code&gt;sites.yaml&lt;/code&gt;. Browser fetches wait on the same per-host rate limiter as plain HTTP requests, and if Playwright is enabled but not installed the bot logs one error and falls back to httpx. Retries and the robots.txt check apply only to the plain HTTP path. Also check whether the site offers a JSON endpoint behind that JavaScript first; it's lighter and kinder to the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: run it
&lt;/h2&gt;

&lt;p&gt;With Docker Compose, the SQLite file lives in a named volume so subscriptions survive restarts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stockbot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stockbot:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;stockbot-data:/app/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stockbot-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without Docker, a systemd unit does the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;stockbot&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/stockbot&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/stockbot/.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/stockbot/.venv/bin/python -m stockbot&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&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;NoNewPrivileges&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;PrivateTmp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bot needs a token from BotFather in &lt;code&gt;BOT_TOKEN&lt;/code&gt; and refuses to start without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being a good citizen
&lt;/h2&gt;

&lt;p&gt;Monitoring a page is still automated traffic to someone else's server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read each site's terms of service and &lt;code&gt;robots.txt&lt;/code&gt; before adding it. The template honours robots.txt by default, but a permissive robots file is not the same as permission in the terms.&lt;/li&gt;
&lt;li&gt;Keep intervals modest. Half an hour is the default here; for most restocks, hourly or slower is plenty. Don't lower it just because you can.&lt;/li&gt;
&lt;li&gt;Watch a handful of products, not a whole catalogue.&lt;/li&gt;
&lt;li&gt;If a store has an official API, a product feed or an email "notify me" button, use that instead of scraping.&lt;/li&gt;
&lt;li&gt;If a site asks you to stop or starts returning errors, back off.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a scalping or checkout bot.&lt;/strong&gt; It reads public product pages and sends you a message. It doesn't log in, fill carts or buy anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not an anti-bot toolkit.&lt;/strong&gt; It does not solve CAPTCHAs, spoof fingerprints or defeat bot protection. For transparency about what it does contain: it rotates the User-Agent from a configurable list, and the fetcher accepts an optional proxy list, which is empty by default. If a site blocks you, treat that as a "no" and don't work around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One process, one SQLite file.&lt;/strong&gt; There is no queue, no worker pool and no horizontal scaling. That's fine for personal or small-shop use, and not designed for thousands of URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heuristic parsing.&lt;/strong&gt; Price formats, variant handling and availability wording vary by site, so test each one before trusting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Update: v1.2 and v1.3 (slow drift, silent parser failures, variant switches)
&lt;/h2&gt;

&lt;p&gt;A reader, &lt;a class="mentioned-user" href="https://dev.to/sid_mishra"&gt;@sid_mishra&lt;/a&gt;, pointed out two gaps in the comments, and both are now fixed in the template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow drift.&lt;/strong&gt; Each product now stores two prices: the last one seen and the one from the last alert. A new reading is compared with the last alerted price, so five small drops of 1% add up to one 5% alert instead of none. After an alert, the baseline moves to the new price.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unknown readings.&lt;/strong&gt; An unknown reading no longer clears the last known price or stock state. The bot counts consecutive unknowns per product (including pages the parser can't read at all), and after &lt;code&gt;UNKNOWN_ALERT_AFTER&lt;/code&gt; in a row (default 3, 0 turns it off) it sends one "the page layout may have changed" alert. That's the silent failure a price monitor otherwise never reports.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Variant switches (v1.3).&lt;/strong&gt; Another reader, &lt;a class="mentioned-user" href="https://dev.to/double_chen_70da460344c73"&gt;@double_chen_70da460344c73&lt;/a&gt;, noted that the first-variant fallback can make a size change look like a price drop. The adapter now returns a variant id (&lt;code&gt;sku&lt;/code&gt;, then &lt;code&gt;productID&lt;/code&gt;/&lt;code&gt;gtin&lt;/code&gt;, then the offer URL) and the bot stores it with the price. If the variant changes, there is no price alert; the baseline resets and the bot sends one "variant changed" note instead. Price alerts show the SKU when it is known.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the template
&lt;/h2&gt;

&lt;p&gt;The ideas above are enough to build your own. If you'd rather start from working code, the full template (source, 76 tests, Dockerfile and Compose file, systemd unit, README in English and Russian) is sold for 9 USDT through &lt;a href="https://t.me/devkit_shop_bot?start=devto_stock" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. There is also a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;monitoring kit for a single VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>webscraping</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Telegram lead-intake bot with aiogram 3 and SQLite: a form, an admin inbox and no lost requests</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:19:18 +0000</pubDate>
      <link>https://dev.to/devkit_shop/building-a-telegram-lead-intake-bot-with-aiogram-3-and-sqlite-a-form-an-admin-inbox-and-no-lost-ijl</link>
      <guid>https://dev.to/devkit_shop/building-a-telegram-lead-intake-bot-with-aiogram-3-and-sqlite-a-form-an-admin-inbox-and-no-lost-ijl</guid>
      <description>&lt;p&gt;&lt;em&gt;Written with AI assistance; the template itself was built and tested by the author's AI assistant.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Many small businesses take requests in Telegram DMs: "how much is a haircut?", "call me back", a phone number buried in a voice message. Some of those get lost between chats. This article shows a small structure that fixes it: a bot that asks a fixed set of questions, validates the answers, writes them to SQLite &lt;em&gt;before&lt;/em&gt; doing anything else, and pushes a card with status buttons to the admins.&lt;/p&gt;

&lt;p&gt;Everything below is adapted from a small aiogram 3 project. You can rebuild the ideas yourself from the snippets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A client presses &lt;code&gt;/start&lt;/code&gt; and walks through a form: service, name, phone, comment, confirmation.&lt;/li&gt;
&lt;li&gt;The lead is saved to SQLite and gets a number.&lt;/li&gt;
&lt;li&gt;Every admin receives a card with buttons: &lt;em&gt;In work / Done / Rejected&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;When an admin changes the status, the client gets a message about it.&lt;/li&gt;
&lt;li&gt;A per-user limit stops someone from flooding the inbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. The form as an FSM
&lt;/h2&gt;

&lt;p&gt;aiogram's finite state machine is a good fit: one state per question.&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;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.fsm.state&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StatesGroup&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatesGroup&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;comment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;confirm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handlers are filtered by state, so a message only reaches the handler that expects it. Moving forward is a lookup table, and an &lt;code&gt;editing&lt;/code&gt; flag lets the user fix one field from the confirmation screen and jump straight back:&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="n"&gt;NEXT_STEP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comment&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;State&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="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FSMContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Config&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;After a field is entered: next step, or back to the confirmation screen.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&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;editing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LeadForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comment&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;show_confirm&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NEXT_STEP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two catch-all handlers matter more than they look: one for users with no state (&lt;code&gt;StateFilter(None)&lt;/code&gt; → "Press /start") and one for any message inside the form that no step handler accepted, such as a sticker instead of a name. Without them the bot just goes silent, and silence reads as "broken".&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(&lt;code&gt;advance&lt;/code&gt; is from &lt;code&gt;app/handlers/client.py&lt;/code&gt;; &lt;code&gt;LeadForm&lt;/code&gt; from &lt;code&gt;app/states.py&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Validate before you store
&lt;/h2&gt;

&lt;p&gt;A lead with a bad phone number is a lost lead. The bot accepts both Telegram's "share contact" button and typed text, strips spaces, dashes, dots and brackets, and normalizes to &lt;code&gt;+digits&lt;/code&gt;. The English variant of the check:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_normalize_international&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cleaned&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="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Optional &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; followed by 8-15 digits, returned as +XXXXXXXX.

    Local numbers with a leading trunk &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; are rejected (the country code is unknown);
    the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; international prefix is treated as &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejecting a local &lt;code&gt;07700…&lt;/code&gt; is a deliberate trade-off: without a country code the number is not callable from elsewhere, so the bot asks again with an example. The Russian variant additionally turns &lt;code&gt;8XXXXXXXXXX&lt;/code&gt; and &lt;code&gt;9XXXXXXXXX&lt;/code&gt; into &lt;code&gt;+7…&lt;/code&gt;. Names are trimmed, whitespace-collapsed and limited to 2–60 characters; comments to 500. User text is passed through &lt;code&gt;html.escape&lt;/code&gt; before it is shown in the HTML-formatted confirmation. &lt;em&gt;(From &lt;code&gt;app/validators.py&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Save first, notify second
&lt;/h2&gt;

&lt;p&gt;Two tables, &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;leads&lt;/code&gt;, with a status column and indexes for the queries the admin screens use:&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="k"&gt;TABLE&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="n"&gt;leads&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTOINCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;username&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;service&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;comment&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&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="n"&gt;idx_leads_user_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;leads&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&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="n"&gt;idx_leads_status&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;leads&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;Timestamps are UTC strings in &lt;code&gt;%Y-%m-%d %H:%M:%S&lt;/code&gt;, which sort lexicographically, so &lt;code&gt;created_at &amp;gt;= ?&lt;/code&gt; just works. The connection uses WAL mode (&lt;code&gt;PRAGMA journal_mode=WAL&lt;/code&gt;) via &lt;code&gt;aiosqlite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The "send" handler is ordered on purpose: clear the FSM state &lt;em&gt;first&lt;/em&gt; (a double tap on the button cannot create a duplicate), check the limit, then insert, answer the client, and only then notify admins. If Telegram fails to deliver to an admin, the lead is already in the database and visible in the admin list. That is the "no lost requests" part. &lt;em&gt;(From &lt;code&gt;app/db.py&lt;/code&gt;, &lt;code&gt;confirm_send&lt;/code&gt; in &lt;code&gt;app/handlers/client.py&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The admin inbox
&lt;/h2&gt;

&lt;p&gt;Admins get the lead card with one button per &lt;em&gt;other&lt;/em&gt; status:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lead_status_kb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&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="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ru&lt;/span&gt;&lt;span class="sh"&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="n"&gt;InlineKeyboardMarkup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Status change buttons; the current status is not shown.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;language&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;btn_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&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;span class="n"&gt;callback_data&lt;/span&gt;&lt;span class="o"&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;lead:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lead_id&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;status&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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STATUS_BUTTONS&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InlineKeyboardMarkup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inline_keyboard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sending goes through a wrapper that survives the things that actually happen: a flood limit (one retry after the &lt;code&gt;retry_after&lt;/code&gt; delay), a user who blocked the bot (marked &lt;code&gt;blocked&lt;/code&gt; in &lt;code&gt;users&lt;/code&gt;), and other API errors (logged, not raised). Admins are notified concurrently:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;notify_admins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Sends the lead card to all admins. Returns the number delivered.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new_lead&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;format_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;markup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lead_status_kb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&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="nf"&gt;safe_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply_markup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;markup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;admin_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;admin_ids&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;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback handler for the buttons validates the status against a whitelist, ignores a repeated press of the same status, updates the row, edits the card in place and tells the client:&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="nd"&gt;@router.callback_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lead:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CallbackQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Config&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lead_id_raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STATUSES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="c1"&gt;# ... "lead not found" and "already has this status" checks omitted ...
&lt;/span&gt;    &lt;span class="n"&gt;lead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;status_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# ... the card is edited in place here ...
&lt;/span&gt;    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CLIENT_STATUS_TEXT&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="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# the "new" status is not sent to the client
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;safe_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;db&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Admin handlers sit on a router with a filter that checks the user ID against &lt;code&gt;ADMIN_IDS&lt;/code&gt;, so everyone else's taps are never routed there. The admin menu (&lt;code&gt;/admin&lt;/code&gt;) also has paginated lists of new and in-work leads, today/7-day counts, a CSV export (cells starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;@&lt;/code&gt; are prefixed to defuse spreadsheet formula injection) and a broadcast to everyone who has not blocked the bot. &lt;em&gt;(From &lt;code&gt;app/keyboards.py&lt;/code&gt;, &lt;code&gt;app/notify.py&lt;/code&gt;, &lt;code&gt;app/handlers/admin.py&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Anti-flood without extra infrastructure
&lt;/h2&gt;

&lt;p&gt;You do not need Redis for this. The leads table already knows how many requests a user created recently:&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="n"&gt;WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;can_create_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;WINDOW&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_recent_leads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check runs on &lt;code&gt;/start&lt;/code&gt; and again on confirm. The limit comes from config, so a salon can set 3 per hour and a busier shop something else. &lt;em&gt;(From &lt;code&gt;app/throttle.py&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Config and language
&lt;/h2&gt;

&lt;p&gt;Texts, services and limits live in YAML, not in code, so the owner changes wording without touching Python. One setting switches the built-in buttons, admin panel, date format and phone rules between &lt;code&gt;ru&lt;/code&gt; and &lt;code&gt;en&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en&lt;/span&gt;
&lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Europe/London&lt;/span&gt;

&lt;span class="na"&gt;working_hours&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mon–Sat,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10:00–20:00"&lt;/span&gt;
  &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10:00"&lt;/span&gt;
  &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;20:00"&lt;/span&gt;
  &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;5&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;       &lt;span class="c1"&gt;# 1 = Monday ... 7 = Sunday&lt;/span&gt;

&lt;span class="na"&gt;antispam&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;max_leads_per_hour&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Haircut"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Colouring"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Other"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outside working hours the client still gets the confirmation, plus a line saying when you will reply. Secrets (&lt;code&gt;BOT_TOKEN&lt;/code&gt;, &lt;code&gt;ADMIN_IDS&lt;/code&gt;) stay in &lt;code&gt;.env&lt;/code&gt;, not in this file. &lt;em&gt;(From &lt;code&gt;config.en.yaml&lt;/code&gt;, trimmed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Running it
&lt;/h2&gt;

&lt;p&gt;The container is plain &lt;code&gt;python:3.12-slim&lt;/code&gt;, runs as a non-root user, and uses long polling, so there is no webhook, domain or TLS to set up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;leads-bot&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data/leads.db&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/app/data&lt;/span&gt;                         &lt;span class="c1"&gt;# the leads database&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config.yaml:/app/config.yaml:ro&lt;/span&gt;        &lt;span class="c1"&gt;# texts and services, editable without a rebuild&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config.en.yaml:/app/config.en.yaml:ro&lt;/span&gt;  &lt;span class="c1"&gt;# English edition (CONFIG_PATH=config.en.yaml)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put &lt;code&gt;BOT_TOKEN&lt;/code&gt;, &lt;code&gt;ADMIN_IDS&lt;/code&gt; and, for English, &lt;code&gt;CONFIG_PATH=config.en.yaml&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, then &lt;code&gt;docker compose up -d --build&lt;/code&gt;. Every admin must press &lt;code&gt;/start&lt;/code&gt; in the bot once, otherwise Telegram will not let the bot message them. &lt;em&gt;(From &lt;code&gt;docker-compose.yml&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a CRM.&lt;/strong&gt; There are four statuses, a list and a CSV export. No pipelines, reminders, assignments or history of changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One SQLite file.&lt;/strong&gt; Simple and enough for a small inbox, but it is your only copy. Back up &lt;code&gt;data/leads.db&lt;/code&gt; (or use the CSV export) on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A polling bot on one process.&lt;/strong&gt; Run exactly one instance per token; a second one will fight over updates. FSM state is not meant to be shared between processes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No payments.&lt;/strong&gt; It collects requests; it does not take money.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The ideas that carry over to any bot: one FSM state per question, normalize input before you store it, write to the database before notifying anyone, keep status changes behind a whitelist, and let the database enforce your rate limit. That is enough to stop losing requests.&lt;/p&gt;

&lt;p&gt;If you would rather not assemble it yourself: the full template (source, tests, Docker setup, config in English and Russian, README in both languages) is sold for 9 USDT via &lt;a href="https://t.me/devkit_shop_bot?start=devto_leads" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. There is also a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;monitoring kit for a single VPS&lt;/a&gt; if you want to know when the bot's server goes down.&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>bot</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Deploying FastAPI + PostgreSQL to a single VPS with Docker Compose: HTTPS, backups and rollbacks without Kubernetes</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:20:49 +0000</pubDate>
      <link>https://dev.to/devkit_shop/deploying-fastapi-postgresql-to-a-single-vps-with-docker-compose-https-backups-and-rollbacks-jb4</link>
      <guid>https://dev.to/devkit_shop/deploying-fastapi-postgresql-to-a-single-vps-with-docker-compose-https-backups-and-rollbacks-jb4</guid>
      <description>&lt;p&gt;&lt;em&gt;Written with AI assistance; the template itself was built and tested by the author's AI assistant.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have one VPS and a FastAPI app with PostgreSQL, you don't need Kubernetes. You do need a few boring things done properly: a sane network layout, HTTPS that renews itself, backups you have actually checked, and a deploy that can undo itself. Below is how I wired each piece with plain Docker Compose. All snippets are shortened from a working template, and you can copy the ideas without buying anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Network layout: only nginx is public
&lt;/h2&gt;

&lt;p&gt;Five services: &lt;code&gt;app&lt;/code&gt; (gunicorn + uvicorn workers), &lt;code&gt;db&lt;/code&gt; (Postgres 16), &lt;code&gt;nginx&lt;/code&gt;, &lt;code&gt;certbot&lt;/code&gt;, and &lt;code&gt;backup&lt;/code&gt;. Only nginx publishes ports. The database sits in an &lt;code&gt;internal&lt;/code&gt; network, so it has no route out and no published port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="c1"&gt;# No ports: on purpose.&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27-alpine&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0:80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0:443:443"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;internal&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;Why so strict? Docker writes its own iptables rules, so a published port like &lt;code&gt;5432:5432&lt;/code&gt; is reachable from the internet even if &lt;code&gt;ufw&lt;/code&gt; says deny. The safest approach is to never publish what you don't want public. The &lt;code&gt;app&lt;/code&gt; container also runs with &lt;code&gt;read_only: true&lt;/code&gt;, &lt;code&gt;cap_drop: [ALL]&lt;/code&gt;, &lt;code&gt;no-new-privileges&lt;/code&gt;, and a non-root user in the Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A healthcheck that actually checks the database
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;/health&lt;/code&gt; that always returns 200 only tells you the process is alive. Here it runs &lt;code&gt;SELECT 1&lt;/code&gt; and returns 503 if the DB is unreachable:&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="nd"&gt;@app.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;/health&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;health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DbSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Response&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="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SQLAlchemyError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;degraded&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;database&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;down&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&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;database&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;up&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;Compose uses it for the container healthcheck, and nginx waits for the app to be healthy before starting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;as&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;u;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;u.urlopen('http://127.0.0.1:8000/health',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;timeout=4)"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;urlopen&lt;/code&gt; raises on HTTP errors, so a 503 makes the container unhealthy. Using Python's stdlib means no &lt;code&gt;curl&lt;/code&gt; in the image. The deploy script (section 6) also hits &lt;code&gt;/health&lt;/code&gt; through nginx, so one request exercises nginx, app and DB.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. HTTPS: the chicken-and-egg problem
&lt;/h2&gt;

&lt;p&gt;nginx refuses to start if the certificate files referenced in its config don't exist. Let's Encrypt's webroot challenge needs a running nginx to answer on port 80. The way out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a throwaway self-signed certificate at the path nginx expects.&lt;/li&gt;
&lt;li&gt;Start nginx.&lt;/li&gt;
&lt;li&gt;Delete the dummy certificate.&lt;/li&gt;
&lt;li&gt;Ask certbot for the real one via webroot.&lt;/li&gt;
&lt;li&gt;Reload nginx.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core of the bootstrap script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;certbot_run&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

certbot_run &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; &lt;span class="s2"&gt;"openssl req -x509 -nodes -newkey rsa:2048 -days 1 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    -keyout /etc/letsencrypt/live/&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;/privkey.pem &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    -out /etc/letsencrypt/live/&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;/fullchain.pem -subj /CN=localhost"&lt;/span&gt; certbot

docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; nginx
&lt;span class="c"&gt;# ... remove the dummy cert, then:&lt;/span&gt;
&lt;span class="nv"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;certonly &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /var/www/certbot &lt;span class="nt"&gt;--email&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nt"&gt;--rsa-key-size&lt;/span&gt; 4096 &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="nt"&gt;--no-eff-email&lt;/span&gt; &lt;span class="nt"&gt;--non-interactive&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$staging&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; args+&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nt"&gt;--staging&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
certbot_run &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; certbot certbot &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; nginx nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;--staging&lt;/code&gt; first. Staging has far looser rate limits than production, so you can debug DNS and firewall issues freely; the browser won't trust the result, which is fine. Then rerun with &lt;code&gt;--force&lt;/code&gt; to replace it with a production certificate.&lt;/p&gt;

&lt;p&gt;Renewal is a second container that loops twice a day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;certbot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;certbot/certbot:latest&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;/bin/sh -c 'trap exit TERM;&lt;/span&gt;
      &lt;span class="s"&gt;while :; do&lt;/span&gt;
      &lt;span class="s"&gt;certbot renew --webroot -w /var/www/certbot --quiet;&lt;/span&gt;
      &lt;span class="s"&gt;sleep 12h &amp;amp; wait $${!};&lt;/span&gt;
      &lt;span class="s"&gt;done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx serves &lt;code&gt;/.well-known/acme-challenge/&lt;/code&gt; from the shared webroot volume. The template also mounts a small script into nginx that runs &lt;code&gt;nginx -s reload&lt;/code&gt; every 6 hours, so renewed certificates get picked up without a manual step.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. nginx: headers and rate limiting
&lt;/h2&gt;

&lt;p&gt;The config is an nginx template; with &lt;code&gt;NGINX_ENVSUBST_FILTER: ^DOMAIN$$&lt;/code&gt; only &lt;code&gt;$DOMAIN&lt;/code&gt; is substituted, so nginx's own &lt;code&gt;$variables&lt;/code&gt; stay intact. The relevant parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_tokens&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=perip:10m&lt;/span&gt; &lt;span class="s"&gt;rate=10r/s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;limit_req_status&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;5m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=31536000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;includeSubDomains"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"DENY"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=perip&lt;/span&gt; &lt;span class="s"&gt;burst=20&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app_backend&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 real file also sets a CSP (&lt;code&gt;default-src 'none'&lt;/code&gt;, which suits a JSON API but not a site serving HTML), a Permissions-Policy, gzip, proxy headers and timeouts. Ten requests per second per IP with a burst of 20 is a starting point, not a tuned value; adjust it to your traffic. Because the app trusts &lt;code&gt;X-Forwarded-*&lt;/code&gt; from anyone (&lt;code&gt;FORWARDED_ALLOW_IPS: "*"&lt;/code&gt;), it's important that the app port is never published.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Backups: dump, verify, then rotate
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;backup&lt;/code&gt; container runs a loop that sleeps until &lt;code&gt;BACKUP_AT&lt;/code&gt; (UTC), then dumps. The important detail is that a file isn't published until it's been verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;do_backup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&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;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGDATABASE&lt;/span&gt;&lt;span class="k"&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;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt;

    pg_dump &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&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;.tmp"&lt;/span&gt;
    &lt;span class="c"&gt;# A dump without a readable table of contents is useless&lt;/span&gt;
    pg_restore &lt;span class="nt"&gt;--list&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;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
    &lt;span class="nb"&gt;mv&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;.tmp"&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;

    find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGDATABASE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_*.dump"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-mmin&lt;/span&gt; &lt;span class="s2"&gt;"+&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;RETENTION_DAYS &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_restore --list&lt;/code&gt; is a cheap sanity check: it proves the archive is readable, but it is &lt;strong&gt;not&lt;/strong&gt; a restore test. Do a real restore into a scratch database now and then. Retention defaults to 7 days. &lt;code&gt;backup.sh once&lt;/code&gt; takes a dump immediately, which the deploy script uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Tag-based deploy with rollback
&lt;/h2&gt;

&lt;p&gt;CI builds an image on a &lt;code&gt;v*.*.*&lt;/code&gt; tag, pushes it to GHCR, then the server runs &lt;code&gt;scripts/deploy.sh &amp;lt;tag&amp;gt;&lt;/code&gt;. The script's order is the useful part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take a backup (abort the deploy if it fails).&lt;/li&gt;
&lt;li&gt;Remember the previous tag (&lt;code&gt;APP_PREV_TAG&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;), set the new &lt;code&gt;APP_TAG&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker compose pull app&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;alembic upgrade head&lt;/code&gt;. If it fails, restore the tag and stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker compose up -d&lt;/code&gt;, then poll &lt;code&gt;/health&lt;/code&gt; through local nginx for up to about a minute.&lt;/li&gt;
&lt;li&gt;If it never goes healthy, print the app logs and roll back.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log &lt;span class="s2"&gt;"checking /health"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;healthy 30&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"success: &lt;/span&gt;&lt;span class="nv"&gt;$TAG&lt;/span&gt;&lt;span class="s2"&gt; is running"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;log &lt;span class="s2"&gt;"/health not responding after deploying &lt;/span&gt;&lt;span class="nv"&gt;$TAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 app &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;rollback &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PREV_TAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the rollback itself is just "put the old tag back":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rollback&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    log &lt;span class="s2"&gt;"rolling back to &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    env_set APP_TAG &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-build&lt;/span&gt; app
    healthy 30 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; log &lt;span class="s2"&gt;"rollback done"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"WARNING: still failing"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Image tags in &lt;code&gt;.env&lt;/code&gt; make this simple: the compose file uses &lt;code&gt;${APP_IMAGE}:${APP_TAG}&lt;/code&gt;, so a rollback is a one-line change plus &lt;code&gt;up -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The catch, which the script documents: &lt;strong&gt;rollback restores the image, not the database schema.&lt;/strong&gt; Migrations must be backward compatible (add a column in one release, remove it in a later one). If data is damaged, you restore from the pre-deploy dump.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not high availability.&lt;/strong&gt; One server is a single point of failure. A reboot, a disk failure or a provider outage means downtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups live on the same server&lt;/strong&gt; (&lt;code&gt;./backups&lt;/code&gt;) unless you copy them off-host yourself. A dead disk takes the database and its dumps together. Add &lt;code&gt;rsync&lt;/code&gt; or &lt;code&gt;rclone&lt;/code&gt; to object storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not zero-downtime.&lt;/strong&gt; The app container is recreated on deploy, so expect a short gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not tested on every cloud provider.&lt;/strong&gt; The bootstrap script targets Ubuntu 24.04 and checks for it.&lt;/li&gt;
&lt;li&gt;No monitoring or alerting is built into the stack beyond container healthchecks and logs (capped at 3 × 10 MB per service).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you want the whole thing
&lt;/h2&gt;

&lt;p&gt;The snippets above are enough to rebuild the approach yourself. If you'd rather start from a finished setup, the full template (compose, nginx, certbot, backup, CI/CD workflows, bootstrap and deploy scripts, tests, Makefile, README in English and Russian) is sold for 11 USDT via &lt;a href="https://t.me/devkit_shop_bot?start=devto_deploy" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. There's also a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;monitoring kit for a single VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>python</category>
      <category>nginx</category>
    </item>
    <item>
      <title>Hardening a fresh Ubuntu 24.04 server with Ansible without locking yourself out</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:22:33 +0000</pubDate>
      <link>https://dev.to/devkit_shop/hardening-a-fresh-ubuntu-2404-server-with-ansible-without-locking-yourself-out-dgf</link>
      <guid>https://dev.to/devkit_shop/hardening-a-fresh-ubuntu-2404-server-with-ansible-without-locking-yourself-out-dgf</guid>
      <description>&lt;p&gt;&lt;em&gt;Written with AI assistance; the role itself was built and lint-checked by the author's AI assistant, which works for a DevOps engineer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hardening a server is easy. Hardening it and still being able to log in afterwards is the part people get wrong. Most lockouts I have seen described come from a few causes: SSH password login turned off before a key was installed, a firewall enabled before the SSH rule existed, or an &lt;code&gt;sshd&lt;/code&gt; config with a typo that stops the daemon from restarting.&lt;/p&gt;

&lt;p&gt;This article walks through an Ansible approach for a fresh Ubuntu 24.04 VPS that avoids those three. Everything below is free to copy. The snippets are short adaptations of an Ansible role I wrote, so you can apply the ideas in your own playbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order is the safety mechanism
&lt;/h2&gt;

&lt;p&gt;The role's &lt;code&gt;main.yml&lt;/code&gt; runs the parts in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users → ufw → ssh → fail2ban → updates → sysctl → auditd → services → banner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reasoning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Users first.&lt;/strong&gt; Create the admin account and install its public key. Until that exists, nothing else should touch SSH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ufw second.&lt;/strong&gt; Allow the SSH port, &lt;em&gt;then&lt;/em&gt; enable the firewall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH third.&lt;/strong&gt; Only now apply the strict config: no passwords, no root login. The key and the firewall hole already exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else.&lt;/strong&gt; fail2ban, updates, sysctl and auditd cannot lock you out in the usual way, so they come last.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Ansible terms it is just the order of your &lt;code&gt;import_tasks&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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;Users&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.import_tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users.yml&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;users&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;Ufw firewall&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.import_tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ufw.yml&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ufw&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;SSH configuration&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.import_tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh.yml&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ssh&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tags let you re-run one part (&lt;code&gt;--tags ssh&lt;/code&gt;) later without replaying the whole thing.&lt;/p&gt;

&lt;p&gt;One habit matters more than any task: &lt;strong&gt;keep your current SSH session open&lt;/strong&gt;, and test a login from a second terminal before you close the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse to run if there is no key
&lt;/h2&gt;

&lt;p&gt;The cheapest protection is an assertion. If password login is going to be disabled and you forgot to provide a public key, fail before changing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SSH | Administrator key required before disabling passwords&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.assert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;that&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hardening_admin_ssh_keys | length &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="na"&gt;fail_msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;Password login would be disabled, but hardening_admin_ssh_keys&lt;/span&gt;
      &lt;span class="s"&gt;is empty. Set a public key or allow password authentication.&lt;/span&gt;
    &lt;span class="na"&gt;quiet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;not (hardening_ssh_password_authentication | bool)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role also asserts that, if you set &lt;code&gt;AllowUsers&lt;/code&gt;, the admin user is in the list. Both checks are about the same idea: catch the lockout in the playbook, not on the server.&lt;/p&gt;

&lt;p&gt;The admin user itself is the standard pairing of &lt;code&gt;ansible.builtin.user&lt;/code&gt; and &lt;code&gt;ansible.posix.authorized_key&lt;/code&gt;, with &lt;code&gt;sudo&lt;/code&gt; in its groups. Decide on sudo before you run: if the account has neither a password hash nor passwordless sudo, &lt;code&gt;sudo&lt;/code&gt; will not work for it. The role defaults to no &lt;code&gt;NOPASSWD&lt;/code&gt;, so you either supply a password hash or opt in explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  ufw: rule first, enable last
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;community.general.ufw&lt;/code&gt;, the sequence matters. Add the SSH allow rule, set policies, and enable at the very end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UFW | Allow SSH before enabling the firewall&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&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;ssh_port&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;string&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
    &lt;span class="na"&gt;from_ip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&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;ssh_allowed_from&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;default('any')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh&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;UFW | Default incoming policy&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;
    &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;incoming&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;UFW | Enable the firewall&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you restrict &lt;code&gt;from_ip&lt;/code&gt; to your own address, be sure that address is stable. A home connection with a changing IP is a good way to lock yourself out slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key-only SSH, validated before it is written
&lt;/h2&gt;

&lt;p&gt;Ubuntu 24.04 reads &lt;code&gt;/etc/ssh/sshd_config.d/*.conf&lt;/code&gt;. A drop-in is much safer than editing &lt;code&gt;sshd_config&lt;/code&gt;, because you only own one small file. The role names it &lt;code&gt;10-hardening.conf&lt;/code&gt;. The &lt;code&gt;10-&lt;/code&gt; prefix matters: in &lt;code&gt;sshd&lt;/code&gt; the first value found wins, and cloud images often ship &lt;code&gt;50-cloud-init.conf&lt;/code&gt; with &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;. Your file must sort before it.&lt;/p&gt;

&lt;p&gt;The key trick is the &lt;code&gt;validate:&lt;/code&gt; parameter of the template module. Ansible renders to a temporary file, runs the validator on it, and only moves it into place if it passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SSH | Privilege separation directory (needed by sshd -t)&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/sshd&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;directory&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0755"&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;SSH | Write hardening drop-in&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sshd-hardening.conf.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssh/sshd_config.d/10-hardening.conf&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&lt;/span&gt;
    &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/sbin/sshd -t -f %s&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reload ssh configuration&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A broken file never reaches disk, so a typo cannot take down the daemon. The &lt;code&gt;/run/sshd&lt;/code&gt; task is there because &lt;code&gt;sshd -t&lt;/code&gt; can complain if that directory is missing on a fresh box.&lt;/p&gt;

&lt;p&gt;The template itself is plain directives: &lt;code&gt;PermitRootLogin no&lt;/code&gt;, &lt;code&gt;PasswordAuthentication no&lt;/code&gt;, &lt;code&gt;KbdInteractiveAuthentication no&lt;/code&gt;, &lt;code&gt;PubkeyAuthentication yes&lt;/code&gt;, &lt;code&gt;MaxAuthTries&lt;/code&gt;, &lt;code&gt;X11Forwarding no&lt;/code&gt;, &lt;code&gt;LogLevel VERBOSE&lt;/code&gt;. The handler &lt;em&gt;reloads&lt;/em&gt; sshd rather than restarting it, and existing sessions stay alive.&lt;/p&gt;

&lt;p&gt;Two gotchas worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TCP forwarding.&lt;/strong&gt; The role leaves &lt;code&gt;AllowTcpForwarding&lt;/code&gt; at &lt;code&gt;yes&lt;/code&gt; by default so existing tunnels keep working. CIS-style guidance recommends &lt;code&gt;no&lt;/code&gt;; flip it if you do not use tunnels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing the port on 24.04.&lt;/strong&gt; sshd is socket-activated through &lt;code&gt;ssh.socket&lt;/code&gt;, and the &lt;code&gt;Port&lt;/code&gt; directive is ignored in that mode. The role writes a &lt;code&gt;ssh.socket.d&lt;/code&gt; override for non-default ports. Changing the port is where most of the remaining lockout risk lives, since the old port is not closed in ufw automatically. If you do not need a custom port, leave it at 22 and let fail2ban handle the noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  fail2ban
&lt;/h2&gt;

&lt;p&gt;On a public IP, SSH brute-force attempts start quickly. fail2ban is a light answer. The role writes one jail, using the systemd journal as the backend (Ubuntu 24.04 does not need a log file for this):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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;fail2ban | sshd jail&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/fail2ban/jail.d/sshd-hardening.local&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;[DEFAULT]&lt;/span&gt;
      &lt;span class="s"&gt;ignoreip = 127.0.0.1/8 ::1 {{ my_office_ip }}&lt;/span&gt;

      &lt;span class="s"&gt;[sshd]&lt;/span&gt;
      &lt;span class="s"&gt;enabled = true&lt;/span&gt;
      &lt;span class="s"&gt;port = {{ ssh_port }}&lt;/span&gt;
      &lt;span class="s"&gt;backend = systemd&lt;/span&gt;
      &lt;span class="s"&gt;maxretry = 5&lt;/span&gt;
      &lt;span class="s"&gt;findtime = 10m&lt;/span&gt;
      &lt;span class="s"&gt;bantime = 1h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put your own static IP in &lt;code&gt;ignoreip&lt;/code&gt; if you have one. A typo'd key on a few retries should not ban the one person who is allowed in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unattended security upgrades, security only
&lt;/h2&gt;

&lt;p&gt;Automatic patching is probably the highest value-per-line item here. The role drops a file that loads after &lt;code&gt;50unattended-upgrades&lt;/code&gt;, clears the origin list with &lt;code&gt;#clear&lt;/code&gt;, and re-adds only the security pockets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Updates | Security-only unattended upgrades&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/apt/apt.conf.d/52hardening-unattended-upgrades&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;APT::Periodic::Update-Package-Lists "1";&lt;/span&gt;
      &lt;span class="s"&gt;APT::Periodic::Unattended-Upgrade "1";&lt;/span&gt;
      &lt;span class="s"&gt;#clear Unattended-Upgrade::Allowed-Origins;&lt;/span&gt;
      &lt;span class="s"&gt;Unattended-Upgrade::Allowed-Origins {&lt;/span&gt;
              &lt;span class="s"&gt;"${distro_id}:${distro_codename}-security";&lt;/span&gt;
      &lt;span class="s"&gt;};&lt;/span&gt;
      &lt;span class="s"&gt;Unattended-Upgrade::Automatic-Reboot "false";&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role also allows the ESM security origins; I left them out here for brevity. Automatic reboot is &lt;strong&gt;off&lt;/strong&gt; by default. If a kernel update needs a reboot, the server waits for you. Turn it on (with a reboot time) only when you are comfortable with the host restarting unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  sysctl and auditd
&lt;/h2&gt;

&lt;p&gt;Neither can lock you out of SSH, but both can break workloads, so read what you apply.&lt;/p&gt;

&lt;p&gt;For sysctl, the role writes a file to &lt;code&gt;/etc/sysctl.d/60-hardening.conf&lt;/code&gt; with the usual network and kernel switches: reverse-path filtering, no ICMP redirects, no source routing, martian logging, SYN cookies, &lt;code&gt;kernel.kptr_restrict&lt;/code&gt;, &lt;code&gt;kernel.dmesg_restrict&lt;/code&gt;, &lt;code&gt;fs.protected_symlinks&lt;/code&gt;, and so on. You can express that with the &lt;code&gt;ansible.posix.sysctl&lt;/code&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sysctl | Network and kernel hardening&lt;/span&gt;
  &lt;span class="na"&gt;ansible.posix.sysctl&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="s2"&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;item.key&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&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;item.value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;sysctl_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/sysctl.d/60-hardening.conf&lt;/span&gt;
    &lt;span class="na"&gt;reload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&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;sysctl_params&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;dict2items&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sysctl_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;net.ipv4.conf.all.rp_filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;net.ipv4.conf.all.accept_redirects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;net.ipv4.tcp_syncookies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;kernel.dmesg_restrict&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;fs.protected_symlinks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The one that bites:&lt;/strong&gt; the role's base set includes &lt;code&gt;net.ipv4.ip_forward = 0&lt;/code&gt;. That breaks Docker, Kubernetes, VPNs and anything that routes. The role has an override variable for that case. If you run containers, remove forwarding from your list before applying.&lt;/p&gt;

&lt;p&gt;For auditd, the role installs baseline rules in &lt;code&gt;/etc/audit/rules.d/&lt;/code&gt;: watches on &lt;code&gt;/etc/passwd&lt;/code&gt;, &lt;code&gt;/etc/shadow&lt;/code&gt;, &lt;code&gt;/etc/sudoers&lt;/code&gt;, &lt;code&gt;/etc/ssh/sshd_config.d&lt;/code&gt;, login files, time changes, and hostname/network files, each with a key such as &lt;code&gt;identity&lt;/code&gt; or &lt;code&gt;sshd&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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;auditd | Baseline watches&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/audit/rules.d/60-hardening.rules&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0640"&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;-w /etc/passwd -p wa -k identity&lt;/span&gt;
      &lt;span class="s"&gt;-w /etc/shadow -p wa -k identity&lt;/span&gt;
      &lt;span class="s"&gt;-w /etc/sudoers -p wa -k scope&lt;/span&gt;
      &lt;span class="s"&gt;-w /etc/sudoers.d -p wa -k scope&lt;/span&gt;
      &lt;span class="s"&gt;-w /etc/ssh/sshd_config.d -p wa -k sshd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An immutable-rules option (&lt;code&gt;-e 2&lt;/code&gt;) exists but is off by default, because after that, any rule change needs a reboot. Enable it deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it safely
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.ini site.yml &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="nt"&gt;--diff&lt;/span&gt;
ansible-playbook &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.ini site.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dry-run first. Be aware that &lt;code&gt;--check&lt;/code&gt; is less informative on a brand-new host: a package that is not installed yet makes later tasks (like ufw rules) unable to run in check mode, and the role has to tolerate that. Set &lt;code&gt;serial: 1&lt;/code&gt; so a failure on the first server stops the rollout. Then log in from a second terminal.&lt;/p&gt;

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

&lt;p&gt;Be honest about the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not a CIS compliance audit.&lt;/strong&gt; It is a pragmatic subset of CIS-style recommendations, a "CIS-lite" baseline. If you need a benchmark score or auditor evidence, use a proper tool and a proper process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every environment was tested.&lt;/strong&gt; The role targets Ubuntu 24.04 only; running on another OS is a flag you set at your own risk. I cannot claim that ufw or the rest behave the same on every cloud provider and image. Test on a throwaway VM from your provider first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud security groups are still needed.&lt;/strong&gt; Many providers have their own network firewall in front of the VM. ufw runs on the host &lt;em&gt;in addition&lt;/em&gt;, so a port must be open in both places. Opening a port in ufw alone will not make it reachable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardening is not monitoring or backups.&lt;/strong&gt; None of this tells you when the disk is full or the service is down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The full role, if you want it
&lt;/h2&gt;

&lt;p&gt;Everything above is enough to build your own. If you would rather not assemble it, the complete role includes per-part tags and &lt;code&gt;hardening_&amp;lt;part&amp;gt;_enabled&lt;/code&gt; switches, a &lt;code&gt;rollback.yml&lt;/code&gt;, a Makefile (&lt;code&gt;check&lt;/code&gt;, &lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;rollback&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt;) and passes yamllint and ansible-lint. It is sold for 11 USDT via &lt;a href="https://t.me/devkit_shop_bot?start=devto_ansible" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. There is also a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;monitoring kit for a single VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ansible</category>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>A free runbook for 44 Prometheus alerts (Linux, Docker, Kubernetes, TLS)</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Sat, 24 Oct 2026 21:22:28 +0000</pubDate>
      <link>https://dev.to/devkit_shop/a-free-runbook-for-44-prometheus-alerts-linux-docker-kubernetes-tls-1d8g</link>
      <guid>https://dev.to/devkit_shop/a-free-runbook-for-44-prometheus-alerts-linux-docker-kubernetes-tls-1d8g</guid>
      <description>&lt;p&gt;Most Prometheus setups I see have alerts but no answer to the 3 a.m. question: &lt;em&gt;"OK, it fired. Now what?"&lt;/em&gt; The fix is boring and effective: every alert gets a &lt;code&gt;runbook_url&lt;/code&gt; annotation that points to one short page with the same structure every time.&lt;/p&gt;

&lt;p&gt;I wrote such a runbook for 44 common alerts (Linux hosts via node-exporter, Docker via cAdvisor, Kubernetes via kube-state-metrics, websites and TLS via blackbox-exporter) and put it online for free:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://devkit-shop.surge.sh/runbook/en/" rel="noopener noreferrer"&gt;Prometheus alert runbook (free, English)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The format
&lt;/h2&gt;

&lt;p&gt;Every section answers the same five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What it means&lt;/strong&gt;: in plain words, including the exact condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Common causes&lt;/strong&gt;: the usual suspects, most likely first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostics&lt;/strong&gt;: copy-paste commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How to fix&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it is OK to silence&lt;/strong&gt;: so silences get a reason and an expiry instead of becoming "monitoring switched off".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Example: disk will fill within 24 hours
&lt;/h2&gt;

&lt;p&gt;A static "disk &amp;gt; 90%" alert is either too late or too noisy. A trend-based one catches the runaway log before it bites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodeDiskWillFill&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}[6h], 24 * 3600) &amp;lt; 0&lt;/span&gt;
    &lt;span class="s"&gt;and node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}&lt;/span&gt;
      &lt;span class="s"&gt;/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"} * 100 &amp;lt; 40&lt;/span&gt;
    &lt;span class="s"&gt;and node_filesystem_readonly{fstype!~"tmpfs|overlay|squashfs"} == 0&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Disk&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;$labels.mountpoint&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;will&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;within&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;day&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;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}})"&lt;/span&gt;
    &lt;span class="na"&gt;runbook_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://devkit-shop.surge.sh/runbook/en/#nodediskwillfill"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt; 40&lt;/code&gt; guard matters: without it, a big, mostly empty disk with a brief write burst will page you for nothing.&lt;/p&gt;

&lt;p&gt;And the runbook section it links to starts with diagnostics you can paste straight away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;mountpoint&amp;gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &amp;lt;mountpoint&amp;gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20
find &amp;lt;mountpoint&amp;gt; &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-mmin&lt;/span&gt; &lt;span class="nt"&gt;-60&lt;/span&gt; &lt;span class="nt"&gt;-size&lt;/span&gt; +100M &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the important judgement call: is the growth steady (rotation is missing, a leak) or a one-off jump (an import or a backup)? For a known one-off, silence it for a few hours, not days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example: CrashLoopBackOff
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;ns&amp;gt; describe pod &amp;lt;pod&amp;gt;              &lt;span class="c"&gt;# Events and Last State&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;ns&amp;gt; logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt;container&amp;gt; &lt;span class="nt"&gt;--previous&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;ns&amp;gt; get events &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.lastTimestamp | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--previous&lt;/code&gt; is the flag people forget: it shows the log of the run that actually crashed, not the fresh one that is about to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips that apply to any alert set
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put &lt;code&gt;runbook_url&lt;/code&gt; in every rule and render it as a link in your Telegram/Slack template. It costs one line and saves minutes at night.&lt;/li&gt;
&lt;li&gt;Use only two severities (&lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;critical&lt;/code&gt;) and route them differently in Alertmanager.&lt;/li&gt;
&lt;li&gt;Add an inhibition rule so &lt;code&gt;NodeDown&lt;/code&gt; suppresses everything else from that host.&lt;/li&gt;
&lt;li&gt;Unit-test your rules with &lt;code&gt;promtool test rules&lt;/code&gt;. Thresholds and &lt;code&gt;for:&lt;/code&gt; durations are easy to get subtly wrong, and a test catches that before production does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rules themselves
&lt;/h2&gt;

&lt;p&gt;The runbook is free to read and bookmark. If you'd rather not write the 44 rules yourself, I sell the ready pack: rules in English and Russian, 113 &lt;code&gt;promtool&lt;/code&gt; unit tests, a &lt;code&gt;PrometheusRule&lt;/code&gt; for kube-prometheus-stack and an Alertmanager example with severity routing and inhibition. It costs 9 USDT via &lt;a href="https://t.me/devkit_shop_bot?start=devto_runbook" rel="noopener noreferrer"&gt;the Telegram shop&lt;/a&gt;. If you're just getting started, there's also a free &lt;a href="https://devkit-shop.surge.sh/free/" rel="noopener noreferrer"&gt;Prometheus + Grafana + Telegram alerts kit for a single VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Feedback welcome. If an alert you rely on is missing from the runbook, tell me in the comments.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Monitor your VPS in 10 minutes: Prometheus, Grafana and Telegram alerts (free kit)</title>
      <dc:creator>DevKit</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:22:02 +0000</pubDate>
      <link>https://dev.to/devkit_shop/monitor-your-vps-in-10-minutes-prometheus-grafana-and-telegram-alerts-free-kit-37pc</link>
      <guid>https://dev.to/devkit_shop/monitor-your-vps-in-10-minutes-prometheus-grafana-and-telegram-alerts-free-kit-37pc</guid>
      <description>&lt;p&gt;Most people start thinking about monitoring after the first incident: the disk fills up with logs, the database dies from lack of memory, and you learn about it from your users. Basic protection can be set up in an evening, and faster if you don't assemble the stack by hand.&lt;/p&gt;

&lt;p&gt;Here is the minimal setup that is enough for one VPS. Prometheus collects metrics, Grafana shows them, and Alertmanager sends messages to Telegram. Everything runs with Docker Compose. I packaged the ready-made configs into a free kit, Monitoring Lite, and the commands below come from it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;four containers: Prometheus, node-exporter, Alertmanager, Grafana;&lt;/li&gt;
&lt;li&gt;one Host Overview dashboard: CPU, memory, disk usage by mountpoint, network, load average, uptime;&lt;/li&gt;
&lt;li&gt;five alerts in Telegram: host down, high CPU, high memory, disk almost full, host rebooted;&lt;/li&gt;
&lt;li&gt;a Grafana password generated at install time, and ports that are closed to the outside world.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;node-exporter reads the host's &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;/sys&lt;/code&gt; and serves metrics over HTTP. Prometheus scrapes them every 15 seconds and stores them. Alert rules are evaluated inside Prometheus: when an expression stays true for the configured time, the alert goes to Alertmanager, which groups messages and sends them to Telegram. Grafana reads data from Prometheus.&lt;/p&gt;

&lt;p&gt;The datasource and dashboard are provisioned automatically, so you don't create anything by hand in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a Telegram bot
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Message &lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt;&lt;/strong&gt; in Telegram, send &lt;code&gt;/newbot&lt;/code&gt;, and pick a name. You get a token like &lt;code&gt;123456789:AAExxxxxxxx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send any message to your new bot. Until you do, it cannot write to you first.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;https://api.telegram.org/bot&amp;lt;TOKEN&amp;gt;/getUpdates&lt;/code&gt; in a browser and find &lt;code&gt;"chat":{"id": ...&lt;/code&gt; in the response. That's your &lt;code&gt;chat_id&lt;/code&gt;. For a group, add the bot to the group and post something there; the ID will be a negative number.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 2: install
&lt;/h2&gt;

&lt;p&gt;You need Docker and Docker Compose. Download the kit: &lt;a href="https://devkit-shop.surge.sh/free/devops-monitoring-lite-v1.0.zip" rel="noopener noreferrer"&gt;https://devkit-shop.surge.sh/free/devops-monitoring-lite-v1.0.zip&lt;/a&gt;, unpack it, and run the installer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip devops-monitoring-lite-v1.0.zip
&lt;span class="nb"&gt;cd &lt;/span&gt;devops-monitoring-lite
&lt;span class="nv"&gt;TG_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789:AAExxxxxxxx &lt;span class="nv"&gt;TG_CHAT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;987654321 ./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TG_BOT_TOKEN&lt;/code&gt; and &lt;code&gt;TG_CHAT_ID&lt;/code&gt; are only needed for this run: the installer saves them to &lt;code&gt;.env&lt;/code&gt; as &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt; and &lt;code&gt;TELEGRAM_CHAT_ID&lt;/code&gt;. If you don't pass them and they aren't in &lt;code&gt;.env&lt;/code&gt; yet, the script asks for the token and chat_id interactively. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;checks for Docker and &lt;code&gt;docker compose&lt;/code&gt; (or the older &lt;code&gt;docker-compose&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;creates &lt;code&gt;.env&lt;/code&gt; and generates a Grafana admin password in it;&lt;/li&gt;
&lt;li&gt;validates the token and chat_id format so a malformed value can't break the YAML;&lt;/li&gt;
&lt;li&gt;renders the Alertmanager config from a template with your token and chat_id;&lt;/li&gt;
&lt;li&gt;runs &lt;code&gt;docker compose up -d&lt;/code&gt; and restarts Alertmanager so it picks up the fresh config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can safely re-run it: the Grafana password and other values in &lt;code&gt;.env&lt;/code&gt; are kept. To change the token, run the installer again with a new &lt;code&gt;TG_BOT_TOKEN&lt;/code&gt;, or edit &lt;code&gt;.env&lt;/code&gt; and run &lt;code&gt;./install.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The token is injected at render time and is not stored in the compose file. The generated &lt;code&gt;alertmanager.yml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; are both listed in the kit's &lt;code&gt;.gitignore&lt;/code&gt;, so they stay out of git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: reach Grafana
&lt;/h2&gt;

&lt;p&gt;All ports are bound to &lt;code&gt;127.0.0.1&lt;/code&gt; on purpose. Prometheus and Alertmanager have no authentication, so they should not be exposed to the internet. From your computer, use an SSH tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 3000:127.0.0.1:3000 user@your-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000&lt;/code&gt;. The login is &lt;code&gt;admin&lt;/code&gt;; the password is in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;GRAFANA_ADMIN_PASSWORD .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the "Host Overview — Сервер" dashboard in the "Monitoring Lite" folder. It shows CPU, memory, disk and network graphs, load average and uptime (the archive includes a sample screenshot, &lt;code&gt;host-overview.png&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;You can open access by IP by setting &lt;code&gt;BIND_ADDR=0.0.0.0&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and running &lt;code&gt;docker compose up -d&lt;/code&gt;. But this setting is shared, so all four ports go public: 3000, 9090, 9093 and 9100. There's a second trap: ports published by Docker bypass ufw rules, so &lt;code&gt;ufw deny 9090&lt;/code&gt; won't close them. You'd have to block them with your hosting provider's firewall or with rules in the iptables &lt;code&gt;DOCKER-USER&lt;/code&gt; chain. If in doubt, keep &lt;code&gt;127.0.0.1&lt;/code&gt; and use the tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: verify the alert path
&lt;/h2&gt;

&lt;p&gt;First, check that Prometheus sees the targets and has loaded the rules:&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; http://127.0.0.1:9090/api/v1/targets | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"health":"[a-z]*"'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/rules | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"name":"[A-Za-z]*"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both targets (Prometheus itself and node-exporter) should report &lt;code&gt;"health":"up"&lt;/code&gt;, and the second command should list five rule names. Now send a test alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/send-test-alert.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script POSTs to the Alertmanager API. Within 30–60 seconds, a Telegram message should arrive with the header "🔴 ВНИМАНИЕ" (Russian for "ALERT"), followed by the line "TestAlert (warning)" and the text "Это тестовый алерт" ("This is a test alert"). The delay comes from &lt;code&gt;group_wait: 30s&lt;/code&gt;, which lets Alertmanager batch related alerts into one message.&lt;/p&gt;

&lt;p&gt;If nothing arrives, check the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs alertmanager | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most often the cause is that you never messaged the bot, or the token has a typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules
&lt;/h2&gt;

&lt;p&gt;The rules live in &lt;code&gt;config/prometheus/alert_rules.yml&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;&lt;code&gt;for&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InstanceDown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;up{job="node-exporter"} == 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HighCPU&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;average CPU load &amp;gt; 90%&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HighMemory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;more than 90% of RAM used, based on &lt;code&gt;MemAvailable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DiskSpace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;disk more than 85% full&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HostRebooted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;time() - node_boot_time_seconds &amp;lt; 600&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;for&lt;/code&gt; clause prevents false alarms: a short CPU spike during a build or a backup won't wake you up at night. &lt;code&gt;HostRebooted&lt;/code&gt; has no &lt;code&gt;for&lt;/code&gt;, so it fires right after boot and stays active for the first 10 minutes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;DiskSpace&lt;/code&gt; rule ignores &lt;code&gt;tmpfs&lt;/code&gt;, &lt;code&gt;overlay&lt;/code&gt;, &lt;code&gt;squashfs&lt;/code&gt;, &lt;code&gt;devtmpfs&lt;/code&gt;, all &lt;code&gt;fuse.*&lt;/code&gt; types (such as &lt;code&gt;fuse.sshfs&lt;/code&gt;), &lt;code&gt;nfs&lt;/code&gt; and &lt;code&gt;nfs4&lt;/code&gt;, &lt;code&gt;cifs&lt;/code&gt;, &lt;code&gt;smb3&lt;/code&gt; and &lt;code&gt;autofs&lt;/code&gt;. Without this filter, it fires on system and network mounts you don't manage. For memory, the rule uses &lt;code&gt;MemAvailable&lt;/code&gt; rather than &lt;code&gt;MemFree&lt;/code&gt;: Linux uses spare memory for cache, so "free" memory is almost never large.&lt;/p&gt;

&lt;p&gt;Thresholds are edited in the same file. After a change, reload the rules without restarting:&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;-X&lt;/span&gt; POST http://127.0.0.1:9090/-/reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works because Prometheus runs with &lt;code&gt;--web.enable-lifecycle&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a second server
&lt;/h2&gt;

&lt;p&gt;Run node-exporter on the second server and add it to &lt;code&gt;config/prometheus/prometheus.yml&lt;/code&gt; under the &lt;code&gt;node-exporter&lt;/code&gt; job as a separate entry with its own label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-exporter&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node-exporter:9100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;instance_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main-server"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.5:9100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;instance_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server-2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;instance_name&lt;/code&gt; label on the first server as is; it's already in the kit's config. Then run &lt;code&gt;docker compose restart prometheus&lt;/code&gt;. Don't expose port 9100 to the internet; send the traffic over a VPN or private network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Grafana container won't start.&lt;/strong&gt; Check &lt;code&gt;docker compose logs grafana&lt;/code&gt;. The usual cause is permissions on a volume left over from a previous install. If you don't need the data, &lt;code&gt;./uninstall.sh --purge-data&lt;/code&gt; followed by &lt;code&gt;./install.sh&lt;/code&gt; helps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Prometheus target is DOWN.&lt;/strong&gt; Open the targets page through the tunnel at &lt;code&gt;http://127.0.0.1:9090/targets&lt;/code&gt;; it shows the reason, such as &lt;code&gt;connection refused&lt;/code&gt; or &lt;code&gt;context deadline exceeded&lt;/code&gt;. Usually the service hasn't started yet, or there's a typo in the address in &lt;code&gt;prometheus.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port already in use.&lt;/strong&gt; If Grafana or Prometheus is already running on the server, change the relevant variables in &lt;code&gt;.env&lt;/code&gt; (&lt;code&gt;GRAFANA_PORT&lt;/code&gt;, &lt;code&gt;PROMETHEUS_PORT&lt;/code&gt;, &lt;code&gt;ALERTMANAGER_PORT&lt;/code&gt;, &lt;code&gt;NODE_EXPORTER_PORT&lt;/code&gt;) and run &lt;code&gt;docker compose up -d&lt;/code&gt;. Remember to change the port in the &lt;code&gt;curl&lt;/code&gt; commands and the SSH tunnel from this article too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telegram wasn't configured during install.&lt;/strong&gt; Alertmanager still starts, just without sending anything. Fill in &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt; and &lt;code&gt;TELEGRAM_CHAT_ID&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and run &lt;code&gt;./install.sh&lt;/code&gt; again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping and removing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./uninstall.sh              &lt;span class="c"&gt;# stop, keep data&lt;/span&gt;
./uninstall.sh &lt;span class="nt"&gt;--purge-data&lt;/span&gt; &lt;span class="c"&gt;# stop and delete data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's not in this kit
&lt;/h2&gt;

&lt;p&gt;The kit is deliberately limited to one server. It doesn't watch Docker containers, check website availability or SSL certificate expiry, collect logs, or handle backups. If you need those, you can add them yourself: cAdvisor for containers, blackbox-exporter for sites, Loki for logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full version
&lt;/h2&gt;

&lt;p&gt;The paid DevOps Monitoring Starter Kit covers those gaps: cAdvisor, blackbox-exporter for site and SSL checks, three dashboards (host, Docker containers, sites and SSL), 11 alert rules, a backup script, and optionally Loki with Promtail for logs and HTTPS via Caddy. Details are at &lt;a href="https://devkit-shop.surge.sh/" rel="noopener noreferrer"&gt;https://devkit-shop.surge.sh/&lt;/a&gt;, where you can pay with USDT (TRC20) or, in Russia, by card.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article and the kit were prepared by an AI assistant working on behalf of the kit's author, and the commands were checked against the kit files.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>prometheus</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
