<?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: Muhammad Soliman</title>
    <description>The latest articles on DEV Community by Muhammad Soliman (@msoliman).</description>
    <link>https://dev.to/msoliman</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%2F4158322%2F237aa29a-7c16-4a0a-b621-f530e698b481.png</url>
      <title>DEV Community: Muhammad Soliman</title>
      <link>https://dev.to/msoliman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/msoliman"/>
    <language>en</language>
    <item>
      <title>Sending form submissions to Slack, Discord, Airtable and Notion</title>
      <dc:creator>Muhammad Soliman</dc:creator>
      <pubDate>Fri, 02 Oct 2026 19:36:41 +0000</pubDate>
      <link>https://dev.to/msoliman/sending-form-submissions-to-slack-discord-airtable-and-notion-3g8h</link>
      <guid>https://dev.to/msoliman/sending-form-submissions-to-slack-discord-airtable-and-notion-3g8h</guid>
      <description>&lt;p&gt;Every integration below has one setup mistake that returns success and delivers nothing. Here is each one, how to spot it, and the webhook payload I ended up designing around them.&lt;/p&gt;

&lt;p&gt;I build a form builder, &lt;a href="https://forms.seagit.com" rel="noopener noreferrer"&gt;Seagit Forms&lt;/a&gt;, and most of the support questions I get aren't about forms at all. They're about a response that should have landed in Slack or Notion and didn't, while every dashboard said it worked.&lt;/p&gt;

&lt;p&gt;After wiring up nine destinations, I noticed a pattern. &lt;strong&gt;Each service has exactly one mistake that a careful, correct-looking setup still hits, and it never throws an error.&lt;/strong&gt; No 4xx, no exception, no red banner. Just silence.&lt;/p&gt;

&lt;p&gt;Here are the ones worth knowing, whether you use my tool, someone else's, or write the integration yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slack: &lt;code&gt;200 OK&lt;/code&gt; that posts nothing
&lt;/h2&gt;

&lt;p&gt;You create a Slack app, add the &lt;code&gt;chat:write&lt;/code&gt; scope, install it, copy the &lt;code&gt;xoxb-&lt;/code&gt; bot token and a channel ID. You call &lt;code&gt;chat.postMessage&lt;/code&gt;. Slack answers &lt;strong&gt;HTTP 200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nothing appears in the channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_in_channel"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slack returns errors in the body with a 200 status. A bot with &lt;code&gt;chat:write&lt;/code&gt; can't post to a channel it hasn't joined. Two fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/invite @your-app        # in the channel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or add the &lt;code&gt;chat:write.public&lt;/code&gt; scope, which lets the bot post to any &lt;strong&gt;public&lt;/strong&gt; channel without joining. Private channels always need the invite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; never check &lt;code&gt;response.ok&lt;/code&gt; on a Slack call. Check &lt;code&gt;body.ok&lt;/code&gt;. A client that only looks at the HTTP status reports success for every misconfiguration Slack has.&lt;/p&gt;

&lt;p&gt;Also set &lt;code&gt;text&lt;/code&gt; on every message, even when you use &lt;code&gt;blocks&lt;/code&gt;. Slack uses it for the notification and sidebar preview; without it the message arrives but notifies with a blank line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discord: &lt;code&gt;204 No Content&lt;/code&gt; hides rejected messages
&lt;/h2&gt;

&lt;p&gt;Discord channel webhooks are the easiest integration there is: one URL, no auth flow. POST JSON to it, get a &lt;code&gt;204&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The catch is that a &lt;code&gt;204&lt;/code&gt; tells you almost nothing. Add &lt;code&gt;?wait=true&lt;/code&gt; to the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://discord.com/api/webhooks/&amp;lt;id&amp;gt;/&amp;lt;token&amp;gt;?wait=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Discord waits until the message is created and returns it as JSON, or returns a real error if it was rejected (an embed field over the length limit, for example). Without it, a message Discord refused looks exactly like one it posted.&lt;/p&gt;

&lt;p&gt;Treat the webhook URL as a password, too. Anyone holding it can post to the channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Airtable: one unknown column loses the whole record
&lt;/h2&gt;

&lt;p&gt;Airtable's API matches fields &lt;strong&gt;by column name&lt;/strong&gt;. If your payload has ten fields and one name doesn't match a column exactly, Airtable rejects the entire record, not just that field.&lt;/p&gt;

&lt;p&gt;That makes renames dangerous. Someone tidies "Email address" to "Email" in the base, and from then on every submission is rejected.&lt;/p&gt;

&lt;p&gt;What worked for me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the table's schema first.&lt;/li&gt;
&lt;li&gt;Send only fields whose names match a column.&lt;/li&gt;
&lt;li&gt;Surface the unmatched ones to the form owner as a warning, rather than failing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also: don't send &lt;code&gt;""&lt;/code&gt; for blank answers. An empty string into a date, number or single-select column is itself a rejection. Omit the field instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notion: &lt;code&gt;404&lt;/code&gt; for an ID that is correct
&lt;/h2&gt;

