<?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>Forem: Savas Vedova</title>
    <description>The latest articles on Forem by Savas Vedova (@svedova).</description>
    <link>https://forem.com/svedova</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F168788%2F317ffb54-0ff7-4e1c-8dc6-8ed6a74c320f.jpg</url>
      <title>Forem: Savas Vedova</title>
      <link>https://forem.com/svedova</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/svedova"/>
    <language>en</language>
    <item>
      <title>How We Migrated from AWS to Hetzner Without Downtime</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Fri, 30 Jan 2026 08:58:41 +0000</pubDate>
      <link>https://forem.com/svedova/how-we-migrated-from-aws-to-hetzner-without-downtime-5831</link>
      <guid>https://forem.com/svedova/how-we-migrated-from-aws-to-hetzner-without-downtime-5831</guid>
      <description>&lt;p&gt;Before we get started, &lt;a href="https://www.stormkit.io" rel="noopener noreferrer"&gt;Stormkit&lt;/a&gt; is an open-source, boostrapped, self-hostable Vercel alternative that is operating in the EU. It allows building, deploying and hosting websites.&lt;/p&gt;

&lt;p&gt;Recently, we completed a full infrastructure migration from AWS to Hetzner. Zero downtime. Production websites kept humming. Users didn't notice a thing. Here's how we pulled it off—and the rabbit holes we fell into along the way 🐇&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Hetzner?
&lt;/h2&gt;

&lt;p&gt;Let's address the elephant in the room: AWS is expensive. For a bootstrapped company like ours, cutting infrastructure costs by 60-70% while maintaining (or improving) performance is a no-brainer. Hetzner offers bare-metal-like performance at a fraction of the cost, and their European data centers aligned perfectly with our user base.&lt;/p&gt;

&lt;p&gt;But migrating isn't just about spinning up new servers. We had to move databases, redirect traffic from existing AWS IPs, and keep everything running while we did it. No pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Checklist
&lt;/h2&gt;

&lt;p&gt;Before diving in, here's what we were dealing with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL database&lt;/strong&gt; with production data that couldn't afford a single lost transaction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple domains&lt;/strong&gt; pointing to AWS Elastic IPs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless functions&lt;/strong&gt; (Lambda) that don't have a Hetzner equivalent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production websites&lt;/strong&gt; serving real users 24/7&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's tackle each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Migration: The Scary Part
&lt;/h2&gt;

&lt;p&gt;Database migrations are where things can go spectacularly wrong. One missed transaction, one replication lag spike, and you've got angry users and corrupted data. We needed a zero-downtime approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Strategy: Logical Replication
&lt;/h3&gt;

&lt;p&gt;PostgreSQL's logical replication was our friend here. The plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set up a fresh PostgreSQL instance on Hetzner&lt;/li&gt;
&lt;li&gt;Configure logical replication from AWS RDS to Hetzner&lt;/li&gt;
&lt;li&gt;Let it sync while production continues on AWS&lt;/li&gt;
&lt;li&gt;Switch over when replication is caught up
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- On the source (AWS RDS), create a publication&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;stormkit_pub&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- On the target (Hetzner), create a subscription&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;SUBSCRIPTION&lt;/span&gt; &lt;span class="n"&gt;stormkit_sub&lt;/span&gt;
  &lt;span class="k"&gt;CONNECTION&lt;/span&gt; &lt;span class="s1"&gt;'host=aws-rds-endpoint dbname=stormkit user=replicator password=xxx'&lt;/span&gt;
  &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;stormkit_pub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beauty of logical replication is that it keeps both databases in sync in near real-time. We monitored the replication lag obsessively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When lag hit zero consistently, we were ready to switch. The actual cutover was anticlimactic—update the connection string, restart the app, done. Total write downtime: ~2 seconds while the app restarted.&lt;/p&gt;

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

&lt;p&gt;Here's where things got spicy 🌶️&lt;/p&gt;

&lt;p&gt;We had multiple Elastic IPs on AWS that customers had configured in their DNS. Changing DNS records takes time to propagate (thanks, TTL), and we couldn't ask customers to update their records on a schedule. We needed those AWS IPs to keep working but forward traffic to Hetzner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enter: The EC2 NAT Gateway
&lt;/h3&gt;

&lt;p&gt;The solution was surprisingly simple in concept but devilishly complex in execution: keep an EC2 instance on AWS purely to forward traffic to Hetzner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → AWS Elastic IP → EC2 (NAT) → Hetzner Load Balancer → Hetzner Server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Easy, right? Just some iptables rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Forward incoming traffic to Hetzner&lt;/span&gt;
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-i&lt;/span&gt; enX0 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443 &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; HETZNER_LB_IP:443
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-i&lt;/span&gt; enX0 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; HETZNER_LB_IP:80

&lt;span class="c"&gt;# SNAT for return traffic&lt;/span&gt;
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-d&lt;/span&gt; HETZNER_LB_IP &lt;span class="nt"&gt;-j&lt;/span&gt; MASQUERADE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship it! ✅&lt;/p&gt;

&lt;p&gt;Except... we had &lt;strong&gt;two Elastic IPs&lt;/strong&gt;. Which meant &lt;strong&gt;two ENIs (Elastic Network Interfaces)&lt;/strong&gt;. Which meant... asymmetric routing hell.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Multi-ENI Nightmare
&lt;/h3&gt;

