<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hboon.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hboon.com/" rel="alternate" type="text/html" /><updated>2026-05-01T07:59:08+00:00</updated><id>https://hboon.com/feed.xml</id><title type="html">Hwee-Boon Yar</title><subtitle>I write, ship and sell software products. Indie. Writing code in Swift, TypeScript and Ruby. Based in Singapore, working remotely. This is my blog.</subtitle><entry><title type="html">My Cloudflare Tunnel Config Is My Local Dev Directory</title><link href="https://hboon.com/my-cloudflare-tunnel-config-is-my-local-dev-directory/" rel="alternate" type="text/html" title="My Cloudflare Tunnel Config Is My Local Dev Directory" /><published>2026-05-01T06:02:00+00:00</published><updated>2026-05-01T06:02:00+00:00</updated><id>https://hboon.com/my-cloudflare-tunnel-config-is-my-local-dev-directory</id><content type="html" xml:base="https://hboon.com/my-cloudflare-tunnel-config-is-my-local-dev-directory/"><![CDATA[<p>I saw <a href="https://gregraiz.com/blog/local-vibe/">Greg Raiz’s local.vibe post</a> on Hacker News. The problem is familiar: once you have enough local projects, remembering <code class="language-plaintext highlighter-rouge">localhost:5173</code> vs <code class="language-plaintext highlighter-rouge">localhost:3001</code> vs whatever the browser extension dev server picked becomes annoying.</p>

<p>My setup is less ambitious. I already use Cloudflare Tunnel for most products I build, so my tunnel config became the directory of local dev services.</p>

<p>It is a boring YAML file, but it has the answer I need most often: what runs where?</p>

<h2 id="the-file">The File</h2>

<p>My <code class="language-plaintext highlighter-rouge">~/.cloudflared/config.yml</code> has the exposed dev apps:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">ingress</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:5174</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-backend.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:4002</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-myog.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:5273</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-myog-backend.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:4010</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-stacknaut.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:5375</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-stacknaut-backend.example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:3005</span>
  <span class="pi">-</span> <span class="na">service</span><span class="pi">:</span> <span class="s">http_status:404</span>
</code></pre></div></div>

<p>In my actual config, those are real hostnames. They go through a Cloudflare Tunnel to my machine. I use the dev domains as the normal URLs for those apps, including OAuth, webhooks, mobile callbacks, and testing from other devices.</p>

<p>For local-only things that should not be exposed through the tunnel, I add comments to the same file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Local-only dev ports reserved outside Cloudflare tunnels:</span>
<span class="c1"># - 3017: MyOG browser-extension WXT dev server</span>
<span class="c1"># - 3006: DevSnoop browser-extension WXT dev server</span>
</code></pre></div></div>

<p>That’s it. Exposed services are normal ingress rules. Local-only services are comments at the bottom.</p>

<h2 id="why-i-like-this-better-than-another-dashboard">Why I Like This Better Than Another Dashboard</h2>

<p>I don’t need a dashboard for this. I need one file to check.</p>

<p>Most of the time I don’t browse a launcher to find a project. I am already in the project, in tmux, or talking to a coding agent. What I need is for me and the agent to agree on which port and hostname belong to which thing.</p>

<p>The Cloudflare config already has to exist. It already maps names to ports. It has the exact shape I care about:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hostname -&gt; localhost port
</code></pre></div></div>

<p>Adding another file just for agents would make the system worse. Now I have two places to update, and eventually one of them lies.</p>

<h2 id="the-agent-angle">The Agent Angle</h2>

<p>This ended up working well with coding agents.</p>

<p>My <code class="language-plaintext highlighter-rouge">AGENTS.md</code> tells coding agents to check <code class="language-plaintext highlighter-rouge">~/.cloudflared/config.yml</code> before choosing or fixing dev ports. It also tells them that <code class="language-plaintext highlighter-rouge">https://*.example.com</code> domains are Cloudflare tunnels to my local machine, not deployed servers.</p>

<p>So when an agent needs to test a frontend, it does not guess:</p>

<ul>
  <li>It checks the tunnel config.</li>
  <li>It sees the hostname and port.</li>
  <li>It uses the dev domain.</li>
  <li>If it adds a new exposed app, it updates the ingress list.</li>
  <li>If it reserves a local-only port, it adds a comment at the bottom.</li>
</ul>

<p>Browser extension dev servers, local helper tools, one-off WXT servers — these often do not belong on the public internet, even behind a hard-to-guess dev subdomain. But they still need a port. Putting them in comments keeps the list complete without pretending every local service should be tunneled.</p>

<h2 id="the-config-is-also-documentation">The Config Is Also Documentation</h2>

<p>I like documentation that is also configuration. It stays accurate because something depends on it.</p>

<p>A README that says “frontend runs on 5173” gets stale. A tunnel config that routes <code class="language-plaintext highlighter-rouge">dev-myog.example.com</code> to <code class="language-plaintext highlighter-rouge">localhost:5273</code> gets fixed when it breaks.</p>

<p>The comments are the only weak part because they are not executable. But they are in the same file the agent already has to read, right next to the executable mappings. That is good enough in practice.</p>

<p>I also put a command note in the file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Adding new subdomains below requires running this command ("dev" = my tunnel name, don't change):</span>
<span class="c1"># cloudflared tunnel route dns dev dev-&lt;new-subdomain&gt;.example.com</span>
</code></pre></div></div>

<p>This saves a question. When I ask an agent to add a new tunneled dev app, it has the naming convention and the command in front of it.</p>

<h2 id="why-not-auto-assign-ports">Why Not Auto-Assign Ports?</h2>

<p>Auto-assigned ports are nice for tools that fully own process lifecycle. If a tool starts the app, injects <code class="language-plaintext highlighter-rouge">$PORT</code>, proxies the hostname, watches the process, and shuts it down, auto-assignment makes sense.</p>

<p>That is not my workflow.</p>

<p>I usually have projects already running in tmux. Some are frontend apps. Some are Fastify backends. Some are browser extension dev servers. Some are old projects with old assumptions. I want stable ports because stable ports make everything else boring:</p>

<ul>
  <li>OAuth callback URLs stay fixed.</li>
  <li>Mobile apps can keep the same backend URL.</li>
  <li>Browser bookmarks work.</li>
  <li>Agents can test without asking me which app is running where.</li>
  <li>Cloudflare Tunnel can expose selected services with real HTTPS.</li>
</ul>

<p>The cost is that ports still need to be reserved. That’s fine. The agent can pick one, but it has to write the choice down in the same file.</p>

<h2 id="what-i-tell-agents">What I Tell Agents</h2>

<p>The key instruction in my global agent setup is simple:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">### Dev Ports</span>

Other local-only dev port usage (not Cloudflare tunnels, e.g. WXT dev servers)
is documented in comments at the end of ~/.cloudflared/config.yml.
Check/update there before choosing/fixing ports.

<span class="gu">### Dev Domains</span>

https://<span class="err">*</span>.example.com are Cloudflare tunnels to the local machine,
not deployed servers. Config in ~/.cloudflared/config.yml
</code></pre></div></div>

<p>The file is useful to me, but it is more useful because the agents know it exists.</p>

<p>Without that instruction, agents guess. They search package files, find default Vite ports, try raw ports, maybe start another server, maybe collide with something already running.</p>

<p>With it, they check the registry first.</p>

<h2 id="the-small-setup-works">The Small Setup Works</h2>

<p>I like tools like local.vibe. It solves more of the lifecycle problem: hostnames, HTTPS, process management, setup instructions for agents. If I wanted a self-contained local app launcher, I would look at it seriously.</p>

<p>But for my setup, Cloudflare Tunnel already covers the part I care about most. I get stable HTTPS dev domains, remote-device testing, webhook testing, and a readable mapping of services to ports.</p>

<p>The only extra habit I needed was treating <code class="language-plaintext highlighter-rouge">~/.cloudflared/config.yml</code> as the local dev directory, not just tunnel config. Agents read it, use it, and update it.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[I saw Greg Raiz’s local.vibe post on Hacker News. The problem is familiar: once you have enough local projects, remembering localhost:5173 vs localhost:3001 vs whatever the browser extension dev server picked becomes annoying.]]></summary></entry><entry><title type="html">If You Vibe Code an App for Work, Put the Backend in Charge</title><link href="https://hboon.com/if-you-vibe-code-an-app-for-work-put-the-backend-in-charge/" rel="alternate" type="text/html" title="If You Vibe Code an App for Work, Put the Backend in Charge" /><published>2026-05-01T03:29:00+00:00</published><updated>2026-05-01T03:29:00+00:00</updated><id>https://hboon.com/if-you-vibe-code-an-app-for-work-put-the-backend-in-charge</id><content type="html" xml:base="https://hboon.com/if-you-vibe-code-an-app-for-work-put-the-backend-in-charge/"><![CDATA[<p>Someone on Reddit asked about deploying a custom vibe-coded app for work, installed on a local server. They could not code their way through problems, but figured Claude could fix things when they broke.</p>

<p>I have been programming for 30 years. Beyond the obvious “does it work?”, these are the two things I would check first:</p>

<ul>
  <li>the backend must not blindly trust the frontend</li>
  <li>secrets must not leak into the frontend</li>
</ul>

<h2 id="the-backend-must-not-trust-the-frontend">The backend must not trust the frontend</h2>

<p>Assume a website frontend talking to a server-side backend. Same idea applies to mobile apps, desktop apps, browser extensions, internal dashboards, whatever. If there is a client and a server, the client is not trusted.</p>

<p>It is easy to build as if only your frontend will ever talk to your backend. Maybe the button is hidden. Maybe the form validates the email address. Maybe the frontend only sends <code class="language-plaintext highlighter-rouge">role: "user"</code>.</p>

<p>But none of that matters if the backend accepts bad requests directly.</p>

<p>Anyone who can reach your backend can call it directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST https://your-app.example.com/api/generate <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"prompt":"do expensive AI work for me"}'</span>
</code></pre></div></div>

<p>They do not need to use your UI.</p>

<p>They can try:</p>

<ul>
  <li>login attempts</li>
  <li>account creation</li>
  <li>password reset flows</li>
  <li>free trial limits</li>
  <li>free AI calls</li>
  <li>alt text generation</li>
  <li>file uploads</li>
  <li>admin-looking payloads</li>
  <li>object IDs that belong to other users</li>
  <li>prices changed to <code class="language-plaintext highlighter-rouge">0</code></li>
  <li><code class="language-plaintext highlighter-rouge">isAdmin: true</code></li>
  <li><code class="language-plaintext highlighter-rouge">plan: "enterprise"</code></li>
</ul>

<p>If the backend accepts it, it happened.</p>

