<?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: Zil Norvilis</title>
    <description>The latest articles on DEV Community by Zil Norvilis (@zilton7).</description>
    <link>https://dev.to/zilton7</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%2F352433%2F2aff65b6-dba1-4f8c-8aaf-0bc84763ae23.jpg</url>
      <title>DEV Community: Zil Norvilis</title>
      <link>https://dev.to/zilton7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zilton7"/>
    <language>en</language>
    <item>
      <title>Why I Stopped Using Stripe: The Case for Merchant of Records (MoR)</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 13 May 2026 23:23:37 +0000</pubDate>
      <link>https://dev.to/zilton7/why-i-stopped-using-stripe-the-case-for-merchant-of-records-mor-3o2g</link>
      <guid>https://dev.to/zilton7/why-i-stopped-using-stripe-the-case-for-merchant-of-records-mor-3o2g</guid>
      <description>&lt;p&gt;If you are building a SaaS in 2026, the default advice is always the same: &lt;em&gt;"Just plug in Stripe and launch."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stripe is an incredible piece of engineering. Their APIs are flawless, their documentation is the gold standard of the internet, and integrating Stripe Checkout into a Rails app takes about ten minutes. &lt;/p&gt;

&lt;p&gt;For a long time, I used Stripe for everything. But as my side projects started actually making money, a dark, terrifying reality crept in: &lt;strong&gt;Global Tax Compliance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a solo developer based in Europe, I realized that taking payments globally isn't just about moving money from Point A to Point B. It’s a legal minefield. This is why I entirely stopped using Stripe for my B2C (Business to Consumer) SaaS projects and switched to using a &lt;strong&gt;Merchant of Record (MoR)&lt;/strong&gt; like Paddle or Lemon Squeezy.&lt;/p&gt;

&lt;p&gt;Here is the exact difference, and why it matters to your sanity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: The "Tax Nexus" Nightmare
&lt;/h2&gt;

&lt;p&gt;When you use Stripe, Stripe is simply a &lt;strong&gt;Payment Processor&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;They take the credit card, they verify the funds, and they put the money in your bank account. That’s it. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legally, YOU are the one selling the software to the customer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why is this a problem? Let's say you sell a $10/month subscription.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  A user in Germany buys it. You owe 19% VAT to Germany.&lt;/li&gt;
&lt;li&gt;  A user in the UK buys it. You owe 20% VAT to the UK.&lt;/li&gt;
&lt;li&gt;  A user in Texas buys it. You owe Texas state sales tax.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Suddenly, because you sold software globally, you are legally required to register for taxes in multiple different countries, calculate the exact rates based on the buyer's IP address, collect the tax, and remit it to foreign governments quarterly. &lt;/p&gt;

&lt;p&gt;If you are a One-Person Team, trying to figure out the tax laws of 50 different countries will consume your entire life. You will spend more time doing accounting than writing Ruby code. (Yes, Stripe Tax exists, but it only &lt;em&gt;calculates&lt;/em&gt; the tax; you still have to file the paperwork yourself).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: The Merchant of Record (MoR)
&lt;/h2&gt;

&lt;p&gt;A Merchant of Record (like &lt;strong&gt;Paddle&lt;/strong&gt; or &lt;strong&gt;Lemon Squeezy&lt;/strong&gt;) works entirely differently.&lt;/p&gt;

&lt;p&gt;When you use an MoR, the legal flow changes. &lt;br&gt;
&lt;strong&gt;You sell your software to the MoR. The MoR sells the software to the customer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because Paddle is the one officially selling the product to the user in Germany, Paddle is legally responsible for calculating, collecting, and remitting the 19% VAT to the German government. &lt;/p&gt;

&lt;p&gt;For you, the developer, the nightmare is over. At the end of the month, Paddle sends you one single payout. As far as your local tax authority is concerned, you only made one B2B sale that month: you sold your services to Paddle (a UK company). &lt;/p&gt;
&lt;h2&gt;
  
  
  The Trade-Offs
&lt;/h2&gt;

&lt;p&gt;Switching to an MoR feels like a magic bullet, but it does come with trade-offs you need to understand.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. The Fees are Higher
&lt;/h3&gt;

&lt;p&gt;Because an MoR handles international taxes, handles chargeback disputes, and assumes legal liability, they charge more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Stripe:&lt;/strong&gt; ~2.9% + 30¢ per transaction.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Paddle / Lemon Squeezy:&lt;/strong&gt; ~5% + 50¢ per transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you are starting out, giving up 5% feels painful. But ask yourself: how much is an international tax accountant going to cost you? Usually, the 2% difference is much cheaper than hiring a professional to file VAT returns in the EU.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The Integration is Slightly Different
&lt;/h3&gt;

&lt;p&gt;Integrating an MoR into Rails is conceptually similar to Stripe, but slightly less "native". You don't use a massive Ruby gem. &lt;/p&gt;

&lt;p&gt;Usually, you drop their Javascript snippet onto your pricing page to trigger a checkout overlay. Then, just like Stripe, you set up a webhook endpoint in your Rails app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/webhooks/paddle_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Webhooks::PaddleController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="n"&gt;skip_before_action&lt;/span&gt; &lt;span class="ss"&gt;:verify_authenticity_token&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="c1"&gt;# Paddle sends a signature we must verify&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;valid_paddle_signature?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:alert_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s1"&gt;'subscription_payment_succeeded'&lt;/span&gt;
        &lt;span class="c1"&gt;# Upgrade the user!&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;pro: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:unauthorized&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is very straightforward, but you will be relying more on raw webhook handling rather than a polished library.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Less Control Over the Checkout
&lt;/h3&gt;

&lt;p&gt;With Stripe Elements, you can design a checkout flow that perfectly matches your app's brand. With an MoR, you are generally forced to use their hosted checkout pages or standard popups. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;If you are a US-based developer selling exclusively to other US businesses (B2B), Stripe is still the undisputed king.&lt;/p&gt;

&lt;p&gt;But if you are a solo developer (especially in Europe) selling B2C products globally, the math changes. Your goal is to write code and build a great product, not to become an expert in the European Union's digital VAT laws.&lt;/p&gt;

&lt;p&gt;Giving up an extra 2% of your revenue to a Merchant of Record like Paddle or Lemon Squeezy is the best "DevOps" investment you can make. It buys you total peace of mind.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Paying for Vector Databases: How to Build AI Search in Postgres</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 13 May 2026 23:07:00 +0000</pubDate>
      <link>https://dev.to/zilton7/stop-paying-for-vector-databases-how-to-build-ai-search-in-postgres-51pl</link>
      <guid>https://dev.to/zilton7/stop-paying-for-vector-databases-how-to-build-ai-search-in-postgres-51pl</guid>
      <description>&lt;p&gt;I see developers trying to build "AI Chatbots" that know about their specific company data. They want the AI to read their PDFs, their internal wikis, or their past customer support tickets, and answer questions based on that data. &lt;/p&gt;

&lt;p&gt;This technique is called &lt;strong&gt;RAG&lt;/strong&gt; (Retrieval-Augmented Generation).&lt;/p&gt;

&lt;p&gt;When the AI hype first started, developers thought they had to pay for expensive, dedicated "Vector Databases" like Pinecone or Milvus to do this. They added a massive layer of complexity to their stack just to store some AI data.&lt;/p&gt;

&lt;p&gt;In 2026, the Rails way to do this is much simpler. &lt;strong&gt;You just use PostgreSQL.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By using the &lt;code&gt;pgvector&lt;/code&gt; extension and a brilliant Ruby gem called &lt;code&gt;neighbor&lt;/code&gt;, you can keep all your AI data perfectly synced inside your standard Rails database. You get the power of RAG without leaving the comfort of ActiveRecord.&lt;/p&gt;

&lt;p&gt;Here is exactly how to build "Chat with your Database" in 4 steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: What are Embeddings?
&lt;/h2&gt;

&lt;p&gt;Before we code, you need to understand how AI "searches" text. &lt;/p&gt;