&lt;p&gt;When you have multiple ENIs on an EC2 instance, traffic can come in on one interface and try to leave on another. This breaks TCP connections because the source IP doesn't match what the client expects.&lt;/p&gt;

&lt;p&gt;Here's what we saw in &lt;code&gt;conntrack&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src=CLIENT_IP dst=172.31.25.182 sport=39132 dport=443
src=HETZNER_LB_IP dst=172.31.31.236 sport=443 dport=39132
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traffic came in on ENI #2 (&lt;code&gt;172.31.25.182&lt;/code&gt;) but responses were trying to go out via ENI #1 (&lt;code&gt;172.31.31.236&lt;/code&gt;). The client would never receive the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: Policy-Based Routing + Packet Marking
&lt;/h3&gt;

&lt;p&gt;We needed to ensure symmetric routing—packets that come in on interface X must go out on interface X. Here's the solution:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Create routing tables for each ENI:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"100 enX0_table"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/iproute2/rt_tables
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"101 enX1_table"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/iproute2/rt_tables

ip route add default via 172.31.16.1 dev enX0 table enX0_table
ip route add default via 172.31.16.1 dev enX1 table enX1_table

ip rule add from 172.31.31.236 table enX0_table
ip rule add from 172.31.25.182 table enX1_table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Mark packets based on incoming interface:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; mangle &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-i&lt;/span&gt; enX0 &lt;span class="nt"&gt;-j&lt;/span&gt; MARK &lt;span class="nt"&gt;--set-mark&lt;/span&gt; 1
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; mangle &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-i&lt;/span&gt; enX1 &lt;span class="nt"&gt;-j&lt;/span&gt; MARK &lt;span class="nt"&gt;--set-mark&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. SNAT based on the mark:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-m&lt;/span&gt; mark &lt;span class="nt"&gt;--mark&lt;/span&gt; 1 &lt;span class="nt"&gt;-d&lt;/span&gt; HETZNER_LB_IP &lt;span class="nt"&gt;-j&lt;/span&gt; SNAT &lt;span class="nt"&gt;--to-source&lt;/span&gt; 172.31.31.236
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-m&lt;/span&gt; mark &lt;span class="nt"&gt;--mark&lt;/span&gt; 2 &lt;span class="nt"&gt;-d&lt;/span&gt; HETZNER_LB_IP &lt;span class="nt"&gt;-j&lt;/span&gt; SNAT &lt;span class="nt"&gt;--to-source&lt;/span&gt; 172.31.25.182
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Disable reverse path filtering (crucial!):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.all.rp_filter&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Disable source/destination checks on AWS:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the AWS console, we also had to disable source/destination checks for both the EC2 instance and each ENI. By default, AWS drops packets if the source or destination IP doesn't match the instance's assigned IP—which breaks NAT. Go to EC2 → Network Interfaces → Actions → Change Source/Dest Check → Disable.&lt;/p&gt;

&lt;p&gt;After hours of tcpdump, conntrack debugging, and hair-pulling, HTTPS finally worked through both IPs. The feeling when &lt;code&gt;curl https://your-domain.com&lt;/code&gt; returns a 200 after all that? &lt;em&gt;Chef's kiss&lt;/em&gt; 👨‍🍳&lt;/p&gt;

&lt;h2&gt;
  
  
  The Serverless Dilemma
&lt;/h2&gt;

&lt;p&gt;Hetzner doesn't have Lambda. No FaaS offering. Nada.&lt;/p&gt;

&lt;p&gt;For Stormkit, serverless functions are a core feature. Users deploy functions, and they need to run somewhere. We had three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build our own FaaS platform&lt;/strong&gt; on Hetzner (way too complex)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate to a different serverless provider&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Lambda on AWS&lt;/strong&gt; and route function traffic there&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We chose option 3. It's pragmatic—Lambda works, it's battle-tested, and rewriting our entire function runtime wasn't worth the engineering effort. Sometimes the boring solution is the right one.&lt;/p&gt;

&lt;p&gt;The architecture now looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────┐     ┌─────────────────┐
│   Hetzner LB    │────▶│  Hetzner Apps   │
└─────────────────┘     └─────────────────┘
         │
         ▼ (function requests)
┌─────────────────┐
│   AWS Lambda    │
└─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This hybrid approach lets us benefit from Hetzner's pricing for the heavy lifting while keeping Lambda for what it does best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Website Migration
&lt;/h2&gt;

&lt;p&gt;The final piece: moving actual user traffic without anyone noticing.&lt;/p&gt;

&lt;p&gt;Our strategy was gradual:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy the app on Hetzner&lt;/strong&gt; and run it in parallel with AWS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use health checks&lt;/strong&gt; to ensure Hetzner was serving correctly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the load balancer&lt;/strong&gt; to send traffic to Hetzner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor aggressively&lt;/strong&gt; for the first 24 hours&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Having AWS as a backup meant we could instantly rollback if something went wrong. Spoiler: we didn't need to.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test NAT gateways from outside, not localhost.&lt;/strong&gt; We spent hours debugging HTTPS issues that only existed because we were testing from the EC2 itself. Packets were bypassing PREROUTING entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multi-ENI routing is hard.&lt;/strong&gt; If you're doing anything with multiple network interfaces, budget extra time for debugging. &lt;code&gt;tcpdump&lt;/code&gt; and &lt;code&gt;conntrack&lt;/code&gt; are your best friends.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Logical replication is magic.&lt;/strong&gt; PostgreSQL's replication made database migration almost boring (in a good way).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hybrid cloud is fine.&lt;/strong&gt; You don't have to migrate everything. Keeping Lambda on AWS while running compute on Hetzner is a perfectly valid architecture.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Have a rollback plan.&lt;/strong&gt; Every step of the way, we knew exactly how to undo what we'd done. That confidence made us move faster.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Cloud migrations are intimidating, but they don't have to be catastrophic. With the right planning, the right tools, and a healthy dose of paranoia, you can move mountains of infrastructure without dropping a single request.&lt;/p&gt;