<h2 id="frontend-validation-is-for-user-experience">Frontend validation is for user experience</h2>

<p>Frontend validation is useful. It makes the app feel better. It catches mistakes before a request goes over the network.</p>

<p>But it is not security.</p>

<p>If your frontend checks that a file is smaller than 10 MB, the backend still has to check that the file is smaller than 10 MB.</p>

<p>If your frontend hides the “delete project” button from non-admins, the backend still has to check that the current user is allowed to delete the project.</p>

<p>If your frontend disables the “generate” button after 10 free AI calls, the backend still has to count the calls.</p>

<p>The frontend can help honest users avoid mistakes. The backend decides what is allowed.</p>

<h2 id="ask-the-agent-to-check-this-directly">Ask the agent to check this directly</h2>

<p>If I were vibe-coding an internal app and did not trust myself to catch this, I would ask the agent to review the backend directly.</p>

<p>Something like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Review this app for places where the backend trusts the frontend too much.

Check all API routes. For each route, verify:
- authentication is required where needed
- authorization checks happen on the backend
- users can only access their own records
- request body fields cannot override server-owned fields
- paid or limited features are enforced server-side
- rate limits exist for expensive operations
- file upload limits are enforced server-side

Return concrete file paths and fixes.
</code></pre></div></div>

<p>The exact prompt does not matter. The important part is asking about the class of bug directly.</p>

<p>Coding agents are good at fixing things when you point them at the right problem. “Is this app secure?” is too vague.</p>

<p>You still have to decide whether the agent’s answer is good enough. For a work app with real data, real users, or real money involved, I would get a human programmer to check it too.</p>

<h2 id="rate-limit-anything-expensive">Rate limit anything expensive</h2>

<p>AI calls make this worse because the attack can cost you money immediately.</p>

<p>If your app has a free feature that calls OpenAI, Anthropic, Gemini, or any other paid API, assume someone will try to call it directly.</p>

<p>Even on a local server, ask what “local” means.</p>

<p>Is it only bound to <code class="language-plaintext highlighter-rouge">localhost</code>? Is it exposed on the office Wi-Fi? Is it behind a tunnel? Is it reachable through VPN?</p>

<p>Rate limit these endpoints:</p>

<ul>
  <li>login</li>
  <li>signup</li>
  <li>password reset</li>
  <li>email sending</li>
  <li>AI generation</li>
  <li>file uploads</li>
  <li>anything that hits a paid third-party API</li>
</ul>

<p>Also set billing limits on the provider side. Do not rely only on your own app code for this.</p>

<p>For an internal tool, the rate limits can be simple. You do not need a giant abuse prevention system on day one. But you need something.</p>

<h2 id="do-not-put-secrets-in-the-frontend">Do not put secrets in the frontend</h2>

<p>The second thing I mentioned was leaking API keys or secrets in the frontend.</p>

<p>Frontend code is sent to users. For web apps, that is literal JavaScript in the browser. For mobile and desktop apps, it is still code running on a device you do not control.</p>

<p>Assume an attacker can inspect it.</p>

<p>At minimum, they can search the downloaded code for strings that look like secrets:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sk-...
eyJ...
AKIA...
-----BEGIN PRIVATE KEY-----
</code></pre></div></div>

<p>Some keys are meant to be public. Analytics keys are commonly public. Stripe publishable keys are public. Supabase anon keys can be used from the frontend if your row-level security is correct.</p>

<p>But secrets belong on the backend:</p>

<ul>
  <li>OpenAI API keys</li>
  <li>Anthropic API keys</li>
  <li>Stripe secret keys</li>
  <li>database URLs</li>
  <li>private signing keys</li>
  <li>webhook secrets</li>
  <li>admin tokens</li>
  <li>service account credentials</li>
</ul>

<p>If the frontend needs to do something that requires a secret, it should call your backend. The backend uses the secret. The frontend gets the result.</p>

<h2 id="environment-variables-do-not-automatically-make-secrets-safe">Environment variables do not automatically make secrets safe</h2>

<p>One common mistake: putting a secret in an environment variable and assuming that makes it safe.</p>

<p>It depends where that environment variable is used.</p>

<p>In many frontend frameworks, variables with certain prefixes are intentionally bundled into the client. For example, <code class="language-plaintext highlighter-rouge">PUBLIC_</code>, <code class="language-plaintext highlighter-rouge">NEXT_PUBLIC_</code>, <code class="language-plaintext highlighter-rouge">VITE_</code>, or similar names usually mean “make this available to browser code.”</p>

<p>That is fine for public values. It is wrong for private secrets.</p>

<p>I would ask the agent to check this too:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Review all environment variables and config usage.

Identify any secrets that are imported or referenced by frontend code.
Check framework-specific public env prefixes.
List which variables are safe to expose and which must move server-side.
</code></pre></div></div>

<p>Then verify the built frontend bundle if the app matters. Search the output. Search the network requests. Search the browser source.</p>

<p>Do not just trust the <code class="language-plaintext highlighter-rouge">.env</code> file layout.</p>

<h2 id="local-server-does-not-mean-safe">Local server does not mean safe</h2>

<p>“Installed on a local server” can mean many things.</p>

<p>It might mean a machine under your desk only reachable from your laptop. It might mean an office server reachable by everyone on Wi-Fi. It might mean a NAS. It might mean a Cloudflare tunnel. It might mean “temporarily exposed for testing” that stays exposed forever.</p>

<p>I would still treat it as a real deployed app:</p>

<ul>
  <li>require login if the data matters</li>
  <li>use HTTPS if it crosses a network</li>
  <li>restrict network access where possible</li>
  <li>keep secrets on the server</li>
  <li>back up important data</li>
  <li>log errors without logging secrets</li>
  <li>set billing limits for paid APIs</li>
</ul>

<p>Internal tools are still tools. They still delete data, send emails, upload files, and call paid APIs.</p>

<h2 id="my-minimum-checklist">My minimum checklist</h2>

<p>For a small vibe-coded work app, my checklist would be:</p>

<ul>
  <li>Can an unauthenticated user call any API route?</li>
  <li>Can one user read or modify another user’s data by changing an ID?</li>
  <li>Are admin actions checked on the backend?</li>
  <li>Are usage limits enforced on the backend?</li>
  <li>Are expensive operations rate limited?</li>
  <li>Are all secrets server-only?</li>
  <li>Are API billing limits configured?</li>
  <li>Are backups configured if the data matters?</li>
  <li>Is the app reachable only by the people who should reach it?</li>
  <li>Can I restore it if Claude “fixes” it into a worse state?</li>
</ul>

<p>That last one matters.</p>

<p>If you cannot code your way out of a bad change, make sure the project is in git and committed before asking an agent to make changes. Then you can get back to a known working version.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git status
git add <span class="nb">.</span>
git commit <span class="nt">-m</span> <span class="s2">"Working version before changes"</span>
</code></pre></div></div>

<p>Use the agent. Let it help. But give yourself a way back.</p>

<p>I am not against vibe-coded internal tools. I use coding agents heavily. They are useful, and small custom tools can save a lot of time.</p>

<p>But if the app has a backend, the backend is the authority. The frontend is just a client.</p>

<p>And if a value is secret, it does not go into the frontend.</p>

<p>Good luck building.</p>]]></content><author><name></name></author><category term="AI" /><category term="Web" /><summary type="html"><![CDATA[Someone on Reddit asked about deploying a custom vibe-coded app for work, installed on a local server. They could not code their way through problems, but figured Claude could fix things when they broke.]]></summary></entry><entry><title type="html">Magic Link Sign Up and Login for SaaS</title><link href="https://hboon.com/magic-link-sign-up-and-login-for-saas/" rel="alternate" type="text/html" title="Magic Link Sign Up and Login for SaaS" /><published>2026-04-30T10:04:00+00:00</published><updated>2026-04-30T10:04:00+00:00</updated><id>https://hboon.com/magic-link-sign-up-and-login-for-saas</id><content type="html" xml:base="https://hboon.com/magic-link-sign-up-and-login-for-saas/"><![CDATA[<p>No passwords. No separate registration form. No “confirm your email” step after sign up.</p>

<p>The user enters an email address, gets a link, clicks it, and they are in. If the account exists, I sign them in. If it does not, I create it.</p>

<p>I use this Magic Link flow across my products. MyOG.social is the example here because it has the cleanest version of the implementation.</p>

<p>I also support Google Sign In because it is the fastest path for Gmail users. But Magic Link is the one I rely on. It works for every email address, including non-Google accounts, company domains, and people who do not want another OAuth prompt.</p>

<h2 id="sign-up-and-login-are-the-same-operation">Sign Up and Login Are the Same Operation</h2>

<p>I don’t ask the user whether they want to sign up or sign in.</p>

<p>That distinction is useful to the app, not the user. The user just wants access.</p>

<p>So the backend does this:</p>

<ul>
  <li>verify the email through a Magic Link</li>
  <li>look up the user by normalized email</li>
  <li>create the user if one does not exist</li>
  <li>return the same session shape either way</li>
</ul>