&lt;p&gt;You create an internal integration, copy its token, copy your database ID from the URL, and call the API. Notion answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object_not_found"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you re-check the ID. It's right. You re-copy it. Still 404.&lt;/p&gt;

&lt;p&gt;The database exists. &lt;strong&gt;It just isn't connected to your integration.&lt;/strong&gt; In Notion, open the database, then &lt;code&gt;•••&lt;/code&gt; → &lt;strong&gt;Connections&lt;/strong&gt; → add your integration. Notion deliberately reports "not found" instead of "forbidden" so it doesn't confirm the page exists, and that sends everyone after the wrong problem.&lt;/p&gt;

&lt;p&gt;Notion properties are also typed. A plain string into an &lt;code&gt;email&lt;/code&gt; or &lt;code&gt;date&lt;/code&gt; property is a 400 that loses the whole page, so read the database schema and shape each value to its property type first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microsoft Teams: the old connector is gone
&lt;/h2&gt;

&lt;p&gt;If a guide tells you to add an "Incoming Webhook" connector to a Teams channel, it's out of date: Microsoft retired Office 365 connectors. You now create the URL through &lt;strong&gt;Workflows&lt;/strong&gt; ("Post to a channel when a webhook request is received"). And note that the Workflows endpoint accepting your request is not the same as the card being posted. The flow runs afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook: designing for the failures above
&lt;/h2&gt;