&lt;p&gt;If you're considering a similar migration, feel free to reach out. We've got the battle scars and the iptables rules to prove we survived 💪&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>We’ve just simplified Stormkit’s pricing 🚀

All limits scale linearly with seats — buy 10 seats, get 10 the resources. No hidden fees, no surprises.

A huge thank you to everyone who gave feedback on the old model.</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Fri, 05 Dec 2025 13:13:10 +0000</pubDate>
      <link>https://forem.com/svedova/weve-just-simplified-stormkits-pricing-all-limits-scale-linearly-with-seats-buy-10-seats-1gpk</link>
      <guid>https://forem.com/svedova/weve-just-simplified-stormkits-pricing-all-limits-scale-linearly-with-seats-buy-10-seats-1gpk</guid>
      <description></description>
    </item>
    <item>
      <title>Stormkit v1.25.0 is out 🚀

New feature: User sign-up management. Admins can now enable/disable new sign-ups or set approval mode to moderate registrations.

Read more: https://www.stormkit.io/docs/self-hosting/managing-users</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Wed, 03 Dec 2025 14:46:34 +0000</pubDate>
      <link>https://forem.com/svedova/stormkit-v1250-is-out-new-feature-user-sign-up-management-admins-can-now-enabledisable-186j</link>
      <guid>https://forem.com/svedova/stormkit-v1250-is-out-new-feature-user-sign-up-management-admins-can-now-enabledisable-186j</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.stormkit.io/docs/self-hosting/managing-users" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.stormkit.io%2Fstormkit-og-image.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.stormkit.io/docs/self-hosting/managing-users" rel="noopener noreferrer" class="c-link"&gt;
            Self-Hosting with Stormkit: User Management" - Stormkit
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Learn how to manage user access to your self-hosted Stormkit instance. Configure sign-up modes, whitelist domains, and approve or reject user registrations.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.stormkit.io%2Fstormkit-logo.png"&gt;
          stormkit.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




</description>
      <category>devops</category>
      <category>news</category>
      <category>security</category>
    </item>
    <item>
      <title>We’ve officially merged www-stormkit-io stormkit-io 🎉
All code, deployment logic, and (most importantly) the docs now live in a single repository. No more jumping between two repos: simpler maintenance, faster iterations, happier team.</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Fri, 28 Nov 2025 08:33:28 +0000</pubDate>
      <link>https://forem.com/svedova/weve-officially-merged-www-stormkit-io-stormkit-io-all-code-deployment-logic-and-most-3kja</link>
      <guid>https://forem.com/svedova/weve-officially-merged-www-stormkit-io-stormkit-io-all-code-deployment-logic-and-most-3kja</guid>
      <description></description>
    </item>
    <item>
      <title>Stormkit v1.24.0 is out 🚀

Lot's of small improvements, documentation updates, feature enhancements and windows support. 

https://github.com/stormkit-io/stormkit-io/blob/main/CHANGELOG.md</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Fri, 21 Nov 2025 16:54:04 +0000</pubDate>
      <link>https://forem.com/svedova/stormkit-v1240-is-out-lots-of-small-improvements-documentation-updates-feature-1pp1</link>
      <guid>https://forem.com/svedova/stormkit-v1240-is-out-lots-of-small-improvements-documentation-updates-feature-1pp1</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/stormkit-io/stormkit-io/blob/main/CHANGELOG.md" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopengraph.githubassets.com%2F3fac377b00c3fc58f86c40a6c48fb5fdffcb77d59f3d2130fc29cb3ad00e3bbb%2Fstormkit-io%2Fstormkit-io" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/stormkit-io/stormkit-io/blob/main/CHANGELOG.md" rel="noopener noreferrer" class="c-link"&gt;
            stormkit-io/CHANGELOG.md at main · stormkit-io/stormkit-io · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Self-hostable alternative to Vercel/Netlify. Deploy modern web apps with automated CI/CD, custom domains, and environment management. - stormkit-io/stormkit-io
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




</description>
      <category>news</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>I believe even small achievements deserve a proper celebration. 

Just a few days after announcing that Stormkit is now fully open-source, we’ve crossed 100 stars and contributions are already pouring in from brilliant minds 😍</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Mon, 10 Nov 2025 08:38:50 +0000</pubDate>
      <link>https://forem.com/svedova/i-believe-even-small-achievements-deserve-a-proper-celebration-just-a-few-days-after-2gb0</link>
      <guid>https://forem.com/svedova/i-believe-even-small-achievements-deserve-a-proper-celebration-just-a-few-days-after-2gb0</guid>
      <description></description>
      <category>news</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Big news 🚀 Stormkit is now fully open source! This has been my dream for so long, and now it came true! 

Here's the repo: https://github.com/stormkit-io/stormkit-io