<p>In MyOG.social, that looks like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">dbService</span>
  <span class="p">.</span><span class="nx">db</span><span class="p">()</span>
  <span class="p">.</span><span class="nx">select</span><span class="p">()</span>
  <span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">users</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">where</span><span class="p">(</span><span class="nx">eq</span><span class="p">(</span><span class="nx">users</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span> <span class="nx">email</span><span class="p">))</span>
  <span class="p">.</span><span class="nx">limit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">then</span><span class="p">((</span><span class="nx">rows</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">user</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">trialExpiresAt</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span>
    <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">+</span> <span class="nx">FREE_TRIAL_DAYS</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span>
  <span class="p">)</span>

  <span class="kd">const</span> <span class="nx">newUser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">dbService</span>
    <span class="p">.</span><span class="nx">db</span><span class="p">()</span>
    <span class="p">.</span><span class="nx">insert</span><span class="p">(</span><span class="nx">users</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">values</span><span class="p">({</span>
      <span class="nx">email</span><span class="p">,</span>
      <span class="na">emailSource</span><span class="p">:</span> <span class="dl">"</span><span class="s2">magicLink</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">trialCreditsRemaining</span><span class="p">:</span> <span class="nx">FREE_TRIAL_CREDITS</span><span class="p">,</span>
      <span class="nx">trialExpiresAt</span><span class="p">,</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nx">returning</span><span class="p">()</span>

  <span class="nx">user</span> <span class="o">=</span> <span class="nx">newUser</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That one branch removes a surprising amount of product surface area. No “create account” screen. No “already have an account?” switch. No duplicate route that does the same thing with slightly different copy.</p>

<p>The frontend can still say “Sign up” or “Sign in” depending on context. The backend does not care.</p>

<h2 id="what-the-table-stores">What the Table Stores</h2>

<p>The Magic Link table stores only what the login flow needs.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">magicLinks</span> <span class="o">=</span> <span class="nx">pgTable</span><span class="p">(</span>
  <span class="dl">"</span><span class="s2">magic_links</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">{</span>
    <span class="na">id</span><span class="p">:</span> <span class="nx">serial</span><span class="p">(</span><span class="dl">"</span><span class="s2">id</span><span class="dl">"</span><span class="p">).</span><span class="nx">primaryKey</span><span class="p">(),</span>
    <span class="na">email</span><span class="p">:</span> <span class="nx">varchar</span><span class="p">(</span><span class="dl">"</span><span class="s2">email</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">(),</span>
    <span class="na">token</span><span class="p">:</span> <span class="nx">varchar</span><span class="p">(</span><span class="dl">"</span><span class="s2">token</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">(),</span>
    <span class="na">code</span><span class="p">:</span> <span class="nx">varchar</span><span class="p">(</span><span class="dl">"</span><span class="s2">code</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">(),</span>
    <span class="na">used</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">(</span><span class="dl">"</span><span class="s2">used</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">().</span><span class="k">default</span><span class="p">(</span><span class="kc">false</span><span class="p">),</span>
    <span class="na">expiresAt</span><span class="p">:</span> <span class="nx">timestamp</span><span class="p">(</span><span class="dl">"</span><span class="s2">expires_at</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">(),</span>
    <span class="na">createdAt</span><span class="p">:</span> <span class="nx">timestamp</span><span class="p">(</span><span class="dl">"</span><span class="s2">created_at</span><span class="dl">"</span><span class="p">).</span><span class="nx">notNull</span><span class="p">().</span><span class="nx">defaultNow</span><span class="p">(),</span>
  <span class="p">},</span>
  <span class="p">(</span><span class="nx">table</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="na">tokenIndex</span><span class="p">:</span> <span class="nx">index</span><span class="p">().</span><span class="nx">on</span><span class="p">(</span><span class="nx">table</span><span class="p">.</span><span class="nx">token</span><span class="p">),</span>
      <span class="na">codeIndex</span><span class="p">:</span> <span class="nx">index</span><span class="p">().</span><span class="nx">on</span><span class="p">(</span><span class="nx">table</span><span class="p">.</span><span class="nx">code</span><span class="p">),</span>
      <span class="na">emailIndex</span><span class="p">:</span> <span class="nx">index</span><span class="p">().</span><span class="nx">on</span><span class="p">(</span><span class="nx">table</span><span class="p">.</span><span class="nx">email</span><span class="p">),</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The key fields:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">token</code> for the link in the email</li>
  <li><code class="language-plaintext highlighter-rouge">code</code> for manual entry</li>
  <li><code class="language-plaintext highlighter-rouge">used</code> so the link can only be used once</li>
  <li><code class="language-plaintext highlighter-rouge">expiresAt</code> so old links stop working</li>
</ul>

<p>I also track how the email first came in:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">emailSourceEnum</span> <span class="o">=</span> <span class="nx">pgEnum</span><span class="p">(</span><span class="dl">"</span><span class="s2">email_source</span><span class="dl">"</span><span class="p">,</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">googleLogin</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">magicLink</span><span class="dl">"</span><span class="p">,</span>
<span class="p">])</span>
</code></pre></div></div>

<p>This is not required for authentication. I keep it because it helps later. I can tell whether a user came from Google Sign In or Magic Link, and I can use that when debugging support issues or looking at conversion.</p>

<h2 id="sending-the-magic-link">Sending the Magic Link</h2>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span>
  <span class="dl">"</span><span class="s2">/auth/magic-link/send</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">{</span>
    <span class="na">preHandler</span><span class="p">:</span> <span class="p">[</span><span class="nx">zodValidateBody</span><span class="p">(</span><span class="nx">sendMagicLinkSchema</span><span class="p">)],</span>
  <span class="p">},</span>
  <span class="nx">sendMagicLink</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The schema only needs an email:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">sendMagicLinkSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">object</span><span class="p">({</span>
  <span class="na">email</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">email</span><span class="p">(),</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The controller normalizes the email, creates a random token, creates a 6-digit code, and stores both with a 15-minute expiry.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">generateToken</span><span class="p">():</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomBytes</span><span class="p">(</span><span class="mi">32</span><span class="p">).</span><span class="nx">toString</span><span class="p">(</span><span class="dl">"</span><span class="s2">hex</span><span class="dl">"</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">generateCode</span><span class="p">():</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomInt</span><span class="p">(</span><span class="mi">100000</span><span class="p">,</span> <span class="mi">1000000</span><span class="p">).</span><span class="nx">toString</span><span class="p">()</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">email</span> <span class="o">=</span> <span class="nx">normalizeEmail</span><span class="p">(</span><span class="nx">rawEmail</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="nx">generateToken</span><span class="p">()</span>
<span class="kd">const</span> <span class="nx">code</span> <span class="o">=</span> <span class="nx">generateCode</span><span class="p">()</span>
<span class="kd">const</span> <span class="nx">expiresAt</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">+</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">)</span>

<span class="k">await</span> <span class="nx">dbService</span><span class="p">.</span><span class="nx">db</span><span class="p">().</span><span class="nx">insert</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">).</span><span class="nx">values</span><span class="p">({</span>
  <span class="nx">email</span><span class="p">,</span>
  <span class="nx">token</span><span class="p">,</span>
  <span class="nx">code</span><span class="p">,</span>
  <span class="nx">expiresAt</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The link uses the configured frontend hostname:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">magicLinkURL</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">env</span><span class="p">.</span><span class="nx">FRONTEND_HOSTNAME</span><span class="p">}</span><span class="s2">/magic-link-verify?token=</span><span class="p">${</span><span class="nx">token</span><span class="p">}</span><span class="s2">`</span>
</code></pre></div></div>

<p>I don’t hardcode production URLs in the auth code. Local dev, staging, and production all need to send different links.</p>

<p>The email includes both the link and the code:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Click the link below to sign in to myog.social:

https://myog.social/magic-link-verify?token=...

Or enter this verification code on the sign-in page:

123456

This link and code will expire in 15 minutes.
</code></pre></div></div>

<p>The 6-digit code looks like a small detail, but it matters.</p>

<p>Some people open email on their phone and the app on their laptop. Some corporate email tools visit links before the user sees them. Some browsers get weird with logged-in state across profiles. A code gives the user another path without adding another auth system.</p>

<h2 id="verifying-the-link">Verifying the Link</h2>

<p>The verify endpoint accepts either a token or a code.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">verifyMagicLinkSchema</span> <span class="o">=</span> <span class="nx">z</span>
  <span class="p">.</span><span class="nx">object</span><span class="p">({</span>
    <span class="na">token</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">optional</span><span class="p">(),</span>
    <span class="na">code</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">optional</span><span class="p">(),</span>
  <span class="p">})</span>
  <span class="p">.</span><span class="nx">refine</span><span class="p">((</span><span class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span> <span class="o">||</span> <span class="nx">data</span><span class="p">.</span><span class="nx">code</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">message</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Either token or code must be provided</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">})</span>
</code></pre></div></div>

<p>For a token, I look up a matching record that has not been used and has not expired.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">dbService</span>
  <span class="p">.</span><span class="nx">db</span><span class="p">()</span>
  <span class="p">.</span><span class="nx">select</span><span class="p">()</span>
  <span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">where</span><span class="p">(</span>
    <span class="nx">and</span><span class="p">(</span>
      <span class="nx">eq</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">.</span><span class="nx">token</span><span class="p">,</span> <span class="nx">token</span><span class="p">),</span>
      <span class="nx">eq</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">.</span><span class="nx">used</span><span class="p">,</span> <span class="kc">false</span><span class="p">),</span>
      <span class="nx">gt</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">.</span><span class="nx">expiresAt</span><span class="p">,</span> <span class="nx">now</span><span class="p">)</span>
    <span class="p">)</span>
  <span class="p">)</span>
  <span class="p">.</span><span class="nx">limit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>

<span class="kd">const</span> <span class="nx">magicLink</span> <span class="o">=</span> <span class="nx">results</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>The code path is the same shape, just <code class="language-plaintext highlighter-rouge">eq(magicLinks.code, code)</code> instead of the token check.</p>

<p>If there is no match, the answer is deliberately vague:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Invalid or expired link/code</span><span class="dl">"</span> <span class="p">})</span>
</code></pre></div></div>

<p>No need to tell the caller whether the token existed, expired, or was already used.</p>

<p>When there is a match, mark it used.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">dbService</span>
  <span class="p">.</span><span class="nx">db</span><span class="p">()</span>
  <span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">)</span>
  <span class="p">.</span><span class="kd">set</span><span class="p">({</span> <span class="na">used</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
  <span class="p">.</span><span class="nx">where</span><span class="p">(</span><span class="nx">eq</span><span class="p">(</span><span class="nx">magicLinks</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="nx">magicLink</span><span class="p">.</span><span class="nx">id</span><span class="p">))</span>
</code></pre></div></div>

<p>I would wrap this in a transaction if I were rebuilding it today. The practical behavior is still fine for my current products, but the stricter version is better: find the row, mark it used, create or fetch the user, all as one unit.</p>

<p>Then create the session.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">jwtToken</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">jwtSign</span><span class="p">({</span> <span class="nx">email</span> <span class="p">})</span>
<span class="kd">const</span> <span class="nx">creditsInfo</span> <span class="o">=</span> <span class="nx">calculateCreditsInfo</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span>

<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">send</span><span class="p">({</span>
  <span class="na">token</span><span class="p">:</span> <span class="nx">jwtToken</span><span class="p">,</span>
  <span class="na">user</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
    <span class="na">email</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span>
    <span class="na">customerID</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">customerID</span><span class="p">,</span>
    <span class="nx">accountHint</span><span class="p">,</span>
    <span class="nx">hasPaidSubscription</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="na">credits</span><span class="p">:</span> <span class="nx">creditsInfo</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>I keep the JWT payload small. The frontend gets the user object in the response, but the token only needs enough identity for authenticated API requests.</p>

<h2 id="the-frontend-has-two-states">The Frontend Has Two States</h2>

<p>The Vue page has two states.</p>

<p>First: enter email.</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;Input</span>
  <span class="na">id=</span><span class="s">"email"</span>
  <span class="na">v-model=</span><span class="s">"email"</span>
  <span class="na">type=</span><span class="s">"email"</span>
  <span class="na">placeholder=</span><span class="s">"you@example.com"</span>
  <span class="err">@</span><span class="na">keyup.enter=</span><span class="s">"sendMagicLink"</span>
<span class="nt">/&gt;</span>

<span class="nt">&lt;Button</span> <span class="err">@</span><span class="na">click=</span><span class="s">"sendMagicLink"</span><span class="nt">&gt;</span>
  Send Magic Link
<span class="nt">&lt;/Button&gt;</span>
</code></pre></div></div>

<p>After the email is sent, it switches to the code state.</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;Input</span>
  <span class="na">v-for=</span><span class="s">"(digit, index) in codeDigits"</span>
  <span class="na">:key=</span><span class="s">"index"</span>
  <span class="na">v-model=</span><span class="s">"codeDigits[index]"</span>
  <span class="na">type=</span><span class="s">"text"</span>
  <span class="na">inputmode=</span><span class="s">"numeric"</span>
  <span class="na">pattern=</span><span class="s">"[0-9]*"</span>
  <span class="na">maxlength=</span><span class="s">"1"</span>
  <span class="err">@</span><span class="na">paste=</span><span class="s">"handleCodePaste"</span>
<span class="nt">/&gt;</span>
</code></pre></div></div>

<p>The paste handler strips non-digits and verifies automatically when it gets 6 digits.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">pastedData</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">clipboardData</span><span class="p">?.</span><span class="nx">getData</span><span class="p">(</span><span class="dl">"</span><span class="s2">text</span><span class="dl">"</span><span class="p">)</span> <span class="o">||</span> <span class="dl">""</span>
<span class="kd">const</span> <span class="nx">digits</span> <span class="o">=</span> <span class="nx">pastedData</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">\D</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">""</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">6</span><span class="p">)</span>

<span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="mi">6</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">codeDigits</span><span class="p">.</span><span class="nx">value</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">=</span> <span class="nx">digits</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">||</span> <span class="dl">""</span>
<span class="p">}</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">digits</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">6</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">verifyCode</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Nothing fancy, but it removes friction. People paste codes.</p>

<p>The email link goes to a separate verify page:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">onMounted</span><span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="nx">route</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">token</span> <span class="k">as</span> <span class="kr">string</span>

  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">token</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">errorMessage</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">Invalid magic link</span><span class="dl">"</span>
    <span class="nx">isVerifying</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="k">return</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">appStore</span><span class="p">.</span><span class="nx">verifyMagicLink</span><span class="p">({</span> <span class="nx">token</span> <span class="p">})</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">void</span> <span class="nx">router</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="nx">errorMessage</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">result</span><span class="p">.</span><span class="nx">error</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">Failed to verify magic link.</span><span class="dl">"</span>
    <span class="nx">isVerifying</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="kc">false</span>
  <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Click link, verify token, store session, go to the app. That’s it.</p>

<h2 id="pinia-owns-the-session">Pinia Owns the Session</h2>

<p>The frontend store has the usual auth state:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">ref</span><span class="o">&lt;</span><span class="nx">User</span> <span class="o">|</span> <span class="kc">null</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">jwtToken</span> <span class="o">=</span> <span class="nx">ref</span><span class="o">&lt;</span><span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">credits</span> <span class="o">=</span> <span class="nx">ref</span><span class="o">&lt;</span><span class="nx">CreditsInfo</span> <span class="o">|</span> <span class="kc">null</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">)</span>

<span class="kd">const</span> <span class="nx">isAuthenticated</span> <span class="o">=</span> <span class="nx">computed</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="o">!!</span><span class="nx">user</span><span class="p">.</span><span class="nx">value</span> <span class="o">&amp;&amp;</span> <span class="o">!!</span><span class="nx">jwtToken</span><span class="p">.</span><span class="nx">value</span><span class="p">)</span>
</code></pre></div></div>

<p>Sending the Magic Link is just a POST to <code class="language-plaintext highlighter-rouge">/auth/magic-link/send</code>.</p>

<p>Verifying stores the returned JWT and user:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">jwtToken</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span>
<span class="nx">user</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">user</span>
<span class="nx">credits</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">credits</span> <span class="o">||</span> <span class="kc">null</span>

<span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="nx">JWT_STORAGE_KEY</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span><span class="p">)</span>
<span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="nx">USER_STORAGE_KEY</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">user</span><span class="p">))</span>
</code></pre></div></div>

<p>The rest of the app only asks whether the store has a session.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">appStore</span><span class="p">.</span><span class="nx">isAuthenticated</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">appStore</span><span class="p">.</span><span class="nx">restoreSession</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Protected routes do not need to know whether the user came through Magic Link or Google.</p>

<h2 id="where-google-sign-in-fits">Where Google Sign In Fits</h2>

<p>Google Sign In sits beside Magic Link in the dialog.</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"googleSignInButton"</span><span class="nt">&gt;&lt;/div&gt;</span>

<span class="nt">&lt;Button</span> <span class="err">@</span><span class="na">click=</span><span class="s">"goToMagicLink()"</span> <span class="na">variant=</span><span class="s">"outline"</span><span class="nt">&gt;</span>
  Sign in with Magic Link
<span class="nt">&lt;/Button&gt;</span>
</code></pre></div></div>

<p>The frontend loads Google Identity Services, renders Google’s button, receives an ID token, and sends it to the backend.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">success</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">store</span><span class="p">.</span><span class="nx">loginWithGoogle</span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">credential</span><span class="p">)</span>
</code></pre></div></div>

<p>The backend verifies the ID token with Google, extracts the email, and then follows the same find-or-create-user shape.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">ticket</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">verifyIdToken</span><span class="p">({</span>
  <span class="nx">idToken</span><span class="p">,</span>
  <span class="na">audience</span><span class="p">:</span> <span class="nx">env</span><span class="p">.</span><span class="nx">GOOGLE_CLIENT_ID</span><span class="p">,</span>
<span class="p">})</span>

<span class="kd">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">ticket</span><span class="p">.</span><span class="nx">getPayload</span><span class="p">()</span>
<span class="kd">const</span> <span class="nx">email</span> <span class="o">=</span> <span class="nx">normalizeEmail</span><span class="p">(</span><span class="nx">payload</span><span class="p">.</span><span class="nx">email</span><span class="p">)</span>
</code></pre></div></div>

<p>I like this split:</p>

<ul>
  <li>Google is fast for people with Google accounts</li>
  <li>Magic Link works for everyone else</li>
  <li>both return the same app session</li>
</ul>

<p>I don’t want password auth unless I have a specific reason to add it. Passwords mean reset flows, breach concerns, password manager weirdness, and another thing for users to maintain. Email-based auth is enough for the products I build.</p>

<p>This auth flow is part of <a href="https://stacknaut.com">Stacknaut</a>. I extracted it from the products I actually run.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[No passwords. No separate registration form. No “confirm your email” step after sign up.]]></summary></entry><entry><title type="html">DevSnoop — Browser Access for Coding Agents</title><link href="https://hboon.com/devsnoop-browser-access-for-coding-agents/" rel="alternate" type="text/html" title="DevSnoop — Browser Access for Coding Agents" /><published>2026-04-23T10:22:00+00:00</published><updated>2026-04-23T10:22:00+00:00</updated><id>https://hboon.com/devsnoop-browser-access-for-coding-agents</id><content type="html" xml:base="https://hboon.com/devsnoop-browser-access-for-coding-agents/"><![CDATA[<p>I use coding agents — Claude Code, Codex, Cursor — for most of my development. They’re good at reading and writing code. They’re not good at seeing what’s happening in the browser. The agent generates frontend code and has no way to check if a button is in the right place, if a form submits correctly, or if there’s a console error after the page loads.</p>

<p>The existing options — Chrome’s DevTools MCP, its CLI, Browser Tools MCP — all work. But they load dozens of tools into the agent’s context, return data the agent has to translate before it can act, and cost more tokens than I’d like for simple tasks. I don’t need all the tools they offer. I wanted a small, reliable subset.</p>

<p>So I built <a href="https://devsnoop.com">DevSnoop</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>DevSnoop is a Chrome extension that gives coding agents direct access to the browser. The agent sends an HTTP request to <code class="language-plaintext highlighter-rouge">localhost:9400</code>, the extension executes on the live page, and structured JSON comes back.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-X</span> POST http://127.0.0.1:9400/ <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"command":"page_summary","params":{"depth":3}}'</span>
</code></pre></div></div>

<p>One HTTP call, structured JSON back. No MCP server, no DevTools protocol.</p>

<p>18 commands — page inspection, click/fill/scroll/hover, console logs, network requests, screenshots, DOM diffing, tech stack detection, accessibility audits. I kept the surface area small on purpose.</p>

<h2 id="why-not-the-existing-tools">Why not the existing tools?</h2>

<p>Chrome DevTools MCP loads a large tool definition into the agent’s context window. DevSnoop’s skill file is a single markdown document with curl examples. The agent learns the full API in one read.</p>

<p>I ran a comparison on a real page — the admin panel of one of my projects. DevSnoop’s setup cost was under 2,000 tokens. Chrome DevTools CLI/MCP was closer to 5,000. For the actual task (understanding the page and its interactive elements), DevSnoop returned a compact summary with 30 interactive targets. Chrome DevTools returned a deeper accessibility tree, but at higher token cost.</p>

<p>Then there’s actionability. DevSnoop’s <code class="language-plaintext highlighter-rouge">page_summary</code> returns each interactive element with a CSS selector the agent can pass directly to <code class="language-plaintext highlighter-rouge">click</code> or <code class="language-plaintext highlighter-rouge">fill</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>label: "Refresh Followers" → selector: "body &gt; div &gt; button:nth-of-type(2)"
</code></pre></div></div>

<p>Chrome DevTools returns accessibility-tree nodes. The agent gets a label, but needs an extra step to map it back to something it can interact with. DevSnoop skips that step — the response is already in the shape the agent needs for the next action.</p>

<p>The tradeoff: Chrome DevTools gives richer semantic understanding of page structure. DevSnoop gives the agent what it needs to act. For my workflow — build something, verify it in the browser, fix what’s wrong — acting is what matters.</p>

<h2 id="how-it-works">How it works</h2>

<p>Three pieces:</p>

<ul>
  <li><strong>Chrome extension</strong> — runs on the page, executes commands, captures logs and network requests via Chrome’s debugger API</li>
  <li><strong>Native host</strong> — a compiled binary (no Node/Bun/npm needed on your machine) that bridges HTTP to Chrome’s native messaging</li>
  <li><strong>Skill file</strong> — a markdown doc that teaches the agent all 18 commands with examples</li>
</ul>

<p>Install is one line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://devsnoop.com/install.sh | bash
</code></pre></div></div>

<p>Works with any coding agent that can make HTTP requests — Claude Code, Cursor, Windsurf, Cline, Aider. No special integration needed. Chrome-based, so it works with Arc, Brave, and Edge too.</p>

<h2 id="what-i-use-it-for">What I use it for</h2>

<p>Mostly verification. I make changes, the agent checks the browser to see if they worked. A typical flow:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">page_summary</code> to understand the current state</li>
  <li><code class="language-plaintext highlighter-rouge">click</code> or <code class="language-plaintext highlighter-rouge">fill</code> to interact with something</li>
  <li><code class="language-plaintext highlighter-rouge">get_logs</code> to check for errors</li>
  <li><code class="language-plaintext highlighter-rouge">screenshot</code> to see the result</li>
  <li><code class="language-plaintext highlighter-rouge">diff</code> to track what changed after a hot reload</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">diff</code> command is useful — first call takes a baseline, second call compares against it. The agent can make code changes, wait for hot reload, then check what actually changed in the DOM without re-reading the full page.</p>

<h2 id="pricing">Pricing</h2>

<p>$29, one-time. Launch pricing. No subscription, no usage limits. Works on macOS and Linux (Windows planned).</p>

<p><a href="https://devsnoop.com">DevSnoop</a> is on the <a href="https://chromewebstore.google.com/detail/devsnoop/kkhkcpgpklnofjgnflkciekppiokdilp">Chrome Web Store</a> today.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><category term="Projects" /><summary type="html"><![CDATA[I use coding agents — Claude Code, Codex, Cursor — for most of my development. They’re good at reading and writing code. They’re not good at seeing what’s happening in the browser. The agent generates frontend code and has no way to check if a button is in the right place, if a form submits correctly, or if there’s a console error after the page loads.]]></summary></entry><entry><title type="html">Writing Coding-Agent Skills for External Services</title><link href="https://hboon.com/writing-coding-agent-skills-for-external-services/" rel="alternate" type="text/html" title="Writing Coding-Agent Skills for External Services" /><published>2026-04-23T06:26:00+00:00</published><updated>2026-04-23T06:26:00+00:00</updated><id>https://hboon.com/writing-coding-agent-skills-for-external-services</id><content type="html" xml:base="https://hboon.com/writing-coding-agent-skills-for-external-services/"><![CDATA[<p>Most of my <a href="/skills-are-the-missing-piece-in-my-ai-coding-workflow/">coding-agent skills</a> so far have been for workflow automation — committing, deploying, reviewing. But I recently wrote two skills for external services — <code class="language-plaintext highlighter-rouge">betterstack-log-export</code> and <code class="language-plaintext highlighter-rouge">postmark-setup</code> — and they turned out to work differently.</p>

<h2 id="write-skills-during-the-task-not-before">Write skills during the task, not before</h2>

<p><code class="language-plaintext highlighter-rouge">betterstack-log-export</code> started when I was tracking down why a weekly email report was failing. I needed to filter Better Stack logs by time range and fields like <code class="language-plaintext highlighter-rouge">from</code> and <code class="language-plaintext highlighter-rouge">level</code>. I figured out the right approach — both the manual UI export and the curl/ClickHouse query — and set an in-session reminder to turn it into a skill.</p>

<p><code class="language-plaintext highlighter-rouge">postmark-setup</code> was even more direct. I was deploying <a href="https://devsnoop.com">DevSnoop</a> and needed Postmark servers. I gave the agent my account token, told it to follow the pattern from my existing <code class="language-plaintext highlighter-rouge">betterstack-source-setup</code> skill, and the new skill was written and used in the same session.</p>

<p>I wrote both during or right after the actual task, which meant I already knew what to encode instead of guessing at it.</p>

<h2 id="what-to-put-in-the-skill">What to put in the skill</h2>

<p>Most of the time goes into deciding what belongs in the skill.</p>

<p>For <code class="language-plaintext highlighter-rouge">betterstack-log-export</code>, what mattered:</p>

<ul>
  <li>UI path for one-offs: Telemetry → filter view → gear → Download → NDJSON</li>
  <li>curl path for repeatable queries: Integrations → Connect ClickHouse HTTP client</li>
  <li>EU/US cluster split — which of my projects are on which cluster</li>
  <li>Collection names — <code class="language-plaintext highlighter-rouge">t337893_theblue_logs</code>, <code class="language-plaintext highlighter-rouge">t337893_theblue_s3</code>, etc.</li>
  <li>Credential locations: <code class="language-plaintext highlighter-rouge">~/.config/betterstack/eu.env</code> and <code class="language-plaintext highlighter-rouge">~/.config/betterstack/us.env</code></li>
  <li>Query pattern: UNION ALL over hot + archived logs — not obvious from the docs</li>
  <li><code class="language-plaintext highlighter-rouge">JSONExtract</code> patterns for nested fields (<code class="language-plaintext highlighter-rouge">JSONExtractRaw</code> for nested objects)</li>
  <li>Concurrency limit: Standard tier allows 4 concurrent log queries, so retry on failures</li>
</ul>

<p>None of this is in the Better Stack docs as a package. It’s my account topology, my file layout, and the things I discovered by doing it once.</p>

<p>For <code class="language-plaintext highlighter-rouge">postmark-setup</code>, different knowledge but the same idea:</p>

<ul>
  <li>Create both dev and prod servers, not just one</li>
  <li>Naming convention: <code class="language-plaintext highlighter-rouge">&lt;ProjectName&gt; dev</code> and <code class="language-plaintext highlighter-rouge">&lt;ProjectName&gt; prod</code></li>
  <li>The returned Server API token works as both SMTP username and password — not obvious</li>
  <li>Update <code class="language-plaintext highlighter-rouge">.env</code> for dev, <code class="language-plaintext highlighter-rouge">.env.kamal</code> for prod</li>
  <li>Remind about sender signature/domain verification in the Postmark UI</li>
</ul>

<p>That last one is easy to forget. Without it in the skill, the agent finishes the API work, everything looks fine, and then email silently fails in production.</p>

<h2 id="include-both-the-ui-path-and-the-api-path">Include both the UI path and the API path</h2>

<p><code class="language-plaintext highlighter-rouge">betterstack-log-export</code> covers two ways to get at logs:</p>

<ul>
  <li>Manual/one-off: filter in the UI and download NDJSON from the gear menu</li>
  <li>Repeatable/scripted: query the ClickHouse HTTP API with curl</li>
</ul>

<p>I discovered the UI download first, before reaching for the API. It’s faster when you just need to eyeball what happened. The curl path is for reproducible filters, row counts, or anything you might run again.</p>

<p>An agent that only knows the API will always reach for curl. Sometimes the right answer is downloading 200 rows from the UI and piping them through <code class="language-plaintext highlighter-rouge">jq</code>. Encoding both paths means the agent can pick the right one.</p>

<h2 id="keep-credentials-outside-the-skill">Keep credentials outside the skill</h2>

<p>Both skills store credentials under <code class="language-plaintext highlighter-rouge">~/.config/&lt;service&gt;/</code> and source them at runtime. Nothing in the skill body, nothing in the repo.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">source</span> ~/.config/betterstack/eu.env
</code></pre></div></div>

<p>Skills are text files — they can end up in shared directories, version control, or another agent’s context. Keeping credentials out means you don’t have to think about it when you share or reorganize skills.</p>

<p>The pattern came from <code class="language-plaintext highlighter-rouge">betterstack-source-setup</code>, which predates the log-export skill. When I wrote <code class="language-plaintext highlighter-rouge">postmark-setup</code>, I followed the same layout: <code class="language-plaintext highlighter-rouge">~/.config/postmark/api.env</code>, sourced at runtime.</p>

<h2 id="trim-it-down">Trim it down</h2>

<p>The first version of <code class="language-plaintext highlighter-rouge">betterstack-log-export</code> was much longer than the current one. More explanation, more examples than the agent would ever need. Most of that was useful while I was figuring things out, but the agent at runtime just needs to know which cluster to hit, where the credentials are, and what SQL pattern to use.</p>

<p>I trimmed it in the same session. The current version has one section each for the quick path, clusters, credentials, query pattern, and guardrails.</p>

<h2 id="what-the-agent-cant-get-from-docs">What the agent can’t get from docs</h2>

<p>The agent can read Better Stack’s API docs or Postmark’s SMTP reference on its own. What it can’t figure out is which cluster my project is on, what naming convention I use for servers, or that the returned API token doubles as the SMTP password. That’s the knowledge that gets lost between sessions and re-discovered every time — unless a skill holds it.</p>

<h2 id="use-the-skill-immediately">Use the skill immediately</h2>

<p><code class="language-plaintext highlighter-rouge">postmark-setup</code> was used in the same session it was written — I set up DevSnoop’s dev and prod servers with it right away. If the skill was wrong or incomplete, I’d have found out on the spot.</p>

<p><code class="language-plaintext highlighter-rouge">betterstack-log-export</code> was used within a few sessions, running real queries against real log data. The <code class="language-plaintext highlighter-rouge">JSONExtract</code> patterns were refined once during actual use. The first version is a starting point — it gets better when you exercise it.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[Most of my coding-agent skills so far have been for workflow automation — committing, deploying, reviewing. But I recently wrote two skills for external services — betterstack-log-export and postmark-setup — and they turned out to work differently.]]></summary></entry><entry><title type="html">Claude Code vs Cursor vs Codex: Which AI Agent Should You Use?</title><link href="https://hboon.com/claude-code-vs-cursor-vs-codex-which-ai-agent-should-you-use/" rel="alternate" type="text/html" title="Claude Code vs Cursor vs Codex: Which AI Agent Should You Use?" /><published>2026-04-16T05:22:00+00:00</published><updated>2026-04-16T05:22:00+00:00</updated><id>https://hboon.com/claude-code-vs-cursor-vs-codex-which-ai-agent-should-you-use</id><content type="html" xml:base="https://hboon.com/claude-code-vs-cursor-vs-codex-which-ai-agent-should-you-use/"><![CDATA[<p>I use <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://www.factory.ai/droid">Droid</a>, and <a href="https://github.com/openai/codex">Codex</a> daily across all my projects. I also ship a SaaS starter kit — <a href="https://stacknaut.com">Stacknaut</a> — that comes with an <a href="/how-to-write-an-agents-md-that-actually-works/">AGENTS.md</a> pre-configured for coding agents.</p>

<p>These are practical daily-use observations from working with all three agents on production codebases with skills, project instructions, and defined conventions.</p>

<h2 id="claude-code">Claude Code</h2>

<p>Claude Code is the most capable agent I use for working with a structured codebase. It reads <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> (which I point to <code class="language-plaintext highlighter-rouge">AGENTS.md</code> via <code class="language-plaintext highlighter-rouge">@AGENTS.md</code>) at session start and follows instructions consistently.</p>

<p>What it does well:</p>
<ul>
  <li>Follows AGENTS.md conventions reliably — coding style, commit format, tool preferences</li>
  <li>Reads and understands project structure quickly. Greps for patterns, reads relevant files, builds a mental map</li>
  <li>Handles multi-step tasks well — “add a new API endpoint with tests, types, and a frontend page” works in a single prompt</li>
  <li>Git integration is solid — commits, branches, diffs</li>
  <li><a href="/skills-are-the-missing-piece-in-my-ai-coding-workflow/">Skills</a> work naturally. Trigger a skill, the prompt gets injected, the agent follows it</li>
</ul>

<p>Where it struggles:</p>
<ul>
  <li>Context window fills up fast on large codebases. Long sessions degrade. I start fresh sessions often</li>
  <li>Sometimes over-reads files — pulls in more context than needed, burning through the window</li>
  <li>Can be cautious about running commands, asking for approval when I’d prefer it just goes ahead. I use <a href="/how-i-use-claude-code/"><code class="language-plaintext highlighter-rouge">--dangerously-skip-permissions</code></a> for trusted projects. I used to route Claude Max through Droid via <a href="/using-factory-droid-with-claude-code-max-subscription/">CLIProxyAPI</a> partly because Droid has a stronger permission system — but Anthropic has since restricted Claude Max use with third-party tools</li>
</ul>

<p>With a structured codebase:
Claude Code handles Stacknaut’s monorepo structure (frontend/backend/shared) naturally. It understands the path aliases, knows to run type-check in both packages, and follows the Drizzle ORM patterns without me repeating the rules. The pre-configured AGENTS.md means the first session on a new project based on Stacknaut is already productive — no warmup needed.</p>

<h2 id="codex">Codex</h2>

<p>Codex is OpenAI’s open source agent. I use it for reviewing code that Claude Code wrote, bug fixing, and tackling tasks where I want a different perspective.</p>

<p>What it does well:</p>
<ul>
  <li>Fast for targeted tasks. “Review this file for issues” or “refactor this function” — Codex is snappy</li>
  <li>Good at catching things other agents missed. Different model, different blind spots</li>
  <li>Reads AGENTS.md and follows basic conventions</li>
  <li>The sandbox is a nice safety net — commands run in an isolated environment by default</li>
  <li>Open source, so I can see exactly what it’s doing</li>
</ul>

<p>Where it struggles:</p>
<ul>
  <li>Less capable at multi-step agentic workflows than Claude Code. It handles simpler task chains better than complex ones</li>
  <li>Skills work but less polished than Droid and Claude Code — I <a href="/skills-are-the-missing-piece-in-my-ai-coding-workflow/">share skills across all three</a>, though Codex sometimes needs more nudging to follow them</li>
  <li>The sandbox, while safe, sometimes prevents it from doing things I want — accessing the network, running the dev server, interacting with Docker</li>
</ul>

<p>With a structured codebase:
Codex works well for targeted edits within Stacknaut — fixing a bug, adding a field, updating a component. For bigger tasks like “add a new billing plan with Stripe integration across frontend, backend, and shared types,” I reach for Claude Code. Codex tends to need more prompting to coordinate across a monorepo.</p>

<h2 id="cursor">Cursor</h2>

<p>Cursor is the best IDE-embedded agent, but I <a href="/what-coding-agent-should-you-use/">keep coming back to terminal agents</a>.</p>

<p>What it does well:</p>
<ul>
  <li>Tab completion is genuinely good for small, predictive edits while you’re actively writing code</li>
  <li>Inline diffs are nice to review — you see the changes in context without switching tools</li>
  <li>Reads project rules (<code class="language-plaintext highlighter-rouge">.cursor/rules</code>, <code class="language-plaintext highlighter-rouge">AGENTS.md</code>) and follows conventions</li>
  <li>The Composer/Agent mode handles multi-file edits within the IDE</li>
  <li>Background agents and Bugbot for automated tasks</li>
</ul>

<p>Where it struggles:</p>
<ul>
  <li>Primarily an editor experience. Cursor has a CLI and background agents now, but the core workflow is still VS Code. I use WebStorm and Neovim — Cursor means giving those up</li>
  <li>Parallel sessions are less natural. Background agents help, but with terminal agents I run 3-5 in <a href="/using-tmux-with-claude-code/">tmux</a> and coordinate between them. That’s harder to replicate in an editor</li>
  <li>Project rules work for conventions, but there’s no skills system like Claude Code or Droid have — small, portable prompts I can trigger on demand and <a href="/skills-are-the-missing-piece-in-my-ai-coding-workflow/">share across agents</a></li>
</ul>

<p>With a structured codebase:
Cursor handles a starter kit fine for single-file edits. Where it falls short is the agentic workflow I actually use — having an agent autonomously implement a feature across the monorepo, run tests, check types, fix errors, and commit. That workflow needs a terminal agent that can loop independently.</p>

<h2 id="droid">Droid</h2>

<p>Droid was my primary agent. It reads both <code class="language-plaintext highlighter-rouge">AGENTS.md</code> and <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, supports skills and custom droids, and has good context management.</p>

<p>What it does well:</p>
<ul>
  <li>Model-agnostic — I can use different models for different tasks</li>
  <li>Skills and custom droids work well for repeatable workflows. I have droids for review, exploration, and specific project tasks</li>
  <li>Spec mode lets me plan before coding — useful for complex features where I want to review the approach before the agent starts writing</li>
  <li>Sub-agents via the Task tool — delegate subtasks to separate instances</li>
  <li>Good at following AGENTS.md conventions, especially with project-specific custom droids</li>
</ul>

<p>Where it struggles:</p>
<ul>
  <li>Newer than Claude Code, so the ecosystem and documentation are still growing</li>
  <li>Some rough edges in session management compared to Claude Code</li>
</ul>

<p>With a structured codebase:
Droid works particularly well with Stacknaut because I can create project-specific droids that know the codebase deeply. A custom droid configured for “add a new API endpoint” knows the exact file patterns, the route structure, the type definitions, and the test setup. It goes beyond what AGENTS.md alone provides.</p>

<h2 id="how-i-actually-use-them-together">How I Actually Use Them Together</h2>

<p>I don’t pick one agent. I use all three:</p>

<ul>
  <li><strong>Claude Code</strong> for primary development — implementing features, working through complex tasks, using skills for commit/review/deploy workflows</li>
  <li><strong>Droid</strong> for an alternative perspective and when I want spec mode or custom droids for specific workflows</li>
  <li><strong>Codex</strong> for review — I have it check what the other agents wrote. Different model catches different issues</li>
</ul>

<p>The shared AGENTS.md means all three agents follow the same conventions. The code they produce is consistent regardless of which agent wrote it. That’s the whole point of having project instructions — it normalizes the output across agents.</p>

<h2 id="which-should-you-pick">Which Should You Pick?</h2>

<p>If you’re working with a structured codebase — starter kit or not — start with Claude Code. It’s the most capable, most polished, and the AGENTS.md support is mature.</p>

<p>Add Codex as a reviewer. Having a second agent review the first agent’s work is one of the most reliable quality improvements I’ve found.</p>

<p>If you want skills and custom agents, try Droid. Project-specific droids that know your exact patterns go beyond what AGENTS.md alone provides.</p>

<p>Cursor is fine if you prefer staying in VS Code. It’ll follow your project conventions. But you’ll miss the composability and parallel sessions of terminal agents.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[I use Claude Code, Droid, and Codex daily across all my projects. I also ship a SaaS starter kit — Stacknaut — that comes with an AGENTS.md pre-configured for coding agents.]]></summary></entry><entry><title type="html">Don’t Let the LLM Verify. Make It Build the Verifier.</title><link href="https://hboon.com/dont-let-the-llm-verify-make-it-build-the-verifier/" rel="alternate" type="text/html" title="Don’t Let the LLM Verify. Make It Build the Verifier." /><published>2026-04-11T03:30:00+00:00</published><updated>2026-04-11T03:30:00+00:00</updated><id>https://hboon.com/dont-let-the-llm-verify-make-it-build-the-verifier</id><content type="html" xml:base="https://hboon.com/dont-let-the-llm-verify-make-it-build-the-verifier/"><![CDATA[<p>Someone posted about asking Claude to generate an HTML report from a JSON file, then spawning 4 agents to test it. All 4 reported success. Manual testing showed 60%+ failure — hallucinated selectors, fake IDs, wrong values.</p>

<p>When you ask an LLM to “check if this is correct,” it predicts what a correct-sounding check looks like. That’s not the same as actually checking.</p>

<h2 id="what-i-do-instead">What I do instead</h2>

<p>I tell the agent to write a script that performs the check, then run the script. Not through the LLM — just execute it.</p>

<p>The obvious examples: lint and formatting. I don’t ask Claude “is this formatted correctly?” I have it run <code class="language-plaintext highlighter-rouge">eslint</code> and <code class="language-plaintext highlighter-rouge">prettier</code>. The tool tells me if it passes or not.</p>

<p>This works for ad-hoc checks too. Say I need to verify an HTML report pulls the right values from a JSON source. I tell Claude to write a script that parses the JSON, queries the DOM with a real parser, compares expected vs. actual, and prints mismatches. Then run it. Same input, same output every time.</p>

<p>The pattern applies anywhere there’s a ground truth to check against — data validation, math, DOM structure, spelling, broken links. All of these have real tools or can be checked with a short script. The LLM writes the script. The script does the verification.</p>

<p>It’s also cheaper. The script runs without burning tokens, and once you have it, you can improve it and rerun it as many times as you want. Having the LLM re-check means paying for a fresh prediction every time — and getting a different answer each time too.</p>

<h2 id="why-more-agents-dont-help">Why more agents don’t help</h2>

<p>Four agents running the same prediction process give you four predictions. If the model hallucinates a selector, it hallucinates it four times. More agents just means synchronized fiction.</p>

<p>Agents help when each one runs a real tool and reports the output. The agent orchestrates. The tool verifies.</p>]]></content><author><name></name></author><category term="AI" /><summary type="html"><![CDATA[Someone posted about asking Claude to generate an HTML report from a JSON file, then spawning 4 agents to test it. All 4 reported success. Manual testing showed 60%+ failure — hallucinated selectors, fake IDs, wrong values.]]></summary></entry><entry><title type="html">Two Ways to Direct Coding Agents</title><link href="https://hboon.com/two-ways-to-direct-coding-agents/" rel="alternate" type="text/html" title="Two Ways to Direct Coding Agents" /><published>2026-04-11T03:21:00+00:00</published><updated>2026-04-11T03:21:00+00:00</updated><id>https://hboon.com/two-ways-to-direct-coding-agents</id><content type="html" xml:base="https://hboon.com/two-ways-to-direct-coding-agents/"><![CDATA[<p>I work with coding agents in two modes. For larger features, I write a detailed spec before any code. For smaller tasks, I skip the spec and set guardrails instead. Both work — they solve different problems.</p>

<h2 id="full-spec">Full Spec</h2>

<p>For new features or anything with non-obvious architectural decisions, I write everything out first — data flow, DB schema, API shape, edge cases. I have a <a href="/build-a-spec-skill-for-your-coding-agent/">spec skill</a> that interviews me about all of this before any code gets written. What comes out is a document like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>## Data Model
- users table with email, hashed_password, created_at
- sessions table with user_id, token, expires_at
- No soft deletes — hard delete on account removal

## API
- POST /auth/register — validate, hash, insert, return session token
- POST /auth/login — verify, create session, return token
- Auth middleware checks session token on protected routes

## Edge Cases
- Duplicate email returns 409, not a generic error
- Expired sessions return 401, frontend sends user to login
</code></pre></div></div>

<p>The agent follows this. It doesn’t get to suggest alternatives or rethink things during execution. This eliminates drift — there’s no room to wander.</p>

<h2 id="guardrails-instead-of-a-spec">Guardrails Instead of a Spec</h2>

<p>For smaller tasks — bug fixes, refactors, wiring up something that follows an existing pattern — I skip the full spec. I describe the problem and add constraints. A real prompt looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The /auth/login endpoint returns 500 when the email doesn't exist.
It should return 401 with { error: "invalid_credentials" }.

Constraints:
- Do not modify the database schema
- Do not change more than 20 lines
- Keep changes in src/routes/auth.ts
- Run the existing auth tests after fixing
</code></pre></div></div>

<p>The constraints keep the agent from over-engineering it. “Do not modify the database schema” prevents the agent from deciding it needs a <code class="language-plaintext highlighter-rouge">login_attempts</code> table. “Keep changes in <code class="language-plaintext highlighter-rouge">src/routes/auth.ts</code>” stops it from refactoring the error handling across three files.</p>

<p>I couple these with validation — linting, type checks, tests — to catch anything that goes outside the boundaries.</p>

<h2 id="when-i-use-which">When I Use Which</h2>

<p>New system, multiple services, design decisions that affect the whole project? Full spec. A bug in a well-understood module, or a refactor that follows an existing pattern? Guardrails and go.</p>

<p>The gray area is medium-sized tasks — adding a feature to an existing system where the pattern is clear but there are a few decisions to make. I usually start with guardrails and tighten them if the agent drifts. If I find myself adding more than four or five constraints, that’s a sign I should write a spec instead.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[I work with coding agents in two modes. For larger features, I write a detailed spec before any code. For smaller tasks, I skip the spec and set guardrails instead. Both work — they solve different problems.]]></summary></entry><entry><title type="html">Why I Self-Host My SaaS Apps</title><link href="https://hboon.com/why-i-self-host-my-saas-apps/" rel="alternate" type="text/html" title="Why I Self-Host My SaaS Apps" /><published>2026-04-07T04:16:00+00:00</published><updated>2026-04-07T04:16:00+00:00</updated><id>https://hboon.com/why-i-self-host-my-saas-apps</id><content type="html" xml:base="https://hboon.com/why-i-self-host-my-saas-apps/"><![CDATA[<p>I run four apps — <a href="https://myog.social">MyOG.social</a>, <a href="https://stacknaut.com">Stacknaut.com</a>, <a href="https://altcaption.com">AltCaption.com</a>, and <a href="https://tolocaltime.com">ToLocaltime.com</a> — on a single ~$30/month Hetzner ARM server. <a href="https://theblue.social">TheBlue.social</a> is older and still runs on Render. I used Heroku years ago, then Render for a couple of years. Both were fine for deployment. I switched to self-hosting for cost, control, and performance.</p>

<h2 id="cost">Cost</h2>

<p>~$30/month for four apps with PostgreSQL, background workers, and the whole stack on one server.</p>

<p>The cost stays flat. More traffic doesn’t change my bill — I’m paying for the server, not per-request or per-seat. If I need more capacity, I bump to the next server tier. Still cheaper than any PaaS.</p>

<h2 id="control">Control</h2>

<p>On my server, I pick the OS, the Docker version, the PostgreSQL config, the backup schedule, the firewall rules. And I make those decisions once — they’re encoded in <a href="/terraform-for-indie-hackers-just-enough-infrastructure-as-code/">Terraform</a> and <a href="/one-command-deploy-how-kamal-2-changed-how-i-ship/">Kamal</a> configs. Spinning up a new project is the same setup, same commands. I’m not dependent on a platform’s roadmap or pricing decisions.</p>

<h2 id="performance">Performance</h2>

<p>My app and database run on the same machine. Database queries are sub-millisecond — no network hop. On most PaaS setups, the database is a separate service with network overhead on every query.</p>

<p>The ~$30 server gives me dedicated vCPUs and RAM.</p>

<h2 id="devops-in-2026">DevOps in 2026</h2>

<p>Setting up a server in 2015 meant configuring Nginx, managing SSL certificates by hand, writing systemd service files, and hoping you didn’t miss a security update.</p>

<p>Now it’s Docker and Kamal. I define my app in a Dockerfile, my infrastructure in Terraform, and my deployment in a Kamal config. One command provisions the server. One command deploys the app. SSL is automatic via Let’s Encrypt through kamal-proxy.</p>

<p>I spend maybe 30 minutes a month on maintenance — OS updates, checking disk space, reviewing logs. My <a href="/setting-up-a-telegram-bot-for-system-notifications/">Telegram bot</a> pings me if anything needs attention.</p>

<h2 id="scaling">Scaling</h2>

<p>A single Hetzner ARM server handles far more traffic than most indie SaaS apps will see. PostgreSQL on one server can do thousands of queries per second.</p>

<p>If I outgrow one server — a great problem to have — Kamal supports multi-server deployments. Move the database to its own box, add a second web server, and kamal-proxy load-balances between them.</p>

<h2 id="what-i-self-host-and-dont">What I Self-Host (and Don’t)</h2>

<ul>
  <li>MyOG.social, Stacknaut.com, AltCaption.com, ToLocaltime.com — Vue frontends, Fastify APIs, PostgreSQL, background workers</li>
  <li>Automated daily PostgreSQL dumps to object storage</li>
  <li>Monitoring via Uptime Robot and PostHog</li>
</ul>

<p>What I don’t self-host:</p>

<ul>
  <li>This blog — Jekyll on GitHub Pages, because it’s static and free</li>
  <li>Email — transactional email goes through a service. Self-hosting email is a deliverability nightmare</li>
  <li>CDN/edge — Cloudflare sits in front of the server for caching and DDoS protection</li>
</ul>

<h2 id="getting-started">Getting Started</h2>

<p>I wrote about the specific tools in detail:</p>

<ul>
  <li><a href="/terraform-for-indie-hackers-just-enough-infrastructure-as-code/">Terraform setup</a> — provision a Hetzner server with four files</li>
  <li><a href="/one-command-deploy-how-kamal-2-changed-how-i-ship/">Kamal deployment</a> — zero-downtime deploys with one command</li>
</ul>

<p>The whole thing took about two days the first time. Subsequent projects take an hour or two — and most of that time is setting up API keys for external services, not the hosting itself. After that, every deploy is <code class="language-plaintext highlighter-rouge">kamal deploy</code>.</p>]]></content><author><name></name></author><category term="Web" /><category term="Tools" /><summary type="html"><![CDATA[I run four apps — MyOG.social, Stacknaut.com, AltCaption.com, and ToLocaltime.com — on a single ~$30/month Hetzner ARM server. TheBlue.social is older and still runs on Render. I used Heroku years ago, then Render for a couple of years. Both were fine for deployment. I switched to self-hosting for cost, control, and performance.]]></summary></entry><entry><title type="html">Terraform for Indie Hackers: Just Enough Infrastructure as Code</title><link href="https://hboon.com/terraform-for-indie-hackers-just-enough-infrastructure-as-code/" rel="alternate" type="text/html" title="Terraform for Indie Hackers: Just Enough Infrastructure as Code" /><published>2026-04-06T07:09:00+00:00</published><updated>2026-04-06T07:09:00+00:00</updated><id>https://hboon.com/terraform-for-indie-hackers-just-enough-infrastructure-as-code</id><content type="html" xml:base="https://hboon.com/terraform-for-indie-hackers-just-enough-infrastructure-as-code/"><![CDATA[<p>I use Terraform and Kamal 2 to provision and deploy my SaaS apps. The main reason is cost control — hosting one app on a PaaS like Render or Vercel can be fine, but it gets tough when I’m experimenting with a few of them and they don’t all make money yet.</p>

<h2 id="why-bother">Why Bother</h2>

<p>With Terraform, the entire server setup is a file. Run <code class="language-plaintext highlighter-rouge">terraform apply</code>, get the exact same server. Same config, same firewall rules, same SSH keys.</p>

<h2 id="the-minimal-setup">The Minimal Setup</h2>

<p>My Terraform config for a single Hetzner server running a SaaS app:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>infra/
  main.tf          # provider and server resources
  variables.tf     # configurable values
  outputs.tf       # values to display after apply
  terraform.tfvars # actual values (not committed)
</code></pre></div></div>

<p>Four files. That’s it.</p>

<h3 id="provider-setup">Provider Setup</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">terraform</span> <span class="p">{</span>
  <span class="nx">required_providers</span> <span class="p">{</span>
    <span class="nx">hcloud</span> <span class="p">=</span> <span class="p">{</span>
      <span class="nx">source</span>  <span class="p">=</span> <span class="s2">"hetznercloud/hcloud"</span>
      <span class="nx">version</span> <span class="p">=</span> <span class="s2">"~&gt; 1.45"</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">provider</span> <span class="s2">"hcloud"</span> <span class="p">{</span>
  <span class="nx">token</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">hcloud_token</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="the-server">The Server</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"hcloud_server"</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">name</span>        <span class="p">=</span> <span class="s2">"myapp-prod"</span>
  <span class="nx">image</span>       <span class="p">=</span> <span class="s2">"ubuntu-24.04"</span>
  <span class="nx">server_type</span> <span class="p">=</span> <span class="s2">"cax21"</span>
  <span class="nx">location</span>    <span class="p">=</span> <span class="s2">"fsn1"</span>
  <span class="nx">ssh_keys</span>    <span class="p">=</span> <span class="p">[</span><span class="nx">hcloud_ssh_key</span><span class="err">.</span><span class="nx">default</span><span class="err">.</span><span class="nx">id</span><span class="p">]</span>

  <span class="nx">user_data</span> <span class="p">=</span> <span class="nx">file</span><span class="err">(</span><span class="s2">"cloud-init.yml"</span><span class="err">)</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"hcloud_ssh_key"</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">name</span>       <span class="p">=</span> <span class="s2">"default"</span>
  <span class="nx">public_key</span> <span class="p">=</span> <span class="nx">file</span><span class="err">(</span><span class="s2">"~/.ssh/id_ed25519.pub"</span><span class="err">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cax21</code> is the ARM instance — 4 vCPU, 8GB RAM, ~€8/month. <code class="language-plaintext highlighter-rouge">user_data</code> is a cloud-init script that runs on first boot.</p>

<h3 id="cloud-init-server-bootstrap">Cloud-Init: Server Bootstrap</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#cloud-config</span>
<span class="na">packages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">docker.io</span>
  <span class="pi">-</span> <span class="s">docker-compose-plugin</span>
  <span class="pi">-</span> <span class="s">fail2ban</span>
  <span class="pi">-</span> <span class="s">ufw</span>

<span class="na">runcmd</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">systemctl enable docker</span>
  <span class="pi">-</span> <span class="s">systemctl start docker</span>
  <span class="pi">-</span> <span class="s">ufw allow 22/tcp</span>
  <span class="pi">-</span> <span class="s">ufw allow 80/tcp</span>
  <span class="pi">-</span> <span class="s">ufw allow 443/tcp</span>
  <span class="pi">-</span> <span class="s">ufw --force enable</span>
  <span class="pi">-</span> <span class="s">fallocate -l 2G /swapfile</span>
  <span class="pi">-</span> <span class="s">chmod 600 /swapfile</span>
  <span class="pi">-</span> <span class="s">mkswap /swapfile</span>
  <span class="pi">-</span> <span class="s">swapon /swapfile</span>
  <span class="pi">-</span> <span class="s">echo '/swapfile none swap sw 0 0' &gt;&gt; /etc/fstab</span>
</code></pre></div></div>

<p>Installs Docker, sets up the firewall (SSH, HTTP, HTTPS only), enables fail2ban, and creates swap. When the server boots, it’s ready for Kamal to deploy to.</p>

<h3 id="variables">Variables</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"hcloud_token"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="p">=</span> <span class="s2">"Hetzner API token"</span>
  <span class="nx">sensitive</span>   <span class="p">=</span> <span class="kc">true</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The actual token goes in <code class="language-plaintext highlighter-rouge">terraform.tfvars</code> (never committed):</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">hcloud_token</span> <span class="err">=</span> <span class="s2">"your-token-here"</span>
</code></pre></div></div>

<h3 id="outputs">Outputs</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">output</span> <span class="s2">"server_ip"</span> <span class="p">{</span>
  <span class="nx">value</span> <span class="p">=</span> <span class="nx">hcloud_server</span><span class="err">.</span><span class="nx">app</span><span class="err">.</span><span class="nx">ipv4_address</span>
<span class="p">}</span>
</code></pre></div></div>

<p>After <code class="language-plaintext highlighter-rouge">terraform apply</code>, it prints the server IP. I copy that into Kamal’s <code class="language-plaintext highlighter-rouge">deploy.yml</code> and deploy.</p>

<h2 id="the-workflow">The Workflow</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>infra
terraform init      <span class="c"># first time only</span>
terraform plan      <span class="c"># preview what will change</span>
terraform apply     <span class="c"># create/update the server</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">terraform plan</code> shows exactly what will be created, changed, or destroyed before you confirm.</p>

<p>For a fresh project:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">terraform apply</code> — creates the server</li>
  <li>Copy the server IP to Kamal’s <code class="language-plaintext highlighter-rouge">deploy.yml</code></li>
  <li><code class="language-plaintext highlighter-rouge">kamal setup</code> — first deploy, sets up kamal-proxy and containers</li>
  <li><code class="language-plaintext highlighter-rouge">kamal deploy</code> — subsequent deploys</li>
</ol>

<h2 id="dns-records">DNS Records</h2>

<p>I manage DNS through Cloudflare and add those records to Terraform too:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"cloudflare_dns_record"</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">zone_id</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">cloudflare_zone_id</span>
  <span class="nx">name</span>    <span class="p">=</span> <span class="s2">"myapp.com"</span>
  <span class="nx">content</span> <span class="p">=</span> <span class="nx">hcloud_server</span><span class="err">.</span><span class="nx">app</span><span class="err">.</span><span class="nx">ipv4_address</span>
  <span class="nx">type</span>    <span class="p">=</span> <span class="s2">"A"</span>
  <span class="nx">proxied</span> <span class="p">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"cloudflare_dns_record"</span> <span class="s2">"www"</span> <span class="p">{</span>
  <span class="nx">zone_id</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">cloudflare_zone_id</span>
  <span class="nx">name</span>    <span class="p">=</span> <span class="s2">"www"</span>
  <span class="nx">content</span> <span class="p">=</span> <span class="s2">"myapp.com"</span>
  <span class="nx">type</span>    <span class="p">=</span> <span class="s2">"CNAME"</span>
  <span class="nx">proxied</span> <span class="p">=</span> <span class="kc">true</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">terraform apply</code> creates the server and points the domain at it. One command.</p>

<h2 id="firewall-rules">Firewall Rules</h2>

<p>Hetzner has cloud firewalls, also manageable via Terraform:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"hcloud_firewall"</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">name</span> <span class="p">=</span> <span class="s2">"app-firewall"</span>

  <span class="nx">rule</span> <span class="p">{</span>
    <span class="nx">direction</span> <span class="p">=</span> <span class="s2">"in"</span>
    <span class="nx">protocol</span>  <span class="p">=</span> <span class="s2">"tcp"</span>
    <span class="nx">port</span>      <span class="p">=</span> <span class="s2">"22"</span>
    <span class="nx">source_ips</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">,</span> <span class="s2">"::/0"</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">rule</span> <span class="p">{</span>
    <span class="nx">direction</span> <span class="p">=</span> <span class="s2">"in"</span>
    <span class="nx">protocol</span>  <span class="p">=</span> <span class="s2">"tcp"</span>
    <span class="nx">port</span>      <span class="p">=</span> <span class="s2">"80"</span>
    <span class="nx">source_ips</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">,</span> <span class="s2">"::/0"</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">rule</span> <span class="p">{</span>
    <span class="nx">direction</span> <span class="p">=</span> <span class="s2">"in"</span>
    <span class="nx">protocol</span>  <span class="p">=</span> <span class="s2">"tcp"</span>
    <span class="nx">port</span>      <span class="p">=</span> <span class="s2">"443"</span>
    <span class="nx">source_ips</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">,</span> <span class="s2">"::/0"</span><span class="p">]</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"hcloud_firewall_attachment"</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">firewall_id</span> <span class="p">=</span> <span class="nx">hcloud_firewall</span><span class="err">.</span><span class="nx">app</span><span class="err">.</span><span class="nx">id</span>
  <span class="nx">server_ids</span>  <span class="p">=</span> <span class="p">[</span><span class="nx">hcloud_server</span><span class="err">.</span><span class="nx">app</span><span class="err">.</span><span class="nx">id</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Defense in depth — the cloud firewall blocks traffic before it reaches the server, UFW on the server is the second layer.</p>

<h2 id="state-management">State Management</h2>

<p>Terraform tracks what it created in a state file (<code class="language-plaintext highlighter-rouge">terraform.tfstate</code>). This maps your config to real resources — it knows <code class="language-plaintext highlighter-rouge">hcloud_server.app</code> is server ID 12345678 on Hetzner.</p>

<p>For one or two servers, the local state file is fine. Keep it out of version control (it contains sensitive data) and back it up. Lose the state file and Terraform doesn’t know what it created — you’d have to import resources manually or start fresh.</p>

<p>For remote state shared across machines, Terraform supports S3-compatible backends. Hetzner doesn’t have one, but Cloudflare R2 works:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">terraform</span> <span class="p">{</span>
  <span class="nx">backend</span> <span class="s2">"s3"</span> <span class="p">{</span>
    <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">"terraform-state"</span>
    <span class="nx">key</span>    <span class="p">=</span> <span class="s2">"myapp/terraform.tfstate"</span>
    <span class="nx">region</span> <span class="p">=</span> <span class="s2">"auto"</span>
    <span class="nx">endpoints</span> <span class="p">=</span> <span class="p">{</span>
      <span class="nx">s3</span> <span class="p">=</span> <span class="s2">"https://ACCOUNT_ID.r2.cloudflarestorage.com"</span>
    <span class="p">}</span>
    <span class="nx">skip_credentials_validation</span> <span class="p">=</span> <span class="kc">true</span>
    <span class="nx">skip_metadata_api_check</span>     <span class="p">=</span> <span class="kc">true</span>
    <span class="nx">skip_requesting_account_id</span>  <span class="p">=</span> <span class="kc">true</span>
    <span class="nx">skip_region_validation</span>      <span class="p">=</span> <span class="kc">true</span>
    <span class="nx">skip_s3_checksum</span>            <span class="p">=</span> <span class="kc">true</span>
    <span class="nx">use_path_style</span>              <span class="p">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I keep the state file locally and back it up. Remote state is more complexity than I need.</p>

<h2 id="what-i-dont-use-terraform-for">What I Don’t Use Terraform For</h2>

<ul>
  <li>Application deployment — that’s Kamal’s job</li>
  <li>Database management — PostgreSQL runs in a Docker container managed by Kamal</li>
  <li>SSL certificates — Let’s Encrypt via kamal-proxy, also Kamal</li>
  <li>Monitoring — I stream logs to BetterStack</li>
</ul>

<p>Terraform provisions the box. Everything that runs on it is managed by other tools.</p>

<h2 id="getting-started">Getting Started</h2>

<ol>
  <li>Install Terraform (<code class="language-plaintext highlighter-rouge">brew install terraform</code> on macOS)</li>
  <li>Get a Hetzner API token from the Cloud Console</li>
  <li>Create the four files above, adjusted for your server type and SSH key</li>
  <li>Run <code class="language-plaintext highlighter-rouge">terraform init &amp;&amp; terraform apply</code></li>
  <li>Point your domain at the server IP</li>
  <li>Deploy your app with Kamal</li>
</ol>

<p>That’s it. One server, one config, one command. Add complexity later if you need it — but for a SaaS serving hundreds or thousands of users, this is more than enough.</p>]]></content><author><name></name></author><category term="Tools" /><category term="Web" /><summary type="html"><![CDATA[I use Terraform and Kamal 2 to provision and deploy my SaaS apps. The main reason is cost control — hosting one app on a PaaS like Render or Vercel can be fine, but it gets tough when I’m experimenting with a few of them and they don’t all make money yet.]]></summary></entry></feed>