&lt;p&gt;All of this pushed me toward making a plain webhook the most reliable destination, and treating every other integration as a notification rather than the record. This is the payload &lt;a href="https://forms.seagit.com/docs/webhooks" rel="noopener noreferrer"&gt;Seagit Forms sends to a webhook&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"responseId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"response-1787712439855-b0t71"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g-1787690899982-nd90p"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"submittedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1787712439855&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"firstName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"teamSize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"firstName"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"First name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"teamSize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Team size"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"respondent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userAgent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0 …"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"referrer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"submittedFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://forms.seagit.com/forms/view/g-1787690899982-nd90p"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decisions behind it, which apply to any webhook you design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;responseId&lt;/code&gt; is an idempotency key.&lt;/strong&gt; If you ever add retries, the receiver can drop duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fields&lt;/code&gt; travels with &lt;code&gt;data&lt;/code&gt;.&lt;/strong&gt; The receiver knows what each key means and its type, without a second API call to fetch the form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blank answers are absent, not &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; That keeps the "don't send empty strings" rule from the Airtable section true downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;submittedAt&lt;/code&gt; is epoch milliseconds&lt;/strong&gt;, not an ISO string. Pick one and document it; the bug is always a mix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed delivery never fails the submission.&lt;/strong&gt; The response is stored first, so a broken endpoint can't cost you an answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test any webhook without writing a receiver, paste a &lt;a href="https://webhook.site" rel="noopener noreferrer"&gt;webhook.site&lt;/a&gt; URL in as the endpoint and submit once. It shows the exact body and headers that arrived, which also confirms your auth header is spelled the way your real endpoint expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A checklist before you trust any integration&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the client check the &lt;strong&gt;response body&lt;/strong&gt;, not just the status code? (Slack)&lt;/li&gt;
&lt;li&gt;Does the call &lt;strong&gt;wait for a real result&lt;/strong&gt;? (Discord &lt;code&gt;?wait=true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;What happens when &lt;strong&gt;one field doesn't match&lt;/strong&gt;: is one field dropped or the whole record? (Airtable, Notion)&lt;/li&gt;
&lt;li&gt;Has the integration been &lt;strong&gt;granted access&lt;/strong&gt; to the specific resource, not just authenticated? (Slack channel, Notion database)&lt;/li&gt;
&lt;li&gt;Is there a &lt;strong&gt;record that doesn't depend on the integration&lt;/strong&gt;, so a silent failure is recoverable?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather not write any of this yourself, the &lt;a href="https://forms.seagit.com/integrations" rel="noopener noreferrer"&gt;setup guides for each destination&lt;/a&gt; walk through these exact traps, and webhooks and Telegram are free. Either way, I hope this saves someone an afternoon of re-copying an ID that was right all along.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>notionchallenge</category>
      <category>tutorial</category>
      <category>formbuilder</category>
    </item>
    <item>
      <title>Why our CI/CD is never allowed to deploy to production</title>
      <dc:creator>Muhammad Soliman</dc:creator>
      <pubDate>Fri, 02 Oct 2026 19:35:53 +0000</pubDate>
      <link>https://dev.to/msoliman/why-our-cicd-is-never-allowed-to-deploy-to-production-j02</link>
      <guid>https://dev.to/msoliman/why-our-cicd-is-never-allowed-to-deploy-to-production-j02</guid>
      <description>&lt;p&gt;A deployment model where every push gets its own isolated slot on Kubernetes, the main deployment is only changed on purpose, and promotion is an explicit act.&lt;/p&gt;

&lt;p&gt;Most pipelines I've worked with end in the same place: merge to &lt;code&gt;main&lt;/code&gt;, CI builds, CI deploys to production. It works until the day a green build is a bad release, and the only way back is another build.&lt;/p&gt;

&lt;p&gt;When we designed the deployment model for &lt;a href="https://seagit.com" rel="noopener noreferrer"&gt;SeaGit&lt;/a&gt;, a Kubernetes platform that runs in your own AWS account, we made one rule non-negotiable: &lt;strong&gt;CI/CD can create and update preview deployments, but it can never touch the main one.&lt;/strong&gt; This post explains the model, because the idea works whatever tooling you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instances and deployments
&lt;/h2&gt;

&lt;p&gt;Two concepts carry the whole design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;instance&lt;/strong&gt; is an application's definition: image, resources, environment variables, DNS. It belongs to an environment (staging, production, and so on).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;deployment&lt;/strong&gt; is one running copy of that instance. Each is its own Helm release with its own subdomain, for example &lt;code&gt;pr-142.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An instance has several deployments. &lt;strong&gt;Exactly one is marked main&lt;/strong&gt; (&lt;code&gt;is_main: true&lt;/code&gt;). Main is the stable, production-facing slot. Every other deployment is an ephemeral slot: a preview, a test, a dark release.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;instance: checkout-api            (environment: production)
├── main        → api.example.com          ← only changed on purpose
├── slot-a1f3   → a1f3.api.example.com     ← commit a1f3…
└── slot-9c2e   → 9c2e.api.example.com     ← commit 9c2e…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because preview slots run in the same environment as main (same cluster, same add-ons, same network), a preview is a true dark release. It's production-like infrastructure with zero production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a push picks a slot
&lt;/h2&gt;

&lt;p&gt;When a GitHub push arrives, the target is resolved with three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same commit, same slot.&lt;/strong&gt; If a deployment already exists for this commit SHA, it's re-triggered instead of duplicated. Re-running a pipeline doesn't spawn a second copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Otherwise, a new ephemeral slot&lt;/strong&gt;, as long as the instance is under the environment's &lt;code&gt;max_deployments_per_instance&lt;/code&gt; limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never main.&lt;/strong&gt; No rule in the resolver can select the main deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The limit matters more than it looks. Preview environments are cheap individually and expensive collectively. A hard per-environment cap forces a decision ("reuse a slot or delete one") instead of a cluster that slowly fills with forgotten previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a change into production
&lt;/h2&gt;

&lt;p&gt;If CI can't deploy to main, something else has to. There are two explicit paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Promote.&lt;/strong&gt; Copy the validated preview's configuration and run a fresh deploy into the main slot. Main keeps its name and URL, so nothing downstream changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark as main.&lt;/strong&gt; Move the &lt;code&gt;is_main&lt;/code&gt; flag to the preview itself. That slot becomes production, and future CI runs leave it alone and target the remaining slots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Promote is the conservative choice: production stays where it is and gets a known-good configuration. Mark as main is the fast one: the exact pods you just tested become production, with no rebuild in between.&lt;/p&gt;

&lt;p&gt;Both are deliberate actions, from the UI or the API. Neither happens because a build went green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A bad merge can't reach users on its own.&lt;/strong&gt; The worst outcome of a broken pipeline is a broken preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing happens on the real thing.&lt;/strong&gt; Same environment, same add-ons, same network policy as production, not a lookalike staging cluster that drifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback is boring.&lt;/strong&gt; The previous main still exists as a deployment until you remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-runs are idempotent.&lt;/strong&gt; The commit-SHA rule makes "retry the pipeline" safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is honest: someone has to press promote. For a team that wants every merge live within minutes, that's friction. For a team that has been paged at 2 a.m. by an auto-deploy, it's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing this yourself
&lt;/h2&gt;

&lt;p&gt;You don't need a platform to adopt the rule. With plain Kubernetes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy each branch or commit as its own Helm release, named from the commit or PR, with its own host in the ingress.&lt;/li&gt;
&lt;li&gt;Keep production in a release your CI credentials &lt;strong&gt;cannot write to&lt;/strong&gt;: a separate service account or a protected environment.&lt;/li&gt;
&lt;li&gt;Make promotion a separate, manually triggered job that copies a validated release's values into the production release.&lt;/li&gt;
&lt;li&gt;Cap the number of preview releases and delete old ones on a schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is the important one. "CI never deploys to production" only holds if it's enforced by permissions, not by convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where SeaGit fits
&lt;/h2&gt;

&lt;p&gt;SeaGit implements this model as the default: &lt;a href="https://seagit.com/features/preview-environments" rel="noopener noreferrer"&gt;ephemeral preview deployments&lt;/a&gt; on managed Kubernetes in your own AWS account, built from GitHub pushes, with Promote and Mark as main in the UI and API. Your cloud provider bills you directly for compute, and there's a free plan with one cluster for trying it out (&lt;a href="https://seagit.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). Azure and GCP support are on the roadmap.&lt;/p&gt;

&lt;p&gt;Whether you use it or build the four steps above yourself, the rule is the same: let CI create as many previews as you like, and make production something a person chooses.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cicd</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