Let's build together!</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Tue, 04 Nov 2025 06:35:43 +0000</pubDate>
      <link>https://forem.com/svedova/big-news-stormkit-is-now-fully-open-source-this-has-been-my-dream-for-so-long-and-now-it-came-1pgi</link>
      <guid>https://forem.com/svedova/big-news-stormkit-is-now-fully-open-source-this-has-been-my-dream-for-so-long-and-now-it-came-1pgi</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/stormkit-io/stormkit-io" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopengraph.githubassets.com%2F3fac377b00c3fc58f86c40a6c48fb5fdffcb77d59f3d2130fc29cb3ad00e3bbb%2Fstormkit-io%2Fstormkit-io" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/stormkit-io/stormkit-io" rel="noopener noreferrer" class="c-link"&gt;
            GitHub - stormkit-io/stormkit-io: Self-hostable alternative to Vercel/Netlify. Deploy modern web apps with automated CI/CD, custom domains, and environment management. · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Self-hostable alternative to Vercel/Netlify. Deploy modern web apps with automated CI/CD, custom domains, and environment management. - stormkit-io/stormkit-io
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




</description>
    </item>
    <item>
      <title>Docker Port Publishing: A Security Wake-Up Call 🚨</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Fri, 19 Sep 2025 11:55:56 +0000</pubDate>
      <link>https://forem.com/svedova/docker-port-publishing-a-security-wake-up-call-17kl</link>
      <guid>https://forem.com/svedova/docker-port-publishing-a-security-wake-up-call-17kl</guid>
      <description>&lt;p&gt;&lt;strong&gt;Did you know that &lt;code&gt;ports: "5432:5432"&lt;/code&gt; in your docker-compose.yml is exposing your database to the entire internet?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I see this mistake constantly in production environments. Here's what's actually happening:&lt;/p&gt;

&lt;p&gt;❌ &lt;strong&gt;What you think you're doing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&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;5432:5432"&lt;/span&gt;  &lt;span class="c1"&gt;# "Just making it accessible to my app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🌍 &lt;strong&gt;What you're actually doing:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Binding port 5432 to &lt;code&gt;0.0.0.0:5432&lt;/code&gt; - making your database accessible from ANY IP address that can reach your server.&lt;/p&gt;

&lt;p&gt;✅ &lt;strong&gt;Here's how to fix it:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: Bind to localhost only&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5432:5432"&lt;/span&gt;  &lt;span class="c1"&gt;# Only accessible from the host machine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option 2: Use Docker networks (recommended)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# No ports section needed!&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&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="s"&gt;app-network&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&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="s"&gt;app-network&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:3000"&lt;/span&gt;  &lt;span class="c1"&gt;# Only expose what users need&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;app-network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔐 &lt;strong&gt;Pro tip:&lt;/strong&gt; Your application containers can communicate with each other using service names as hostnames within the same network. No port publishing required!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The golden rule:&lt;/strong&gt; Only publish ports that external clients need to access directly.&lt;/p&gt;

&lt;p&gt;Have you caught this security issue in your own Docker setups? Share your Docker security tips in the comments! 👇&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Node.js Hosting: DIY Flexibility vs. Stormkit’s Streamlined Power</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Thu, 24 Jul 2025 13:51:54 +0000</pubDate>
      <link>https://forem.com/stormkit/nodejs-hosting-diy-flexibility-vs-stormkits-streamlined-power-5bkj</link>
      <guid>https://forem.com/stormkit/nodejs-hosting-diy-flexibility-vs-stormkits-streamlined-power-5bkj</guid>
      <description>&lt;p&gt;When it comes to hosting Node.js applications, you’re faced with a choice: build and manage your own infrastructure or use an orchestrator like &lt;a href="https://www.stormkit.io" rel="noopener noreferrer"&gt;Stormkit&lt;/a&gt;, purpose-built to scale and simplify, especially for managing multiple apps or hundreds of long-lived processes. While DIY hosting offers control, Stormkit’s feature set makes it a compelling choice for developers looking to optimize resources and streamline workflows. &lt;/p&gt;

&lt;p&gt;Let’s break down the trade-offs, highlighting Stormkit’s unique strengths and acknowledging the realities of self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  DIY Hosting: Control at a Cost
&lt;/h2&gt;

&lt;p&gt;Running your own Node.js setup, with a process manager like PM2, or systemd, gives you full control over your environment. For small projects or developers who enjoy fine-tuning servers, this can be a great fit. But as you scale to multiple apps or hundreds of processes, the DIY approach shows its limits.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scaling Complexity:&lt;/strong&gt; Managing traffic spikes across multiple apps means manually provisioning servers, setting up load balancers, and ensuring uptime. This can lead to over-provisioning, where you’re paying for idle servers, or under-provisioning, risking crashes during surges.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Maintenance Burden:&lt;/strong&gt; You’re responsible for server updates, Node version upgrades, and monitoring resource usage. Juggling multiple apps amplifies this, as each may have different runtime or dependency needs, leading to configuration sprawl.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security Overhead:&lt;/strong&gt; Securing multiple apps requires configuring firewalls, SSL certificates, and staying vigilant about vulnerabilities. A single misstep can expose your apps, and keeping everything patched is a time sink.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deployment Friction:&lt;/strong&gt; Rolling out updates across multiple apps often involves manual restarts, risking downtime. Testing new versions or rolling back failed deployments can be clunky without a streamlined system.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost Inefficiency:&lt;/strong&gt; A small VPS is cheap, but scaling multiple apps across servers, plus adding redundancy and monitoring, drives up costs. Your time spent managing servers is another hidden expense.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DIY hosting is ideal for small, single-app projects or when you need highly specific configurations. If you’ve got the skills and time, it’s a hands-on way to learn and save on small-scale costs. But for managing multiple Node.js apps at scale, the overhead can pull you away from coding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stormkit: Optimized for Scale and Simplicity
&lt;/h2&gt;