&lt;p&gt;AI does not read words; it reads math. When you send a paragraph of text to an AI (like OpenAI's embedding model), it returns an &lt;strong&gt;Embedding&lt;/strong&gt; - a massive array of 1,536 numbers. &lt;/p&gt;

&lt;p&gt;Think of this array as a set of coordinates on a map. Paragraphs that talk about similar things are placed closer together on this map. To search for an answer, we turn the user's question into coordinates, and ask the database: &lt;em&gt;"Which paragraphs are physically closest to this question on the map?"&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Database Setup
&lt;/h2&gt;

&lt;p&gt;First, we need to tell PostgreSQL that it is allowed to store these massive arrays of numbers. We do this by enabling the &lt;code&gt;vector&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;Add the gems to your &lt;code&gt;Gemfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'ruby-openai'&lt;/span&gt; &lt;span class="c1"&gt;# To talk to ChatGPT&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'neighbor'&lt;/span&gt;    &lt;span class="c1"&gt;# To add vector search to ActiveRecord&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;bundle install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next, generate a migration to enable the extension and add a vector column to the table we want to search (let's use a &lt;code&gt;Document&lt;/code&gt; model).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails g migration AddEmbeddingsToDocuments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# db/migrate/20260506120000_add_embeddings_to_documents.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddEmbeddingsToDocuments&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Enable the Postgres extension&lt;/span&gt;
    &lt;span class="n"&gt;enable_extension&lt;/span&gt; &lt;span class="s2"&gt;"vector"&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Add the column. OpenAI's standard models output 1536 dimensions.&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;limit: &lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;rails db:migrate&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Now, open your model and tell the &lt;code&gt;neighbor&lt;/code&gt; gem to track that column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/document.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_neighbors&lt;/span&gt; &lt;span class="ss"&gt;:embedding&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: Generating the Embeddings
&lt;/h2&gt;

&lt;p&gt;When a user creates a new Document in your app, we need to turn its text into an embedding and save it to the database. &lt;em&gt;(Note: Because API calls are slow, you should do this in a Solid Queue background job!)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/embedding_service.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmbeddingService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"text-embedding-3-small"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;input: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Extract the array of 1536 floats&lt;/span&gt;
    &lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Save it directly to our Postgres column&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;embedding: &lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 3: The Vector Search (Finding the Context)
&lt;/h2&gt;

&lt;p&gt;Now for the magic. A user asks a question: &lt;em&gt;"What is our company's refund policy?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First, we must turn their question into a vector using the exact same OpenAI model. Then, we use the &lt;code&gt;neighbor&lt;/code&gt; gem's &lt;code&gt;.nearest_neighbors&lt;/code&gt; method to search Postgres.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/rag_search_service.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RagSearchService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Turn the question into coordinates&lt;/span&gt;
    &lt;span class="n"&gt;question_vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"text-embedding-3-small"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;input: &lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Ask Postgres to find the 3 closest documents&lt;/span&gt;
    &lt;span class="c1"&gt;# "inner_product" is the fastest distance metric for OpenAI embeddings&lt;/span&gt;
    &lt;span class="n"&gt;relevant_docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nearest_neighbors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;distance: &lt;/span&gt;&lt;span class="s2"&gt;"inner_product"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;relevant_docs&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of the &lt;code&gt;neighbor&lt;/code&gt; gem, searching vectors feels exactly like a standard ActiveRecord query!&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 4: The RAG Prompt
&lt;/h2&gt;

&lt;p&gt;We have the user's question, and we have the 3 documents that contain the answer. Now, we just smash them together into one giant prompt and send it to ChatGPT to generate a human-sounding response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/chats_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;user_question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:question&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Get the relevant data from Postgres&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RagSearchService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Build the context string&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Build the RAG Prompt&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;~&lt;/span&gt;&lt;span class="no"&gt;PROMPT&lt;/span&gt;&lt;span class="sh"&gt;
      You are a helpful company assistant. Answer the user's question 
      using ONLY the context provided below. If the answer is not in 
      the context, say "I don't know."

      CONTEXT:
      &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;    PROMPT&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Ask the AI&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;user_question&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="vi"&gt;@answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Render your Hotwire view here...&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The entire multi-billion dollar "RAG" industry boils down to this incredibly simple pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Text -&amp;gt; OpenAI -&amp;gt; Numbers (Saved in Postgres).&lt;/li&gt;
&lt;li&gt;Question -&amp;gt; OpenAI -&amp;gt; Numbers.&lt;/li&gt;
&lt;li&gt;Find closest Numbers in Postgres using &lt;code&gt;neighbor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send Question + Found Text -&amp;gt; OpenAI -&amp;gt; Final Answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By leveraging &lt;code&gt;pgvector&lt;/code&gt; and ActiveRecord, we avoid adding a completely new piece of infrastructure to our stack. Your AI data lives right next to your user data, it is backed up together, and it is queried using the same Ruby syntax you already know and love.&lt;/p&gt;

&lt;p&gt;The "One Person Framework" strikes again.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ai</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Background AI: Using Solid Queue for Slow OpenAI API Calls</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Tue, 12 May 2026 23:07:01 +0000</pubDate>
      <link>https://dev.to/zilton7/background-ai-using-solid-queue-for-slow-openai-api-calls-31de</link>
      <guid>https://dev.to/zilton7/background-ai-using-solid-queue-for-slow-openai-api-calls-31de</guid>
      <description>&lt;p&gt;Very often I see developers integrating AI into their Rails apps for the first time, and they make a critical mistake that completely destroys their server performance.&lt;/p&gt;

&lt;p&gt;They treat the OpenAI (or Anthropic) API like a regular database query. They put the API call directly inside their controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The "Server Killer" Approach&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="c1"&gt;# This might take 15 seconds!&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;summary: &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you do this, the Puma web thread handling that user's request &lt;strong&gt;freezes&lt;/strong&gt;. It sits there doing absolutely nothing for 15 seconds while it waits for the AI to respond. If you have 5 users asking for summaries at the same time, your entire server will lock up. No one else will be able to load your website. The browser might even time out and show an error page.&lt;/p&gt;

&lt;p&gt;AI calls are slow. You &lt;strong&gt;must&lt;/strong&gt; put them in the background.&lt;/p&gt;

&lt;p&gt;In 2026, Rails 8 makes this ridiculously easy because we have &lt;strong&gt;Solid Queue&lt;/strong&gt; built-in. We don't need to install Redis. We just use our existing PostgreSQL database. Here is how to move your AI calls to the background and use Hotwire to update the user's screen in real-time.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Empty State View
&lt;/h2&gt;

&lt;p&gt;When a user clicks "Generate Summary", we want the page to load instantly. We will show them a loading spinner while the AI thinks in the background.&lt;/p&gt;

&lt;p&gt;To do this, we need to set up a Hotwire listener (&lt;code&gt;turbo_stream_from&lt;/code&gt;) on our document page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/documents/show.html.erb --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 1. Listen for WebSocket broadcasts attached to this specific document --&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_stream_from&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 2. The target div that we will update later --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 animate-pulse"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🤖 AI is generating your summary...&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: The Fast Controller
&lt;/h2&gt;

&lt;p&gt;Now, we update our controller. Instead of calling the AI, we just tell our background queue to handle it, and we immediately render the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/summaries_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SummariesController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:document_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# Send the heavy lifting to Solid Queue!&lt;/span&gt;
    &lt;span class="no"&gt;GenerateSummaryJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Instantly redirect back to the show page&lt;/span&gt;
    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Summary is generating..."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your controller now executes in &lt;code&gt;0.02&lt;/code&gt; seconds instead of 15 seconds. Your server is happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: The Solid Queue Job
&lt;/h2&gt;

&lt;p&gt;Now we create the actual job that will run in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate job generate_summary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside this job, we make the slow API call, save the result to the database, and then broadcast the new HTML over WebSockets so the user's screen updates without them refreshing the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/generate_summary_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateSummaryJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="ss"&gt;:default&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. The Slow API Call (Takes 10-15 seconds)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_ACCESS_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;summary_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Save to database&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;summary: &lt;/span&gt;&lt;span class="n"&gt;summary_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. The Hotwire Magic: Broadcast the new HTML to the user!&lt;/span&gt;
    &lt;span class="no"&gt;Turbo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StreamsChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast_replace_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Matches the turbo_stream_from in our view&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"document_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# The ID of the div to replace&lt;/span&gt;
      &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"documents/summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# A partial containing the final text&lt;/span&gt;
      &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;document: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 4: The Broadcast Partial
&lt;/h2&gt;

&lt;p&gt;In the job above, we told Hotwire to render a partial called &lt;code&gt;documents/summary&lt;/code&gt;. Let's create that tiny file so Hotwire knows what HTML to send over the WebSocket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/documents/_summary.html.erb --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-4 bg-green-50 border border-green-200 rounded-lg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-green-800"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;AI Summary Complete:&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This is the ultimate workflow for the modern AI application. Look at what we achieved without writing a single line of custom JavaScript:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User Experience:&lt;/strong&gt; The user clicks a button and gets instant feedback (the loading state). They don't stare at a frozen browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Health:&lt;/strong&gt; The Puma web server is free to handle hundreds of other users because the 15-second AI wait time is offloaded to a background worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity:&lt;/strong&gt; Because of Rails 8 and Solid Queue, we don't have to manage Redis servers or complex infrastructure. The jobs live right in our standard database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time UI:&lt;/strong&gt; Hotwire securely pushes the finished HTML directly into the user's browser the exact millisecond the AI finishes thinking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are building AI wrappers, this exact pattern is your blueprint for success.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ai</category>
      <category>backgroundjobs</category>
      <category>hotwire</category>
    </item>
    <item>
      <title>Stop Leaking API Keys: Managing Secrets in Kamal 2</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Mon, 11 May 2026 23:07:00 +0000</pubDate>
      <link>https://dev.to/zilton7/stop-leaking-api-keys-managing-secrets-in-kamal-2-4dei</link>
      <guid>https://dev.to/zilton7/stop-leaking-api-keys-managing-secrets-in-kamal-2-4dei</guid>
      <description>&lt;p&gt;I see developers make a mistake that can ruin their entire month. &lt;/p&gt;

&lt;p&gt;They are building a new Rails SaaS. They get their Stripe secret key, their OpenAI key, and their AWS credentials. To deploy the app, they create a file called &lt;code&gt;.env.production&lt;/code&gt; on their laptop, paste the keys inside, and deploy.&lt;/p&gt;

&lt;p&gt;Then, late on a Friday night, they accidentally type &lt;code&gt;git add .&lt;/code&gt; and push that file to a public GitHub repository. &lt;/p&gt;

&lt;p&gt;Within exactly 4 seconds, automated bots scrape those keys. By Saturday morning, hackers have spun up $50,000 worth of crypto-mining servers on their AWS account.&lt;/p&gt;

&lt;p&gt;As a solo developer, you cannot afford this mistake. You need a system where your production secrets &lt;strong&gt;never&lt;/strong&gt; touch a file that can be committed to Git. &lt;/p&gt;

&lt;p&gt;With the release of &lt;strong&gt;Kamal 2&lt;/strong&gt;, managing secrets has been completely overhauled. You can now pull your API keys directly from your password manager during the deployment process. Here is how to lock down your Rails app in 4 simple steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The &lt;code&gt;deploy.yml&lt;/code&gt; Configuration
&lt;/h2&gt;

&lt;p&gt;In Kamal 2, you explicitly tell your deployment configuration which environment variables are considered "secrets". &lt;/p&gt;

&lt;p&gt;Open your &lt;code&gt;config/deploy.yml&lt;/code&gt; file. You will see an &lt;code&gt;env&lt;/code&gt; section. You just list the names of the keys your Rails app expects.&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;# config/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clear&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Safe to commit (Public info)&lt;/span&gt;
    &lt;span class="na"&gt;RAILS_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my_app_user&lt;/span&gt;
  &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# DANGEROUS! Do not put the actual values here!&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RAILS_MASTER_KEY&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;STRIPE_SECRET_KEY&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENAI_API_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you run &lt;code&gt;kamal deploy&lt;/code&gt;, Kamal looks at this list and says: &lt;em&gt;"Okay, I need to find the values for these 4 secrets before I can boot up the Docker container."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 2: The &lt;code&gt;.kamal/secrets&lt;/code&gt; File
&lt;/h2&gt;

&lt;p&gt;So, where does Kamal look for the actual values? &lt;/p&gt;

&lt;p&gt;By default, Kamal 2 looks for a file on your local machine at &lt;code&gt;.kamal/secrets&lt;/code&gt;. &lt;br&gt;
&lt;strong&gt;CRITICAL:&lt;/strong&gt; Ensure this file is added to your &lt;code&gt;.gitignore&lt;/code&gt; immediately so it never ends up on GitHub.&lt;/p&gt;

&lt;p&gt;You can create this file and paste your keys into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .kamal/secrets&lt;/span&gt;
&lt;span class="nv"&gt;RAILS_MASTER_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123supersecret...
&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;databasepassword99!
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_live_55555...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you deploy, Kamal reads this file, injects the secrets securely into the Docker container, and boots the app. &lt;/p&gt;

&lt;p&gt;This is much better than hardcoding keys in your codebase. But we can do even better. We can remove the keys from our hard drive completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: The "Pro Move" (Password Manager CLI)
&lt;/h2&gt;

&lt;p&gt;Having plain text passwords sitting in &lt;code&gt;.kamal/secrets&lt;/code&gt; on your laptop is still risky. If your laptop gets stolen, the keys are compromised.&lt;/p&gt;

&lt;p&gt;Kamal 2 allows you to execute terminal commands &lt;em&gt;inside&lt;/em&gt; the &lt;code&gt;.kamal/secrets&lt;/code&gt; file. This means we can ask a Password Manager (like 1Password, Bitwarden, or LastPass) to fetch the keys from the cloud at the exact moment of deployment.&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;1Password&lt;/strong&gt;. I installed their command-line tool (the &lt;code&gt;op&lt;/code&gt; CLI). &lt;/p&gt;

&lt;p&gt;Instead of writing the actual API key in my file, I write the 1Password command to fetch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .kamal/secrets&lt;/span&gt;

&lt;span class="c"&gt;# Fetch the master key from my 1Password vault&lt;/span&gt;
&lt;span class="nv"&gt;RAILS_MASTER_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"op://Work/RailsApp/master_key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Fetch the database password&lt;/span&gt;
&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"op://Work/Database/password"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Fetch the Stripe key&lt;/span&gt;
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"op://Work/Stripe/secret_key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 4: The Secure Deploy
&lt;/h2&gt;

&lt;p&gt;Now, let's see what happens when I deploy my app.&lt;/p&gt;

&lt;p&gt;I open my terminal and type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kamal deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Kamal reads &lt;code&gt;.kamal/secrets&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It sees the &lt;code&gt;op read&lt;/code&gt; commands.&lt;/li&gt;
&lt;li&gt;1Password pops up on my screen, asking for my fingerprint (TouchID).&lt;/li&gt;
&lt;li&gt;I scan my finger.&lt;/li&gt;
&lt;li&gt;1Password securely hands the keys to Kamal in memory.&lt;/li&gt;
&lt;li&gt;Kamal pushes the keys to the server and boots the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The plain-text keys &lt;strong&gt;do not exist&lt;/strong&gt; anywhere on my laptop's hard drive. They live securely in the 1Password cloud, and are injected directly into the production server's memory. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Security as a solo developer is usually an afterthought until something terrible happens. &lt;/p&gt;

&lt;p&gt;By using Kamal 2's secret management, you completely eliminate the risk of the dreaded "leaked &lt;code&gt;.env&lt;/code&gt; file." &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List the variable names in &lt;code&gt;deploy.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Put the values (or the fetch commands) in &lt;code&gt;.kamal/secrets&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep your &lt;code&gt;.gitignore&lt;/code&gt; clean.&lt;/li&gt;
&lt;li&gt;Use a password manager CLI to never store plain text keys locally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This setup takes about 10 minutes to configure, but the peace of mind it gives you when you run &lt;code&gt;git push&lt;/code&gt; on a Friday night is priceless.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>devops</category>
      <category>security</category>
      <category>kamal</category>
    </item>
    <item>
      <title>The Solo Developer’s Secret Weapon: Self-Hosted Automation with n8n</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sun, 10 May 2026 23:04:47 +0000</pubDate>
      <link>https://dev.to/zilton7/the-solo-developers-secret-weapon-self-hosted-automation-with-n8n-hdc</link>
      <guid>https://dev.to/zilton7/the-solo-developers-secret-weapon-self-hosted-automation-with-n8n-hdc</guid>
      <description>&lt;h1&gt;
  
  
  Automating My Life: How I Use n8n Instead of Custom Ruby Scripts
&lt;/h1&gt;

&lt;p&gt;Very often I find myself falling into the classic developer trap. &lt;/p&gt;

&lt;p&gt;I need a system that checks my Stripe account every morning, finds any failed payments, matches them to a user in my Rails database, and sends an alert to a private Discord channel. &lt;/p&gt;

&lt;p&gt;My immediate instinct as a developer is: &lt;em&gt;"I can build that in an hour."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So, I create a new &lt;code&gt;StripeAlertJob&lt;/code&gt; in Rails. I write a custom HTTP request to Discord. I schedule it with Sidekiq. It works perfectly. &lt;/p&gt;

&lt;p&gt;But six months later, the Discord API changes. Or Stripe updates their gem. Or the job silently fails and I don't notice. Suddenly, I am spending my Saturday morning debugging "glue code" that has absolutely nothing to do with my actual core product.&lt;/p&gt;

&lt;p&gt;As a solo developer, your time is your most precious asset. You should not be writing and maintaining code for basic integrations. &lt;/p&gt;

&lt;p&gt;In 2026, my solution to this is &lt;strong&gt;n8n&lt;/strong&gt;. It is a visual, node-based automation tool (like Zapier), but built for developers. Here is why I stopped writing custom Ruby scripts and moved all my "glue" logic to n8n.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is n8n? (And why not Zapier?)
&lt;/h2&gt;

&lt;p&gt;If you are a developer, you probably hate Zapier. It is insanely expensive (often costing hundreds of dollars a month for a busy app), the error logs are terrible, and doing complex logic like loops or custom HTTP requests feels clunky.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;n8n is different.&lt;/strong&gt; &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is "fair-code" open source. You can self-host it on a cheap $5 Hetzner VPS using Docker.&lt;/li&gt;
&lt;li&gt;It allows you to write raw JavaScript inside the nodes if you need custom logic.&lt;/li&gt;
&lt;li&gt;It has built-in nodes for almost everything (Stripe, GitHub, Postgres, Discord, OpenAI).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is how I use it to replace my custom Ruby scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  USE CASE 1: The "New User" Onboarding Flow
&lt;/h2&gt;

&lt;p&gt;When a user signs up for my Rails SaaS, I want a few things to happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add them to a Beehiiv newsletter list.&lt;/li&gt;
&lt;li&gt;Send me a Slack/Discord notification.&lt;/li&gt;
&lt;li&gt;If their email ends in &lt;code&gt;@google.com&lt;/code&gt; or &lt;code&gt;@microsoft.com&lt;/code&gt;, flag them as a high-value lead in a Google Sheet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Old Way (Rails):&lt;/strong&gt;&lt;br&gt;
I would install three different gems (&lt;code&gt;beehiiv&lt;/code&gt;, &lt;code&gt;slack-notifier&lt;/code&gt;, &lt;code&gt;google-drive-ruby&lt;/code&gt;). I would write a massive &lt;code&gt;UserOnboardingService.rb&lt;/code&gt; class and manage API keys for all three services in my &lt;code&gt;.env&lt;/code&gt; file. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The n8n Way:&lt;/strong&gt;&lt;br&gt;
I write exactly one line of code in my Rails app. I trigger an empty Webhook.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/users/registrations_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;
    &lt;span class="c1"&gt;# Just throw the user data at n8n and forget about it&lt;/span&gt;
    &lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://n8n.mydomain.com/webhook/new-user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;root_path&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the n8n visual editor, I catch that Webhook. I drag and drop a line to the &lt;strong&gt;Beehiiv node&lt;/strong&gt;, a line to an &lt;strong&gt;If node&lt;/strong&gt; (checking for &lt;code&gt;@google.com&lt;/code&gt;), and a line to a &lt;strong&gt;Discord node&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;If any of those APIs fail or change, n8n handles the retry logic and sends me an error alert. My Rails app stays incredibly lightweight and completely unaware of external marketing tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  USE CASE 2: The Nightly Database Report
&lt;/h2&gt;

&lt;p&gt;I love getting a daily summary of my business metrics (New users, MRR, Active sessions). &lt;/p&gt;

&lt;p&gt;Normally, I would write a custom Rake task (&lt;code&gt;rake reports:daily&lt;/code&gt;) and use a Cron job on my server to trigger it.&lt;/p&gt;

&lt;p&gt;With n8n, I don't even touch the Rails app. &lt;br&gt;
n8n has a native &lt;strong&gt;PostgreSQL node&lt;/strong&gt;. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I set a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; for 8:00 AM.&lt;/li&gt;
&lt;li&gt;I add a &lt;strong&gt;Postgres node&lt;/strong&gt;. I give it read-only access to my production Rails database.&lt;/li&gt;
&lt;li&gt;I write raw SQL directly inside the n8n node: 
&lt;code&gt;SELECT COUNT(*) FROM users WHERE created_at &amp;gt; NOW() - INTERVAL '1 day';&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;I pass the result to an &lt;strong&gt;OpenAI node&lt;/strong&gt; and tell the AI: &lt;em&gt;"Summarize these metrics into a friendly morning greeting."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;I pass the AI's output to a &lt;strong&gt;Telegram node&lt;/strong&gt; which messages my phone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Zero Ruby code written. Zero gems installed. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Self-Hosted Workflow
&lt;/h2&gt;

&lt;p&gt;Because n8n is just a Docker container, deploying it alongside your Rails app is incredibly easy, especially if you are using Kamal. &lt;/p&gt;

&lt;p&gt;I just spin up a tiny $5 VPS specifically for my internal tools. I use Docker Compose to run n8n, point a subdomain to it (&lt;code&gt;automations.mycompany.com&lt;/code&gt;), and I have an enterprise-grade automation engine running for the price of a cup of coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;As engineers, our ego often tells us that writing code is always the best solution. &lt;/p&gt;

&lt;p&gt;But every line of code you write is a line of code you have to maintain, test, and debug. If you are building a feature that provides unique value to your customers, write the Ruby code. &lt;/p&gt;

&lt;p&gt;If you are just moving data from Point A to Point B, generating alerts, or syncing marketing lists? &lt;strong&gt;Use n8n.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Offloading the "glue work" keeps your Rails monolith clean, keeps your mind focused, and lets you automate your business visually.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>automation</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>Killing the Password: How to Add Passkeys to Your Rails 8 App</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sat, 09 May 2026 23:04:55 +0000</pubDate>
      <link>https://dev.to/zilton7/killing-the-password-how-to-add-passkeys-to-your-rails-8-app-434f</link>
      <guid>https://dev.to/zilton7/killing-the-password-how-to-add-passkeys-to-your-rails-8-app-434f</guid>
      <description>&lt;p&gt;Very often I see users struggling with the absolute worst part of the internet: &lt;strong&gt;Passwords&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;They forget them. They use "Password123" and get hacked. They get annoyed when your app forces them to include a special character and an uppercase letter. As a solo developer, building "Forgot Password" flows and dealing with compromised accounts is a massive waste of time.&lt;/p&gt;

&lt;p&gt;In 2026, the industry is finally killing the password. Apple, Google, and Microsoft have all standardized &lt;strong&gt;Passkeys&lt;/strong&gt; (WebAuthn). &lt;/p&gt;

&lt;p&gt;This means your users can log into your Rails app using their laptop's TouchID, FaceID, or Windows Hello. It is incredibly secure (phishing-proof) and the UX is magical. &lt;/p&gt;

&lt;p&gt;Adding this to a Rails app sounds terrifying because cryptography is hard. But thanks to the &lt;code&gt;webauthn&lt;/code&gt; gem, we can implement it without needing a PhD in math. Here is the step-by-step guide to adding Passkeys to your Rails app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: How Passkeys Work
&lt;/h2&gt;

&lt;p&gt;Before we write code, you must understand the flow. It is a simple two-step dance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Challenge:&lt;/strong&gt; Your Rails server generates a random string of gibberish (a "challenge") and sends it to the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Signature:&lt;/strong&gt; The browser asks the user for their fingerprint. The hardware securely signs the gibberish and sends it back. Rails verifies the signature. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You never store a password. You only store a Public Key.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Database Setup
&lt;/h2&gt;

&lt;p&gt;We need to store the devices (Passkeys) that the user registers. A user can have many passkeys (e.g., their iPhone and their Macbook).&lt;/p&gt;

&lt;p&gt;First, our &lt;code&gt;User&lt;/code&gt; model needs a unique, random ID for WebAuthn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails g migration AddWebauthnIdToUsers webauthn_id:string:uniq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, we need a model to store the actual Passkey hardware devices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails g model Passkey user:references external_id:string:uniq public_key:string sign_count:integer
rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Note: Ensure your &lt;code&gt;User&lt;/code&gt; model automatically generates a &lt;code&gt;webauthn_id&lt;/code&gt; (like &lt;code&gt;SecureRandom.uuid&lt;/code&gt;) when a new user is created.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 2: The Gem and Configuration
&lt;/h2&gt;

&lt;p&gt;Add the official WebAuthn gem to your &lt;code&gt;Gemfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'webauthn'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;bundle install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now, we need a quick initializer to tell the gem what our website's domain is. &lt;br&gt;
Create &lt;code&gt;config/initializers/webauthn.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;WebAuthn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="c1"&gt;# This should be your actual production domain&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:3000"&lt;/span&gt; 
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rp_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"My Awesome SaaS"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 3: The Backend Logic (Registration)
&lt;/h2&gt;

&lt;p&gt;When a user wants to register their fingerprint, they click a button. This triggers a request to our controller.&lt;/p&gt;

&lt;p&gt;We need two actions: one to generate the "Challenge", and one to verify the "Response" the browser sends back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/passkeys_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasskeysController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="c1"&gt;# 1. Generate the options and the challenge&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_options&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WebAuthn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options_for_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;user: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; 
        &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webauthn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
        &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&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 must save the challenge in the session so we can verify it later&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:creation_challenge&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;challenge&lt;/span&gt;

    &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# 2. Verify the fingerprint response&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;webauthn_credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WebAuthn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:credential&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;begin&lt;/span&gt;
      &lt;span class="n"&gt;webauthn_credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:creation_challenge&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

      &lt;span class="c1"&gt;# If it passes, save the public key to the database!&lt;/span&gt;
      &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;external_id: &lt;/span&gt;&lt;span class="n"&gt;webauthn_credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;public_key: &lt;/span&gt;&lt;span class="n"&gt;webauthn_credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;sign_count: &lt;/span&gt;&lt;span class="n"&gt;webauthn_credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_count&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;WebAuthn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_entity&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 4: The Javascript (Stimulus)
&lt;/h2&gt;

&lt;p&gt;This is usually where developers get stuck. The native browser API for WebAuthn expects raw binary data (ArrayBuffers), but Rails sends JSON. &lt;/p&gt;

&lt;p&gt;To make this perfectly smooth, GitHub created a tiny wrapper library that handles the conversion. We pin it using Importmaps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/importmap pin @github/webauthn-json
rails g stimulus passkey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we write a clean Stimulus controller. It fetches the options from Rails, asks the browser for the fingerprint, and sends the result back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/javascript/controllers/passkey_controller.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&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;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;get&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;@github/webauthn-json&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Get the challenge from our Rails controller&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/passkeys/create_options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// 2. This triggers the MacOS TouchID / Windows Hello popup!&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

      &lt;span class="c1"&gt;// 3. Send the hardware signature back to Rails&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifyResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/passkeys&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifyResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Passkey registered successfully!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;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="s2"&gt;Passkey registration 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="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;In your view, you just add a button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"passkey"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;data-action=&lt;/span&gt;&lt;span class="s"&gt;"click-&amp;gt;passkey#register"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Register Fingerprint / FaceID
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What about Logging In?
&lt;/h2&gt;

&lt;p&gt;The login flow is exactly the same concept, just using &lt;code&gt;options_for_get&lt;/code&gt; instead of &lt;code&gt;options_for_create&lt;/code&gt;. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User types their email.&lt;/li&gt;
&lt;li&gt;Rails generates a login challenge.&lt;/li&gt;
&lt;li&gt;Stimulus calls &lt;code&gt;get({ publicKey: options })&lt;/code&gt; which prompts TouchID.&lt;/li&gt;
&lt;li&gt;Rails verifies the signature, looks up the &lt;code&gt;Passkey&lt;/code&gt; by its &lt;code&gt;external_id&lt;/code&gt;, finds the attached &lt;code&gt;User&lt;/code&gt;, and logs them in!&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Killing the password entirely might be too aggressive for a brand new startup (you still need a fallback for older devices). But adding Passkeys as an alternative login method makes your app feel incredibly premium.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; No database leaks can expose passwords, because you only store public keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UX:&lt;/strong&gt; Clicking a button and touching a fingerprint scanner takes 1 second. Typing a 16-character password takes 15 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Tools:&lt;/strong&gt; By combining &lt;code&gt;webauthn-ruby&lt;/code&gt;, &lt;code&gt;@github/webauthn-json&lt;/code&gt;, and Stimulus, we avoid all the painful cryptography math.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Embrace the future. Let your users log in with their faces.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>security</category>
      <category>webdev</category>
      <category>authentication</category>
    </item>
    <item>
      <title>10 Crypto Disasters That Shook the World (And What They Teach Coders)</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Fri, 08 May 2026 23:19:36 +0000</pubDate>
      <link>https://dev.to/zilton7/10-crypto-disasters-that-shook-the-world-and-what-they-teach-coders-62h</link>
      <guid>https://dev.to/zilton7/10-crypto-disasters-that-shook-the-world-and-what-they-teach-coders-62h</guid>
      <description>&lt;p&gt;In the world of cryptocurrency, we often say that "code is law." But when that law has a bug, or the person writing the law is a thief, billions of dollars can vanish in a single block. &lt;/p&gt;

&lt;p&gt;From algorithmic death spirals to the greatest heists in human history, the blockchain has seen some massive disasters. These aren't just stories about lost money; they are hard lessons for every developer and "coderpreneur" building in this space. &lt;/p&gt;

&lt;p&gt;Here are the 10 biggest crypto disasters that shook the industry to its core.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Celsius Network (2022)
&lt;/h2&gt;

&lt;p&gt;Celsius promised to "unbank" the world by offering safe yields of up to 18%. In reality, CEO Alex Mashinsky was gambling with user funds in high-risk DeFi protocols. When the market crashed in 2022, Celsius froze withdrawals, locking 1.7 million people out of $4.7 billion. &lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; If you don't know where the yield is coming from, you are the yield.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The Poly Network Hack (2021)
&lt;/h2&gt;

&lt;p&gt;A hacker exploited a vulnerability in how this protocol verified cross-chain transactions, stealing $611 million. In a strange turn of events, the hacker started chatting with the developers on the blockchain and eventually returned almost all the money. &lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Cross-chain bridges are currently one of the most fragile points in the ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The Bitfinex Hack (2016)
&lt;/h2&gt;

&lt;p&gt;Nearly 120,000 Bitcoin were stolen from the exchange. At the time, it was worth $72 million; today, it is worth billions. Years later, a couple in New York was arrested for trying to launder the funds. &lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; The blockchain is a permanent ledger. It never forgets a crime, even years later.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The DAO (2016)
&lt;/h2&gt;

&lt;p&gt;The DAO was a decentralized organization that raised $150 million in ETH. A "recursive call" bug in the smart contract allowed an attacker to drain a third of the funds. This was so big it forced Ethereum to do a "Hard Fork," splitting the community.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Even "unstoppable code" is subject to human intervention when enough money is at stake.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. QuadrigaCX (2019)
&lt;/h2&gt;

&lt;p&gt;Canada’s largest exchange collapsed because its founder, Gerald Cotten, was the only person with the private keys to the "cold storage" wallets. When he reportedly died in India, $190 million in customer funds were trapped forever.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Centralized points of failure are the enemy of decentralization.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Ronin Bridge Hack (2022)
&lt;/h2&gt;

&lt;p&gt;Axie Infinity was the king of blockchain gaming, but its bridge to Ethereum was weak. Hackers managed to compromise five out of nine validator nodes to steal $625 million.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; A blockchain is only as secure as its most vulnerable validator.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. BitConnect (2018)
&lt;/h2&gt;

&lt;p&gt;The ultimate Ponzi scheme. BitConnect promised 1% daily interest through a "trading bot" that didn't actually exist. It reached a $2.6 billion market cap before the pyramid collapsed.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; If it sounds too good to be true, it’s a scam.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Mt. Gox (2014)
&lt;/h2&gt;

&lt;p&gt;Mt. Gox handled 70% of all Bitcoin transactions globally. When they announced they "lost" 850,000 Bitcoins to a multi-year hack, the industry nearly died. &lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Bitcoin is secure, but the exchanges we use to buy it are often the weakest link.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. FTX (2022)
&lt;/h2&gt;

&lt;p&gt;Sam Bankman-Fried was the "golden boy" of crypto, but it was all a front. FTX was funneling customer deposits to cover massive gambling losses at its sister firm, Alameda Research. $8 billion evaporated overnight.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Old-school corporate fraud can easily hide behind new-age tech.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Terra LUNA (2022)
&lt;/h2&gt;

&lt;p&gt;This was a mathematical collapse. Terra’s stablecoin (UST) was pegged to the dollar using an algorithm. When the peg broke, it triggered a "death spiral" that wiped out $40 billion in a few days.&lt;br&gt;
&lt;strong&gt;The Lesson:&lt;/strong&gt; Complexity is the enemy of security.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Protect Yourself
&lt;/h2&gt;

&lt;p&gt;The recurring theme in almost all these disasters is &lt;strong&gt;Trust&lt;/strong&gt;. People trusted their life savings to third parties - exchanges, "yield" platforms, or centralized founders. &lt;/p&gt;

&lt;p&gt;When you don't own your private keys, you don't own your money. This is why every developer and builder should use a hardware wallet for their main assets. &lt;/p&gt;

&lt;p&gt;I personally recommend &lt;strong&gt;OneKey&lt;/strong&gt;. Their hardware is fully &lt;strong&gt;open-source&lt;/strong&gt;, meaning the code is transparent and can be audited by the community. There are no hidden backdoors.&lt;/p&gt;

&lt;p&gt;🛡️ &lt;strong&gt;Protect your assets and get 10% off your OneKey device using &lt;a href="https://onekey.so/r/6T9QXC" rel="noopener noreferrer"&gt;THIS LINK&lt;/a&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The history of crypto is written in red ink, but every disaster makes the ecosystem more resilient. As builders, we must remember that there is no "undo" button on the blockchain. Stay safe, keep your keys private, and keep building.&lt;/p&gt;

</description>
      <category>crypto</category>
      <category>security</category>
      <category>blockchain</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Hotwire Native vs NativePHP: How Web Frameworks are Conquering Mobile and Desktop</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Thu, 07 May 2026 23:08:17 +0000</pubDate>
      <link>https://dev.to/zilton7/hotwire-native-vs-nativephp-how-web-frameworks-are-conquering-mobile-and-desktop-2j3l</link>
      <guid>https://dev.to/zilton7/hotwire-native-vs-nativephp-how-web-frameworks-are-conquering-mobile-and-desktop-2j3l</guid>
      <description>&lt;h1&gt;
  
  
  Hotwire Native vs NativePHP: How Web Frameworks are Conquering Mobile and Desktop
&lt;/h1&gt;

&lt;p&gt;Very often I find myself talking to developers who have successfully launched a web application. They are getting traffic, users are happy, but eventually, the inevitable happens. &lt;/p&gt;

&lt;p&gt;A user emails them: &lt;em&gt;"When is the mobile app coming out? Do you have a Mac desktop app?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are a solo developer, learning Swift for iOS, Kotlin for Android, and C++ for Windows is impossible. You want to use the language you already know. &lt;/p&gt;

&lt;p&gt;The two biggest backend communities - &lt;strong&gt;Ruby on Rails&lt;/strong&gt; and &lt;strong&gt;Laravel (PHP)&lt;/strong&gt;—both realized this pain point. They both created incredible, massive open-source tools to solve it. Rails gave us &lt;strong&gt;Hotwire Native&lt;/strong&gt;, and the Laravel community gave us &lt;strong&gt;NativePHP&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;But if you look under the hood, these two tools took completely opposite architectural paths. Here is my breakdown of how Hotwire Native and NativePHP work, the pros and cons of each, and why they solve two very different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Hotwire Native: The "Thin Client" (Rails)
&lt;/h2&gt;

&lt;p&gt;Hotwire Native (formerly Turbo Native) is the Rails way of getting your app onto iOS and Android. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;br&gt;
It is a "Thin Client". When a user downloads your app from the App Store and opens it, they are essentially opening a highly-optimized, invisible web browser. &lt;/p&gt;

&lt;p&gt;The mobile app does not have a database. It does not have business logic. When the user taps a button, the app sends a request to your live Rails server, fetches the HTML, and displays it. &lt;/p&gt;

&lt;p&gt;The magic is that it intercepts the clicks and uses &lt;strong&gt;real native mobile animations&lt;/strong&gt; (like sliding screens and native bottom tabs) to move between pages. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;One Codebase, One Database:&lt;/strong&gt; You only have one Rails app. If you change a typo on the pricing page, it instantly updates for all web, iOS, and Android users. No App Store reviews needed.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Tiny App Size:&lt;/strong&gt; Because the app is just a shell, the download size is incredibly small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Bad:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Requires Internet:&lt;/strong&gt; Because the server does all the work, if the user loses cell service, the app stops working. You cannot build a true "Offline-First" app this way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. NativePHP: The "Local Server" (Laravel)
&lt;/h2&gt;

&lt;p&gt;NativePHP blew the minds of the Laravel community when it launched. It was built primarily to get PHP apps onto Mac and Windows &lt;strong&gt;Desktop&lt;/strong&gt; environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;br&gt;
It takes the exact opposite approach to Hotwire. It is a "Thick Client". &lt;/p&gt;

&lt;p&gt;When you package your app with NativePHP, it uses Electron (or Tauri) as a shell. But here is the crazy part: &lt;strong&gt;It literally bundles a complete PHP runtime and a SQLite database inside the app.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;When your user downloads your &lt;code&gt;.dmg&lt;/code&gt; or &lt;code&gt;.exe&lt;/code&gt; file, they are silently installing a mini-server on their computer. When they click a button, the PHP code runs locally on their CPU, and saves data to a local SQLite database on their hard drive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;True Offline Capability:&lt;/strong&gt; The app works perfectly on an airplane with no Wi-Fi. &lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Deep OS Integration:&lt;/strong&gt; NativePHP gives you simple PHP methods to interact with the operating system. You can create system tray icons, trigger native OS notifications, and read local files easily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Bad:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Massive File Sizes:&lt;/strong&gt; You are shipping a browser, a PHP runtime, and a database to every user. The app size will be massive compared to a native app.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Syncing Nightmare:&lt;/strong&gt; If your app has a cloud component (like syncing notes between a desktop and a phone), you now have to write extremely complex logic to merge the user's local SQLite database with your central cloud database. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Architectural Showdown
&lt;/h2&gt;

&lt;p&gt;Both of these tools are masterpieces of engineering, but they serve two very different philosophies for the solo developer.&lt;/p&gt;

&lt;p&gt;If you are building a &lt;strong&gt;SaaS, a Marketplace, or a Social Network&lt;/strong&gt;, Hotwire Native is the absolute winner. In a SaaS, the data &lt;em&gt;must&lt;/em&gt; live in the cloud. Having a local database on the user's device (like NativePHP does) creates terrifying data-syncing problems. With Hotwire Native, you manage one Postgres database, and the mobile app is just a beautiful window looking into it.&lt;/p&gt;

&lt;p&gt;If you are building a &lt;strong&gt;Utility Tool, a Markdown Editor, or a Local File Manager&lt;/strong&gt;, NativePHP is brilliant. You don't want a markdown editor to stop working because the internet went down. The fact that a PHP developer can build a fast, offline Mac app that lives in the menu bar, without learning Swift or Rust, is a massive achievement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In 2026, you do not need to be a polyglot to ship cross-platform apps. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Use &lt;strong&gt;Hotwire Native (Rails)&lt;/strong&gt; to wrap your cloud-based SaaS into a beautiful, lightweight mobile app.&lt;/li&gt;
&lt;li&gt;  Use &lt;strong&gt;NativePHP (Laravel)&lt;/strong&gt; to bundle your web skills into a heavy, offline-capable desktop utility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stop worrying about learning mobile or desktop languages. Stick to the backend framework you love, use the wrappers the community built for you, and keep shipping products.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>laravel</category>
      <category>mobile</category>
      <category>desktop</category>
    </item>
    <item>
      <title>Stop Fearing OOP: A Simple Guide to Ruby Classes for Beginners</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 06 May 2026 23:08:18 +0000</pubDate>
      <link>https://dev.to/zilton7/stop-fearing-oop-a-simple-guide-to-ruby-classes-for-beginners-3gl5</link>
      <guid>https://dev.to/zilton7/stop-fearing-oop-a-simple-guide-to-ruby-classes-for-beginners-3gl5</guid>
      <description>&lt;h1&gt;
  
  
  The Blueprint and the House: Ruby Classes and Objects Explained
&lt;/h1&gt;

&lt;p&gt;Very often I see new developers hit a massive brick wall when they start learning Ruby. You understand variables, you understand &lt;code&gt;if/else&lt;/code&gt; statements, and you understand loops. &lt;/p&gt;

&lt;p&gt;But then, a tutorial introduces the words &lt;strong&gt;"Class"&lt;/strong&gt; and &lt;strong&gt;"Object"&lt;/strong&gt;. Suddenly, the code is full of &lt;code&gt;@&lt;/code&gt; symbols, &lt;code&gt;def initialize&lt;/code&gt;, and &lt;code&gt;.new&lt;/code&gt;. If you don't have a computer science background, this jargon feels incredibly intimidating.&lt;/p&gt;

&lt;p&gt;You do not need a university degree to understand this. You just need a good mental model. &lt;/p&gt;

&lt;p&gt;Here is the absolute simplest way to understand Classes and Objects in Ruby, without the heavy academic vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: The Blueprint vs. The House
&lt;/h2&gt;

&lt;p&gt;Imagine you are an architect. You sit down and draw a &lt;strong&gt;Blueprint&lt;/strong&gt; for a house. The blueprint says the house will have 2 doors, 3 windows, and be painted a specific color.&lt;/p&gt;

&lt;p&gt;Can you live inside a blueprint? Can you open the doors of a blueprint? &lt;br&gt;
No. It is just a piece of paper. It is a set of instructions.&lt;/p&gt;

&lt;p&gt;To actually live in it, you have to hire a builder to read the blueprint and construct a &lt;strong&gt;Real House&lt;/strong&gt; out of wood and bricks. You can use that exact same blueprint to build 50 different houses on the same street. Some owners might paint their house red, and others might paint it blue, but they all came from the same instructions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The Class&lt;/strong&gt; is the Blueprint.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Object&lt;/strong&gt; is the Real House.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's look at how this translates into Ruby code.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Creating the Blueprint (The Class)
&lt;/h2&gt;

&lt;p&gt;In Ruby, we write a Class to define the rules of our concept. Let's stick with our house example.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;House&lt;/span&gt;
  &lt;span class="c1"&gt;# This is just the blueprint. &lt;/span&gt;
  &lt;span class="c1"&gt;# It doesn't physically exist in our app's memory yet.&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right now, this code does absolutely nothing. It is just a piece of paper sitting on your desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Building the House (The Object)
&lt;/h2&gt;

&lt;p&gt;To turn our blueprint into something real that we can interact with, we have to "build" it. In Ruby, we do this using the &lt;code&gt;.new&lt;/code&gt; method.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# We are building two distinct physical houses from the same blueprint&lt;/span&gt;
&lt;span class="n"&gt;my_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;span class="n"&gt;your_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you type &lt;code&gt;House.new&lt;/code&gt;, Ruby reads your class, allocates physical memory in your computer, and creates a living, breathing &lt;strong&gt;Object&lt;/strong&gt;. &lt;code&gt;my_house&lt;/code&gt; and &lt;code&gt;your_house&lt;/code&gt; are two completely separate objects. &lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Front Door (Initialize)
&lt;/h2&gt;

&lt;p&gt;When a house is built, certain things need to happen immediately. The builder needs to paint it and give the owner the keys. &lt;/p&gt;

&lt;p&gt;In Ruby, we use a special method called &lt;code&gt;initialize&lt;/code&gt;. Think of this as the "Builder". Whenever you call &lt;code&gt;.new&lt;/code&gt;, Ruby automatically looks for the &lt;code&gt;initialize&lt;/code&gt; method and runs it first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;House&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"Building a brand new &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; house!"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;my_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Red"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Outputs: Building a brand new Red house!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The Backpack (Instance Variables)
&lt;/h2&gt;

&lt;p&gt;Here is where beginners get confused. What is the &lt;code&gt;@&lt;/code&gt; symbol?&lt;/p&gt;

&lt;p&gt;Imagine every Object wears a little backpack. Inside that backpack, it keeps its own private data. If &lt;code&gt;my_house&lt;/code&gt; is Red, and &lt;code&gt;your_house&lt;/code&gt; is Blue, they need a way to remember their own colors.&lt;/p&gt;

&lt;p&gt;In Ruby, any variable that starts with an &lt;code&gt;@&lt;/code&gt; is an &lt;strong&gt;Instance Variable&lt;/strong&gt;. This means it goes directly into the Object's backpack. It remembers this data forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;House&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Put the color into this specific object's backpack&lt;/span&gt;
    &lt;span class="vi"&gt;@color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; 
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;what_color_am_i?&lt;/span&gt;
    &lt;span class="c1"&gt;# Look inside the backpack and read the color&lt;/span&gt;
    &lt;span class="s2"&gt;"I am a &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="vi"&gt;@color&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; house."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;my_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Red"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;your_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Blue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;my_house&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;what_color_am_i?&lt;/span&gt;  &lt;span class="c1"&gt;# Outputs: I am a Red house.&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;your_house&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;what_color_am_i?&lt;/span&gt; &lt;span class="c1"&gt;# Outputs: I am a Blue house.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice how they don't get confused? &lt;code&gt;my_house&lt;/code&gt; looked in its own backpack. &lt;code&gt;your_house&lt;/code&gt; looked in its own backpack. They share the same blueprint, but they have their own unique memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Actions (Methods)
&lt;/h2&gt;

&lt;p&gt;Finally, Objects need to &lt;em&gt;do&lt;/em&gt; things. A house has doors you can open. A user has a password they can reset. An invoice has a total it can calculate.&lt;/p&gt;

&lt;p&gt;We define these actions by writing normal methods (&lt;code&gt;def&lt;/code&gt;) inside the Class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;House&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="vi"&gt;@color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;
    &lt;span class="vi"&gt;@door_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;# Doors are closed by default&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_door!&lt;/span&gt;
    &lt;span class="vi"&gt;@door_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"The door is now open."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;my_house&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;House&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Red"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;my_house&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_door!&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Don't let the computer science vocabulary intimidate you. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Class:&lt;/strong&gt; The written instructions (Blueprint).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Object (or Instance):&lt;/strong&gt; The actual thing built from those instructions (The House).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;.new&lt;/code&gt;:&lt;/strong&gt; The command to hire the builder and construct the object.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;initialize&lt;/code&gt;:&lt;/strong&gt; The setup tasks the builder does on day one.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;@variable&lt;/code&gt;:&lt;/strong&gt; The private backpack where the object remembers its own data.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Methods:&lt;/strong&gt; The actions the object can perform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in Ruby is an object. A String is an object built from the &lt;code&gt;String&lt;/code&gt; class blueprint. An Integer is an object built from the &lt;code&gt;Integer&lt;/code&gt; blueprint. Once you grasp this simple idea, the entire Ruby language opens up and starts to feel like plain English.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>beginners</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build Your Own Cloud Empire: Hosting Unlimited Rails Apps with Kamal 2</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Tue, 05 May 2026 23:23:47 +0000</pubDate>
      <link>https://dev.to/zilton7/build-your-own-cloud-empire-hosting-unlimited-rails-apps-with-kamal-2-1bg6</link>
      <guid>https://dev.to/zilton7/build-your-own-cloud-empire-hosting-unlimited-rails-apps-with-kamal-2-1bg6</guid>
      <description>&lt;p&gt;If you are a solo developer or an indie hacker, you probably suffer from "Shiny Object Syndrome." You have 10 different ideas, and you want to launch all of them. &lt;/p&gt;

&lt;p&gt;In the old days, launching an MVP was expensive. You used Heroku or Render. You paid $7 for the web server, $9 for the database, and $5 for Redis. That is $21 per app. If you have 5 side projects, you are paying over $100 a month just to host apps that might have zero active users.&lt;/p&gt;

&lt;p&gt;This "PaaS Tax" kills innovation. It makes you afraid to launch new things.&lt;/p&gt;

&lt;p&gt;But in 2026, with the release of &lt;strong&gt;Kamal 2&lt;/strong&gt;, you don't need a Platform-as-a-Service anymore. You can rent raw, cheap Linux servers and build your own "Cloud Empire." You can host 10 different Rails apps on the exact same server, completely isolated from each other, for a fixed price of $10 a month.&lt;/p&gt;

&lt;p&gt;Here is the step-by-step guide to building your own multi-app VPS cluster using Kamal 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Strategy: The Kamal Proxy
&lt;/h2&gt;

&lt;p&gt;Before we start, you need to understand how this is possible. &lt;/p&gt;

&lt;p&gt;Normally, only one application can listen to Port 80 (HTTP) and Port 443 (HTTPS) on a server. If App A is using it, App B will crash. &lt;/p&gt;

&lt;p&gt;Kamal 2 solves this with &lt;strong&gt;Kamal Proxy&lt;/strong&gt;. When you deploy your first app, Kamal silently installs a master proxy at the front door of your server. This proxy listens to the internet. When a request comes in, the proxy looks at the domain name (&lt;code&gt;app1.com&lt;/code&gt; vs &lt;code&gt;app2.com&lt;/code&gt;) and routes the traffic to the correct Docker container. &lt;/p&gt;

&lt;p&gt;It handles the SSL certificates (HTTPS) automatically. It handles zero-downtime deployments. And it allows infinite apps to share one server.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Base Hardware
&lt;/h2&gt;

&lt;p&gt;First off, go to a cloud provider like Hetzner, DigitalOcean, or Linode. &lt;br&gt;
Rent a cheap Ubuntu VPS. A $10/month server on Hetzner gives you an ARM processor with 4 vCPUs and 8GB of RAM. &lt;/p&gt;

&lt;p&gt;Because Rails 8 is so efficient (especially if you use SQLite for your MVPs), 8GB of RAM is enough to comfortably run 10 to 15 separate Rails applications.&lt;/p&gt;

&lt;p&gt;Write down the IP address of your new server.&lt;/p&gt;
&lt;h2&gt;
  
  
  STEP 2: Deploying App Number 1
&lt;/h2&gt;

&lt;p&gt;Let's deploy your first idea: &lt;code&gt;project-alpha.com&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;In your first Rails application, open &lt;code&gt;config/deploy.yml&lt;/code&gt;. You configure it to point to your new server IP, and you tell Kamal Proxy which domain belongs to this app.&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;# project_alpha/config/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project-alpha&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_docker_username/project-alpha&lt;/span&gt;

&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.1.100&lt;/span&gt; &lt;span class="c1"&gt;# Your Server IP&lt;/span&gt;

&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project-alpha.com&lt;/span&gt;

&lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# ... your docker registry credentials&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your terminal, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kamal setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kamal will SSH into your server, install Docker, install Kamal Proxy, issue an SSL certificate, and start &lt;code&gt;project-alpha&lt;/code&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: Deploying App Number 2 (Sharing the Server)
&lt;/h2&gt;

&lt;p&gt;Now you have a second idea: &lt;code&gt;project-beta.com&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;You do &lt;strong&gt;not&lt;/strong&gt; need to buy a second server. You just point your DNS records for &lt;code&gt;project-beta.com&lt;/code&gt; to the exact same IP address (&lt;code&gt;192.168.1.100&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Open the &lt;code&gt;deploy.yml&lt;/code&gt; for your second Rails app:&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;# project_beta/config/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project-beta&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_docker_username/project-beta&lt;/span&gt;

&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.1.100&lt;/span&gt; &lt;span class="c1"&gt;# The EXACT SAME IP&lt;/span&gt;

&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project-beta.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;kamal setup&lt;/code&gt; in this second app. &lt;/p&gt;

&lt;p&gt;Kamal is smart enough to realize that Docker and Kamal Proxy are already installed on that server. It skips the heavy setup, deploys the new &lt;code&gt;project-beta&lt;/code&gt; container alongside the first one, and registers the new domain name with the proxy. &lt;/p&gt;

&lt;p&gt;Boom. Two apps, one server, one $10 bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 4: Scaling the Empire (The Database Node)
&lt;/h2&gt;

&lt;p&gt;Hosting 10 apps with SQLite on one server is great for MVPs. But what happens when &lt;code&gt;project-alpha&lt;/code&gt; goes viral and gets thousands of users? It starts eating all the CPU, slowing down your other 9 apps.&lt;/p&gt;

&lt;p&gt;It is time to turn your single server into a &lt;strong&gt;Cluster&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;You rent a second $10 VPS. This will be your &lt;strong&gt;Database Server&lt;/strong&gt;. &lt;br&gt;
Instead of spinning up managed RDS databases on AWS for $50/month, you use Kamal's "Accessories" feature to install a massive PostgreSQL container on this new server.&lt;/p&gt;

&lt;p&gt;In your &lt;code&gt;project-alpha&lt;/code&gt; deploy file, you separate your architecture:&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;# project_alpha/config/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project-alpha&lt;/span&gt;

&lt;span class="c1"&gt;# The Web server stays on Node 1&lt;/span&gt;
&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.1.100&lt;/span&gt; 

&lt;span class="c1"&gt;# The Database moves to Node 2&lt;/span&gt;
&lt;span class="na"&gt;accessories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.1.200&lt;/span&gt; &lt;span class="c1"&gt;# Your NEW Server IP&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;clear&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project_alpha_prod&lt;/span&gt;
        &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
      &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db_data:/var/lib/postgresql/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, your web traffic hits Server 1, and your database queries route securely to Server 2. You have built a distributed cloud architecture without writing a single line of Kubernetes configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The "One Person Framework" doesn't just apply to writing code. It applies to infrastructure. &lt;/p&gt;

&lt;p&gt;With Kamal 2, you have total control.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You can launch unlimited MVPs on a single cheap server.&lt;/li&gt;
&lt;li&gt;The proxy handles routing and SSL automatically.&lt;/li&gt;
&lt;li&gt;When an app gets traction, you just add another server IP to your &lt;code&gt;deploy.yml&lt;/code&gt; and scale horizontally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You are no longer renting a tiny slice of a PaaS. You own the hardware. Go build your empire.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>devops</category>
      <category>kamal</category>
      <category>startup</category>
    </item>
    <item>
      <title>Images, Volumes, and Containers: Docker Explained in Plain English</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Mon, 04 May 2026 23:23:45 +0000</pubDate>
      <link>https://dev.to/zilton7/images-volumes-and-containers-docker-explained-in-plain-english-4l6p</link>
      <guid>https://dev.to/zilton7/images-volumes-and-containers-docker-explained-in-plain-english-4l6p</guid>
      <description>&lt;p&gt;Very often I see developers completely give up on learning Docker because of the vocabulary. &lt;/p&gt;

&lt;p&gt;You read a tutorial and the author says: &lt;em&gt;"Just build the Dockerfile into an Image, mount the Volume, map the Ports, and spin up the Container."&lt;/em&gt; If you don't have a DevOps background, that sounds like alien gibberish.&lt;/p&gt;

&lt;p&gt;The truth is, Docker is incredibly simple. It is just a virtual "shipping box" for your code. But because the creators of Docker invented their own terminology, it feels intimidating.&lt;/p&gt;

&lt;p&gt;If you want to deploy modern web apps (especially using tools like Kamal 2), you need to know what these words actually mean. Here is the absolute simplest translation of Docker jargon into plain English.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Dockerfile (The Recipe)
&lt;/h2&gt;

&lt;p&gt;Imagine you are opening a bakery. The &lt;code&gt;Dockerfile&lt;/code&gt; is your master recipe book.&lt;/p&gt;

&lt;p&gt;It is literally just a plain text file. It contains a list of instructions on exactly how to build your application's environment from a completely blank slate. &lt;/p&gt;

&lt;p&gt;It says:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Buy an oven (Install Linux).&lt;/li&gt;
&lt;li&gt;Buy some flour (Install Ruby/Node.js).&lt;/li&gt;
&lt;li&gt;Bring in the secret ingredients (Copy your app's code).&lt;/li&gt;
&lt;li&gt;Mix it all together (Run &lt;code&gt;bundle install&lt;/code&gt; or &lt;code&gt;npm install&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;code&gt;Dockerfile&lt;/code&gt; doesn't &lt;em&gt;do&lt;/em&gt; anything on its own. It is just a piece of paper waiting to be read.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Image (The Frozen Cake)
&lt;/h2&gt;

&lt;p&gt;When you run the command &lt;code&gt;docker build&lt;/code&gt;, Docker reads your recipe (the &lt;code&gt;Dockerfile&lt;/code&gt;) and goes to work. &lt;/p&gt;

&lt;p&gt;When it finishes all the steps, it takes a massive, frozen snapshot of the entire system. This snapshot is called an &lt;strong&gt;Image&lt;/strong&gt;. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  An Image is &lt;strong&gt;read-only&lt;/strong&gt;. You cannot change the code inside an Image once it is built.&lt;/li&gt;
&lt;li&gt;  It is heavy. It might be 500MB or 1GB because it contains a whole mini-operating system.&lt;/li&gt;
&lt;li&gt;  Think of the Image as the CD-ROM of a video game. The CD holds all the game files, but the CD itself doesn't "play" the game until you put it in a console.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The Container (The Running Machine)
&lt;/h2&gt;

&lt;p&gt;This is the word people get confused by the most. &lt;/p&gt;

&lt;p&gt;If the Image is the CD-ROM, the &lt;strong&gt;Container&lt;/strong&gt; is the game actually running on your TV. &lt;/p&gt;

&lt;p&gt;When you run the command &lt;code&gt;docker run&lt;/code&gt;, Docker takes your frozen Image, wakes it up, gives it some RAM and CPU power, and turns it into a living, breathing &lt;strong&gt;Container&lt;/strong&gt;. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  You can run &lt;strong&gt;10 Containers&lt;/strong&gt; from &lt;strong&gt;1 Image&lt;/strong&gt;. (Just like you can install the same CD-ROM onto 10 different computers).&lt;/li&gt;
&lt;li&gt;  Containers are completely isolated from your actual laptop. They think they are the only thing existing in the universe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Volumes (The USB Flash Drive)
&lt;/h2&gt;

&lt;p&gt;Here is the most dangerous thing about Containers: &lt;strong&gt;They have amnesia.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your Container is running your database (like PostgreSQL or SQLite), and you restart the Container or it crashes, &lt;strong&gt;all your data is permanently deleted.&lt;/strong&gt; A Container always wakes up looking exactly like the original, frozen Image.&lt;/p&gt;

&lt;p&gt;To solve this, we use &lt;strong&gt;Volumes&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;A Volume is like a USB Flash Drive that you plug into the side of the Container. You tell Docker: &lt;em&gt;"Hey, whenever the database saves a file, don't save it inside the Container's memory. Save it onto this USB drive."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This way, if the Container explodes and dies, your data is safe on the Volume. When you boot up a brand new Container tomorrow, you just plug that same Volume back in, and your database is exactly where you left off.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ports (The Doors)
&lt;/h2&gt;

&lt;p&gt;As I mentioned earlier, Containers are isolated. They are like locked bank vaults.&lt;/p&gt;

&lt;p&gt;If you have a Rails or Node app running on port 3000 &lt;em&gt;inside&lt;/em&gt; the Container, and you open your browser to &lt;code&gt;http://localhost:3000&lt;/code&gt; on your Mac, it won't work. Your Mac cannot see inside the vault.&lt;/p&gt;

&lt;p&gt;You have to punch a hole in the wall. This is called &lt;strong&gt;Port Mapping&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;When you start the container, you pass a flag like &lt;code&gt;-p 8080:3000&lt;/code&gt;. This tells Docker:&lt;br&gt;
&lt;em&gt;"If anyone knocks on door 8080 of my actual laptop, open a secret tunnel and send them to door 3000 inside the Container."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The Registry (The App Store)
&lt;/h2&gt;

&lt;p&gt;Finally, how do you get your Image from your laptop to your production server? You use a &lt;strong&gt;Registry&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Registry is just Dropbox for Docker Images. The most famous one is Docker Hub, but GitHub has one too (GHCR). &lt;/p&gt;

&lt;p&gt;The workflow is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You build the Image on your laptop.&lt;/li&gt;
&lt;li&gt;You &lt;code&gt;docker push&lt;/code&gt; the Image up to the Registry.&lt;/li&gt;
&lt;li&gt;You log into your cheap $5 cloud server.&lt;/li&gt;
&lt;li&gt;You &lt;code&gt;docker pull&lt;/code&gt; the Image down from the Registry.&lt;/li&gt;
&lt;li&gt;You run it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(Side note: This exact 5-step process is what deployment tools like **Kamal&lt;/em&gt;* do automatically for you!)*&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Don't let the DevOps gatekeepers confuse you. The mental model is actually very logical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Dockerfile:&lt;/strong&gt; The blueprint.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Image:&lt;/strong&gt; The frozen snapshot built from the blueprint.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Container:&lt;/strong&gt; The running instance of the snapshot.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Volume:&lt;/strong&gt; The external hard drive to save your data.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Ports:&lt;/strong&gt; The tunnels to let internet traffic inside.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Registry:&lt;/strong&gt; The cloud storage to hold your snapshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once these terms click in your brain, Docker stops being a scary black box and becomes the most reliable tool in your entire development stack.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Build a Desktop App with Rails 8 and Electron</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sat, 02 May 2026 23:23:47 +0000</pubDate>
      <link>https://dev.to/zilton7/how-to-build-a-desktop-app-with-rails-8-and-electron-2bl8</link>
      <guid>https://dev.to/zilton7/how-to-build-a-desktop-app-with-rails-8-and-electron-2bl8</guid>
      <description>&lt;h1&gt;
  
  
  From Web to Desktop: Wrapping a Rails Hotwire App in Electron
&lt;/h1&gt;

&lt;p&gt;Very often I find myself building a successful web application, and inevitably, a user asks the golden question: &lt;em&gt;"Do you have a desktop app for Mac or Windows?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;As a solo developer, hearing this used to give me anxiety. I am a Ruby developer. I don't have the time to learn Swift for macOS, C# for Windows, or rewrite my entire frontend in React just to use a desktop framework.&lt;/p&gt;

&lt;p&gt;But in 2026, you don't have to. You can take your existing Rails app, wrap it in &lt;strong&gt;Electron&lt;/strong&gt;, and ship it as a native desktop application. &lt;/p&gt;

&lt;p&gt;The secret reason this works so well today is &lt;strong&gt;Hotwire&lt;/strong&gt;. Because Turbo intercepts link clicks and updates the page without doing a full browser reload, your web app inside Electron doesn't have that ugly "white flash" between pages. It actually feels like a native desktop app.&lt;/p&gt;

&lt;p&gt;Here is how to wrap your Rails app into an Electron desktop app in 4 easy steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Electron Setup
&lt;/h2&gt;

&lt;p&gt;Since Electron runs on Node.js, we do need to step out of the Ruby world just for a minute to create our desktop "shell". &lt;/p&gt;

&lt;p&gt;Create a new folder completely separate from your Rails app, and initialize a basic Node project:&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;my-desktop-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-desktop-app
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;electron &lt;span class="nt"&gt;--save-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your &lt;code&gt;package.json&lt;/code&gt;, update the main entry point and add a start script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-desktop-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.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;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main.js"&lt;/span&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;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"electron ."&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;h2&gt;
  
  
  STEP 2: The Main Process (Loading Rails)
&lt;/h2&gt;

&lt;p&gt;Now we create the &lt;code&gt;main.js&lt;/code&gt; file. This is the brain of your desktop app. Its entire job is to boot up a chromium window and point it to your Rails server.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;main.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;electron&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createWindow&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;win&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BrowserWindow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;titleBarStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hiddenInset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Makes it look modern on Mac&lt;/span&gt;
    &lt;span class="na"&gt;webPreferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preload.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;nodeIntegration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;contextIsolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="c1"&gt;// In development, point to localhost. &lt;/span&gt;
  &lt;span class="c1"&gt;// In production, point to your real HTTPS domain.&lt;/span&gt;
  &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// STEP 3 LOGIC GOES HERE...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;whenReady&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;createWindow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run &lt;code&gt;npm start&lt;/code&gt; right now (while your Rails server is running on port 3000), a beautiful desktop window will open, displaying your Rails app!&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: Handling External Links
&lt;/h2&gt;

&lt;p&gt;There is one immediate problem. If a user clicks a link in your app that goes to &lt;code&gt;https://twitter.com&lt;/code&gt;, Twitter will load &lt;em&gt;inside&lt;/em&gt; your Electron app. You don't want that. You want external links to open in the user's default browser (like Chrome or Safari).&lt;/p&gt;

&lt;p&gt;We can intercept these links inside &lt;code&gt;main.js&lt;/code&gt;. Add this inside your &lt;code&gt;createWindow&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// Intercept links opening in new windows&lt;/span&gt;
  &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webContents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWindowOpenHandler&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// If the URL is not your Rails app, open it in the default browser&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openExternal&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Note: In Rails, make sure your external links have &lt;code&gt;target="_blank"&lt;/code&gt; so Electron knows it is trying to open a new window!&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 4: The Hotwire Bridge (Stimulus)
&lt;/h2&gt;

&lt;p&gt;Your app looks great, but what if you want to use Native Desktop features? What if you want to trigger a native Desktop Notification when a Rails background job finishes?&lt;/p&gt;

&lt;p&gt;We need our Rails app to "talk" to Electron. We do this using a &lt;code&gt;preload.js&lt;/code&gt; file. &lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;preload.js&lt;/code&gt; in your Electron folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;contextBridge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;electron&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// We expose a safe API to the browser window&lt;/span&gt;
&lt;span class="nx"&gt;contextBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exposeInMainWorld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desktopAPI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sendNotification&lt;/span&gt;&lt;span class="p"&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="nx"&gt;body&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;// We send a message to the Electron main process&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Notification&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&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;Now, back in your &lt;strong&gt;Rails App&lt;/strong&gt;, you can write a simple Stimulus controller to trigger this!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails g stimulus desktop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/javascript/controllers/desktop_controller.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&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;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;notify&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 check if 'window.desktopAPI' exists. &lt;/span&gt;
    &lt;span class="c1"&gt;// If they are using a normal web browser, it will be undefined.&lt;/span&gt;
    &lt;span class="c1"&gt;// If they are in our Electron app, it will run!&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;desktopAPI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;desktopAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Task Complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your export is ready.&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Task Complete (Web Version)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach this controller to a button in your Rails view: &lt;code&gt;&amp;lt;button data-controller="desktop" data-action="click-&amp;gt;desktop#notify"&amp;gt;Test Notification&amp;lt;/button&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The "Write Once, Run Anywhere" dream used to be a myth. But the modern Rails stack makes it incredibly close to reality.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Your &lt;strong&gt;Rails backend&lt;/strong&gt; handles the database and logic.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Hotwire&lt;/strong&gt; makes the frontend feel instant and native.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Hotwire Native&lt;/strong&gt; wraps it for iOS and Android.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Electron&lt;/strong&gt; wraps it for macOS and Windows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By simply pointing an Electron window at your Rails URL and exposing a few features via a Stimulus bridge, you can offer your users a premium desktop experience without maintaining a separate codebase.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>electron</category>
      <category>hotwire</category>
      <category>desktop</category>
    </item>
  </channel>
</rss>