&lt;p&gt;Stormkit is designed to handle Node.js applications at scale, especially when you’re managing multiple apps or long-lived processes. Its feature set addresses the pain points of DIY hosting, though self-hosting Stormkit means you still manage the underlying servers. Here’s why Stormkit stands out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource-Efficient Orchestration:&lt;/strong&gt; Stormkit’s on-demand spin-up and spin-down mechanism optimizes server usage. Instead of running multiple machines for each app, Stormkit orchestrates processes across fewer instances, dynamically allocating resources. What might take many servers in a traditional setup can often be handled with just a few, saving costs and reducing waste.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Insightful Analytics:&lt;/strong&gt; Stormkit provides a dashboard with stats on your most-visited and most-deployed apps. This quick overview helps you prioritize optimization and understand usage patterns, something DIY setups require custom tooling to achieve.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flexible Runtime Management:&lt;/strong&gt; With Stormkit, you can manage Node runtimes via a .node-version file, supporting multiple versions across apps. This eliminates the hassle of manually installing and switching runtimes on servers, a common DIY headache.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Seamless Deployments:&lt;/strong&gt; Deploying with Stormkit is as simple as a git push, with zero-downtime updates and instant rollbacks if something goes wrong. Each deployment gets a unique URL for testing, and you can add an auth wall to secure preview environments—features that require significant DIY effort to replicate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Self-Hosting Maintenance:&lt;/strong&gt; Since Stormkit is self-hosted, you’re responsible for maintaining the servers it runs on—patching the OS, monitoring hardware, and ensuring uptime. While Stormkit handles app-level orchestration, the server-level maintenance remains your responsibility.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway: Match Your Needs
&lt;/h2&gt;

&lt;p&gt;DIY hosting offers unparalleled control and is great for small projects or developers who love hands-on server work. It’s cost-effective for single apps and lets you tailor every detail, but scaling multiple apps or processes can lead to complexity, inefficiency, and maintenance overload.&lt;/p&gt;

&lt;p&gt;Stormkit flips this by streamlining orchestration, deployments, and runtime management, letting you achieve more with fewer servers. Its analytics, unique deployment URLs, and auth walls make managing multiple apps a breeze, though you’ll still need to handle server maintenance for self-hosting. If you’re focused on coding and scaling efficiently, Stormkit’s purpose-built features are a game-changer. If you have unique server needs or like to be hands-on, DIY might still be your path. &lt;/p&gt;

&lt;p&gt;Either way, keep your focus on building great apps 🚀&lt;/p&gt;

</description>
      <category>node</category>
      <category>devops</category>
      <category>cicd</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Run Automated E2E Tests with Stormkit and Browserless</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Mon, 14 Jul 2025 12:17:46 +0000</pubDate>
      <link>https://forem.com/svedova/how-to-run-automated-e2e-tests-with-stormkit-and-browserless-2ne7</link>
      <guid>https://forem.com/svedova/how-to-run-automated-e2e-tests-with-stormkit-and-browserless-2ne7</guid>
      <description>&lt;p&gt;Running automated browser tests is crucial for maintaining application quality, but managing browser instances can be resource-intensive. This tutorial shows you how to set up scalable automated testing using Stormkit's self-hosted solution with &lt;a href="https://www.browserless.io/" rel="noopener noreferrer"&gt;Browserless&lt;/a&gt; for efficient browser management.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial, you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A self-hosted Stormkit instance with Browserless integration&lt;/li&gt;
&lt;li&gt;Automated Playwright tests running in a scalable browser environment&lt;/li&gt;
&lt;li&gt;Continuous testing on every deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Virtual Private Server with SSH access&lt;/li&gt;
&lt;li&gt;Basic knowledge of React and Node.js&lt;/li&gt;
&lt;li&gt;Familiarity with Playwright testing framework&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Setting Up Stormkit
&lt;/h2&gt;

&lt;p&gt;First, let's install Stormkit using the single-liner installation script:&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;-sSL&lt;/span&gt; https://www.stormkit.io/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This script will set up the basic Stormkit infrastructure on your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Configure Docker Compose with Browserless
&lt;/h2&gt;

&lt;p&gt;Next, we need to update the &lt;code&gt;docker-compose.yaml&lt;/code&gt; file that comes with Stormkit installation, to include Browserless alongside Stormkit. This provides us with a scalable browser environment for our tests.&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="c1"&gt;# ... db, redis, workerserver, hosting services&lt;/span&gt;

  &lt;span class="na"&gt;browserless&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/browserless/chromium&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;always&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="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start your services:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Set Up the React Application
&lt;/h2&gt;

&lt;p&gt;Now let's create our React application locally to test our setup.&lt;/p&gt;

&lt;p&gt;We can use the &lt;code&gt;react-starter&lt;/code&gt; example for this tutorial.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/stormkit-io/react-starter.git
&lt;span class="nb"&gt;cd &lt;/span&gt;react-starter
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Install Playwright Core
&lt;/h3&gt;

&lt;p&gt;Since we're using Browserless for browser management, we only need the core Playwright library without the full testing framework. We will also need &lt;code&gt;tsx&lt;/code&gt; to run Typescript code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;playwright-core tsx &lt;span class="nt"&gt;--save-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Create Your Playwright Tests
&lt;/h2&gt;

&lt;p&gt;Create a &lt;code&gt;tests&lt;/code&gt; directory and add your test files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a basic test file &lt;code&gt;tests/app.spec.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Connect to the remote browser via Browserless&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAYWRIGHT_WS_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ws://localhost:3000/chromium/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SK_DEPLOYMENT_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ssr`&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Navigate to your application&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Test your application&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Page title:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to fetch API health endpoint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API health status:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;✅ All tests passed!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;❌ Test failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Run the test&lt;/span&gt;
&lt;span class="p"&gt;;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// This is important as it tells Stormkit that the process failed&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Add Test Script to Package.json
&lt;/h3&gt;

&lt;p&gt;Add the E2E test script to your &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"test:e2e"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx tests/app.spec.ts"&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;You can now run your E2E tests with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run &lt;span class="nb"&gt;test&lt;/span&gt;:e2e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Push to Repository
&lt;/h3&gt;

&lt;p&gt;Now let's push our test-enabled application to a public repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove existing origin&lt;/span&gt;
git remote remove origin

&lt;span class="c"&gt;# Add your new repository&lt;/span&gt;
git remote add origin git@github.com:your-repository

&lt;span class="c"&gt;# Push to the new repository&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add automated E2E testing with Browserless integration"&lt;/span&gt;
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once pushed, you can connect this repository to your Stormkit instance for automated deployments and testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Configure Environment Variables in Stormkit
&lt;/h2&gt;

&lt;p&gt;Now we need to configure Stormkit to use the Browserless endpoint for our tests.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Import from URL&lt;/strong&gt;: In your Stormkit dashboard, click "Import from URL" and paste your repository URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvfm6vgzagb1t8o1cde4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvfm6vgzagb1t8o1cde4.png" alt="stormkit.116-203-180-254.sslip.io_browserless.png" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set Environment Variables&lt;/strong&gt;: Navigate to Environment &amp;gt; Config &amp;gt; Environment Variables and add:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Key: &lt;code&gt;PLAYWRIGHT_WS_ENDPOINT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Value: &lt;code&gt;ws://browserless:3000/chromium/playwright&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set a status check:&lt;/strong&gt; Scroll down to Status Checks and provide the script we prepared above: &lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;npm run test:e2e&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgds7z06y5dg9l9luwrnl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgds7z06y5dg9l9luwrnl.png" alt="stormkit.116-203-180-254.sslip.io_apps_7_environments_7.png" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: Click the "Deploy" button to start your first deployment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkttd93wp29ejbzcw5xrl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkttd93wp29ejbzcw5xrl.png" alt="stormkit.116-203-180-254.sslip.io_apps_7_environments_7_deployments_110.png" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens Behind the Scenes
&lt;/h2&gt;

&lt;p&gt;When you trigger a deployment, Stormkit orchestrates several automated steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build and Deploy&lt;/strong&gt;: Stormkit builds your application from the repository and deploys it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment Preview&lt;/strong&gt;: A unique deployment endpoint is created and passed to your application via the &lt;code&gt;SK_DEPLOYMENT_URL&lt;/code&gt; environment variable (which our test uses to know where to connect)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status Checks&lt;/strong&gt;: After deployment completes, Stormkit automatically runs the configured status checks (&lt;code&gt;npm run test:e2e&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation&lt;/strong&gt;: The status checks verify that your application loads correctly, required elements are present, and functionality works as expected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production Promotion&lt;/strong&gt;: If all tests pass, Stormkit automatically promotes this deployment to production, making it live for your users&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This automated pipeline ensures that only validated, working deployments reach production, giving you confidence in your releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You now have a fully functional automated testing setup with Stormkit and Browserless! This configuration provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scalable browser testing with Browserless&lt;/li&gt;
&lt;li&gt;Automated test execution on deployments&lt;/li&gt;
&lt;li&gt;Comprehensive test coverage for both UI and API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination of Stormkit's deployment platform with Browserless's browser automation creates a powerful testing environment that can scale with your application needs.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Hosting 300+ Websites - A Technical Deep Dive</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Wed, 21 May 2025 16:18:29 +0000</pubDate>
      <link>https://forem.com/stormkit/hosting-300-websites-a-technical-deep-dive-5248</link>
      <guid>https://forem.com/stormkit/hosting-300-websites-a-technical-deep-dive-5248</guid>
      <description>&lt;p&gt;It’s entirely feasible to host over 300 websites on a lean setup of just a few machines, and Stormkit makes it happen with a smart, scalable configuration. This post walks through the technical details how the infrastructure is set up, what it costs, how reliable it is, and what it takes to manage. Let’s dive in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Setup
&lt;/h2&gt;

&lt;p&gt;The foundation is a compact yet powerful hardware and software stack designed to handle hundreds of sites efficiently. These websites generate millions of requests per month, so reliability is a core concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machines&lt;/strong&gt;: The production setup runs on 3 servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specs&lt;/strong&gt;: 2 vCPUs, 4 GiB RAM, SSD storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Provider&lt;/strong&gt;: Alibaba Cloud, chosen for its compliance with local regulations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Additional Resources
&lt;/h2&gt;

&lt;p&gt;To support the hosting environment, we’ve added a few key components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network Load Balancer (NLB):&lt;/strong&gt; A Layer 4 load balancer ensures TLS termination happens at the source, enabling automatic TLS certificate issuance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis Instance:&lt;/strong&gt; A Tair Redis OSS-compatible instance handles caching, keeps TLS certificates and acts as a message queue for storing logs and analytics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL Database:&lt;/strong&gt; Apsara DB RDS provides a reliable and managed database solution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging Machine:&lt;/strong&gt; A single server mirrors the production environment for testing and experimentation, keeping costs minimal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deployments
&lt;/h2&gt;

&lt;p&gt;Frequent deployments are a hallmark of modern development workflows. Here’s how we’ve streamlined the process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions Integration:&lt;/strong&gt; Instead of scaling up worker servers, we offload CI/CD pipelines to GitHub Actions, which integrates seamlessly with Stormkit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File Storage:&lt;/strong&gt; All static files are stored and served from Alibaba OSS (S3-compatible object storage), ensuring fast and reliable delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Software Configuration
&lt;/h2&gt;

&lt;p&gt;Managing 300+ websites might sound daunting, but with the right tools, it’s surprisingly straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo Architecture:&lt;/strong&gt; All websites are managed in a single monorepo, simplifying code management and updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stormkit Snippets:&lt;/strong&gt; Each website is customized using Stormkit’s Snippets feature. Conditional rules based on &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;path&lt;/code&gt; allow for unique configurations, ensuring every site has its own look and functionality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single Application, One Environment:&lt;/strong&gt; All domains are assigned to a single Stormkit environment, reducing complexity and overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Networking
&lt;/h2&gt;

&lt;p&gt;Since the client primarily serves regional customers, global distribution wasn’t a priority. All resources are hosted in a single region, optimizing for simplicity and cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;p&gt;Here’s how the numbers stack up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alibaba Cloud Resources:&lt;/strong&gt; 4 servers, 1 database, 1 Redis instance, 1 NLB and around 300GB file storage cost approximately &lt;strong&gt;$1000/month&lt;/strong&gt; with subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployments:&lt;/strong&gt; GitHub Actions is included in the client’s paid tier, so there’s no additional cost for CI/CD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stormkit Services:&lt;/strong&gt; $500/month for infrastructure setup, service updates, monitoring, feature development, enterprise features and direct Slack support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total Monthly Cost:&lt;/strong&gt; Around &lt;strong&gt;$1500&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cost Per Website:&lt;/strong&gt; Roughly &lt;strong&gt;$5 per site&lt;/strong&gt;—a fraction of traditional hosting costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability and Downtime
&lt;/h2&gt;

&lt;p&gt;When hosting hundreds of websites, uptime is non-negotiable. Here’s how this setup performs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Downtime:&lt;/strong&gt; Virtually zero downtime over the last six months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource Usage:&lt;/strong&gt; Hosting machines maintain a CPU load of 0.8–1.9 and average memory consumption of 24%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Efficiency:&lt;/strong&gt; With Stormkit’s monorepo and API automation, updates roll out to all sites quickly, minimizing manual effort.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;This setup demonstrates that hosting 300+ websites doesn’t require a sprawling infrastructure or a massive budget. With the right tools, a lean team can manage a large-scale operation efficiently.&lt;/p&gt;

&lt;p&gt;Whether you’re managing a handful of sites or hundreds, this approach proves that scalability and cost-efficiency can go hand in hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need Help with Your Infrastructure?
&lt;/h2&gt;

&lt;p&gt;If you’re looking to optimize your infrastructure or streamline your deployment process, Stormkit can help. Our platform supports deployments for JavaScript (frontend and backend), Go, and Rust applications, offering unparalleled flexibility and control.&lt;/p&gt;

&lt;p&gt;Contact us today to learn how Stormkit can transform your hosting and deployment workflows. &lt;a href="https://www.stormkit.io/enterprise" rel="noopener noreferrer"&gt;Get in touch with us&lt;/a&gt; for more details.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating your app from Webpack to Vite</title>
      <dc:creator>Savas Vedova</dc:creator>
      <pubDate>Mon, 29 Aug 2022 09:49:00 +0000</pubDate>
      <link>https://forem.com/stormkit/migrating-stormkit-from-webpack-to-vite-5429</link>
      <guid>https://forem.com/stormkit/migrating-stormkit-from-webpack-to-vite-5429</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;I recently migrated &lt;a href="https://stormkit.io" rel="noopener noreferrer"&gt;Stormkit&lt;/a&gt; from Webpack to Vite and I wanted to share the changes because I had to follow a trial and error methodology to migrate it due to lack of documentation. &lt;/p&gt;

&lt;p&gt;For those who'd like to see directly the code, &lt;a href="https://github.com/stormkit-io/app-stormkit-io/pull/406/files#diff-6a3b01ba97829c9566ef2d8dc466ffcffb4bdac08706d3d6319e42e0aa6890dd" rel="noopener noreferrer"&gt;here are the changes&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;First thing you should know is that Vite uses &lt;code&gt;esbuild&lt;/code&gt; for development and &lt;code&gt;rollup&lt;/code&gt; for production. So while your development environment is working, your production build may fail. That was indeed the case for me and I'll explain in a moment why it happened. &lt;/p&gt;

&lt;p&gt;Let's take a look to the configuration options used in vite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Although this is called root, &lt;/span&gt;
    &lt;span class="c1"&gt;// in our case this is "src", &lt;/span&gt;
    &lt;span class="c1"&gt;// where our application logic is located. &lt;/span&gt;
    &lt;span class="c1"&gt;// All configuration options defined &lt;/span&gt;
    &lt;span class="c1"&gt;// afterwards will be relative to this endpoint.&lt;/span&gt;
    &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// This is where our assets are served from. &lt;/span&gt;
    &lt;span class="c1"&gt;// In webpack, this was "output.publicPath".&lt;/span&gt;
    &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// This is similar to Webpack's DefinePlugin. &lt;/span&gt;
    &lt;span class="c1"&gt;// It is used to inject constants in your application.&lt;/span&gt;
    &lt;span class="na"&gt;define&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
        &lt;span class="c1"&gt;// Using the following configuration made our build fail as &lt;/span&gt;
        &lt;span class="c1"&gt;// mentioned above. I had to change a couple of `global` usages &lt;/span&gt;
        &lt;span class="c1"&gt;// and remove this configuration to make them work.&lt;/span&gt;
        &lt;span class="c1"&gt;// global: { }&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nl"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// We need https for various 3rd party tools used &lt;/span&gt;
      &lt;span class="c1"&gt;// in local environment. It is similar to &lt;/span&gt;
      &lt;span class="c1"&gt;// Webpack Dev Server's 'server: "https"' config.&lt;/span&gt;
      &lt;span class="c1"&gt;// We require a certificate for this to work and that is &lt;/span&gt;
      &lt;span class="c1"&gt;// achieved through the 'basicSsl' plugin.&lt;/span&gt;
      &lt;span class="c1"&gt;// You can check the actual code to see which package is that.&lt;/span&gt;
      &lt;span class="na"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

      &lt;span class="c1"&gt;// We are proxying all requests from `/api` to our api server. &lt;/span&gt;
      &lt;span class="c1"&gt;// This config is very similar to Webpack Dev Server's proxy config. &lt;/span&gt;
      &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="c1"&gt;// This is almost equivalent to Webpack's "resolve" config. &lt;/span&gt;
  &lt;span class="c1"&gt;// It is used to import files using aliases.&lt;/span&gt;
  &lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="c1"&gt;// Assets under this folder will be copied to the dist folder. &lt;/span&gt;
  &lt;span class="c1"&gt;// We used to achieve the same functionality using &lt;/span&gt;
  &lt;span class="c1"&gt;// the "copy-webpack-plugin" package.&lt;/span&gt;
  &lt;span class="c1"&gt;// Do not forget that this is relative to the "root" path configured above.&lt;/span&gt;
  &lt;span class="na"&gt;publicDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;// List of plugins used to make various technologies work.&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
     &lt;span class="c1"&gt;// used to add https to local environment&lt;/span&gt;
     &lt;span class="nf"&gt;basicSsl&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
     &lt;span class="c1"&gt;// used to include our bundles inside the html file&lt;/span&gt;
     &lt;span class="nf"&gt;createHtmlPlugin&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
     &lt;span class="c1"&gt;// used to import svgs&lt;/span&gt;
     &lt;span class="nf"&gt;svgr&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
     &lt;span class="c1"&gt;// used to make react work with vite&lt;/span&gt;
     &lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Specify the dist folder&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="c1"&gt;// Loading a file was failing and adding this configuration&lt;/span&gt;
  &lt;span class="c1"&gt;// fixed it. It basically replaces top-level "this" usage&lt;/span&gt;
  &lt;span class="c1"&gt;// with the "window" object.&lt;/span&gt;
  &lt;span class="na"&gt;esbuild&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;define&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
      &lt;span class="na"&gt;this&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;window&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We do use Tailwind and Material UI in our project. A simple &lt;code&gt;postcss.config.js&lt;/code&gt; was all that I needed to make Tailwind work properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tailwindcss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;autoprefixer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rationale behind the migration
&lt;/h2&gt;

&lt;p&gt;This definitely adds no direct value for the end user. So why did we migrate? &lt;/p&gt;

&lt;p&gt;The main reason is because we'll make a lot of changes to the frontend soon as we're releasing a V2 for Stormkit. When I realised that the HMR was broken and I heard that Vite is extremely fast for development I decided to give it a try. Turns out, this was a pretty good decision and it will also add value to the end user because it'll increase our development speed. &lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The build time seems to be a bit slower than our previous configuration. This is mainly because we used to use &lt;code&gt;esbuild&lt;/code&gt;, which is extremely fast, for production builds but &lt;code&gt;vite&lt;/code&gt; uses &lt;code&gt;rollup&lt;/code&gt;. However, to be honest, given the increase in the HMR, we're willing to sacrifice a few seconds during production builds. &lt;/li&gt;
&lt;li&gt;Whereas the HMR is extremely fast. When I make a change in the UI and switch to the browser to visualize it, I used to wait a few seconds till those changes were propagated. Now, by the time I switch the Browser tab, the changes are already there. This is fantastic! &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hope this mini tutorial was helpful to understand what is needed to migrate from Webpack to Vite. Stormkit is can be considered as a large application and the migration was possible through 1 PR with almost no breaking changes. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt; This article is also published on our &lt;a href="https://www.stormkit.io/blog/migrating-your-app-from-webpack-to-vite" rel="noopener noreferrer"&gt;blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
