<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rubarajan</title>
    <description>The latest articles on DEV Community by Rubarajan (@rubarajan).</description>
    <link>https://dev.to/rubarajan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F147488%2F9d879572-2931-4711-aba4-18182008499f.jpg</url>
      <title>DEV Community: Rubarajan</title>
      <link>https://dev.to/rubarajan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rubarajan"/>
    <language>en</language>
    <item>
      <title>I Moved My Portfolio Off a Raspberry Pi to Cloudflare Workers</title>
      <dc:creator>Rubarajan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 02:54:51 +0000</pubDate>
      <link>https://dev.to/rubarajan/i-moved-my-portfolio-off-a-raspberry-pi-to-cloudflare-workers-l9m</link>
      <guid>https://dev.to/rubarajan/i-moved-my-portfolio-off-a-raspberry-pi-to-cloudflare-workers-l9m</guid>
      <description>&lt;p&gt;For about a year, my portfolio at &lt;a href="https://rubarajan.dev" rel="noopener noreferrer"&gt;rubarajan.dev&lt;/a&gt; was served by nginx on a Raspberry Pi sitting in my house, exposed to the internet through a Cloudflare Tunnel. Deploying meant running an &lt;code&gt;scp&lt;/code&gt; script from my laptop.&lt;/p&gt;

&lt;p&gt;It worked. That's the thing — it genuinely worked, for a year. But the site is 500 KB of static HTML, CSS, and fonts. No backend. No build step. No database. There was nothing about it that needed a computer in my living room.&lt;/p&gt;

&lt;p&gt;So I moved it to Cloudflare Workers. The migration itself is genuinely simple — point a config file at a folder and push. What made it worth writing about is everything &lt;em&gt;around&lt;/em&gt; it: a performance assumption that turned out to be half wrong, an image that had been silently broken for months, and a &lt;code&gt;www&lt;/code&gt; subdomain that took an hour and three separate failure modes to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest "why"
&lt;/h2&gt;

&lt;p&gt;Let me start with the part most migration posts get wrong, because I got it wrong too.&lt;/p&gt;

&lt;p&gt;My first instinct was: &lt;em&gt;"the Pi gives me zero CDN benefit, every request travels to my house."&lt;/em&gt; I checked before believing myself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://rubarajan.dev/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cf-cache-status: DYNAMIC
Cache-Control: no-cache
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed! Except that was only the HTML document. When I checked the actual assets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;&lt;code&gt;cf-cache-status&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/&lt;/code&gt; (HTML)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DYNAMIC&lt;/code&gt;, &lt;code&gt;no-cache&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/styles.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;REVALIDATED&lt;/code&gt;, &lt;code&gt;max-age=14400&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/assets/fonts/*.woff2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;immutable&lt;/code&gt;, age 6 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My fonts and CSS were &lt;strong&gt;already&lt;/strong&gt; edge-cached. Only the HTML document round-tripped to the Pi. My dramatic framing was wrong.&lt;/p&gt;

&lt;p&gt;The real case was narrower but still solid:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The HTML document is the critical path for first byte.&lt;/strong&gt; Every visitor waited on my home internet before a single asset could even start loading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability was 100% Pi-dependent.&lt;/strong&gt; Power cut, ISP blip, SD card wear, a clock drift bug that had already bitten me once — any of those and the document 502s. Cached fonts don't save a page that never loads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worth stating plainly: if you're going to migrate for performance reasons, measure first. You might find half your assumption is already false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cutover risk was zero, which surprised me
&lt;/h2&gt;

&lt;p&gt;My domain was already on Cloudflare — that's how the tunnel worked. So both apex and &lt;code&gt;www&lt;/code&gt; already resolved to Cloudflare anycast IPs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;nslookup example.com
Addresses:  &amp;lt;Cloudflare anycast addresses&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare was &lt;strong&gt;already the front door&lt;/strong&gt;. Migrating only changed what happened &lt;em&gt;behind&lt;/em&gt; their edge. The visitor-facing addresses never changed — no propagation window, no TTL waiting, no split-brain where some users hit the Pi and others hit the new setup.&lt;/p&gt;

&lt;p&gt;If you're already proxying through Cloudflare, your migration is far lower-risk than it feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decisions before writing any config
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Workers Static Assets, not Pages.&lt;/strong&gt; Cloudflare Pages is still supported, but &lt;a href="https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/" rel="noopener noreferrer"&gt;Cloudflare's own guidance&lt;/a&gt; is that new projects should use Workers — that's where the investment goes. Starting on Pages today means migrating later for no reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No GitHub Actions.&lt;/strong&gt; I planned to write a workflow. I didn't need one. Cloudflare has native Git integration: connect the repo, and every push to &lt;code&gt;main&lt;/code&gt; deploys. No workflow file, no API token in secrets, no CI minutes. For a site with no build step, Actions is a moving part that buys nothing. Add it the day you actually have a build.&lt;/p&gt;

&lt;p&gt;The entire config is nine lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrangler.jsonc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-site"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./public"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;main&lt;/code&gt; entry. A purely static site needs no Worker script at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 1: everything in your assets directory is public
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;"directory": "./public"&lt;/code&gt; matters more than it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers serves every file in the assets directory.&lt;/strong&gt; My repo root contained a deploy script with my Pi's SSH target in it. Had I pointed &lt;code&gt;assets.directory&lt;/code&gt; at the repo root, that file would have been fetchable at &lt;code&gt;example.com/deploy.ps1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So: site files go in &lt;code&gt;public/&lt;/code&gt;, everything else stays outside it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/              &amp;lt;- ONLY this is servable
  index.html
  styles.css
  assets/
wrangler.jsonc       &amp;lt;- not servable
.env                 &amp;lt;- not servable
deploy.ps1           &amp;lt;- not servable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then &lt;strong&gt;verify it&lt;/strong&gt;, rather than trusting the layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;deploy.ps1 .env wrangler.jsonc .gitignore&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; https://example.com/&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;404  deploy.ps1
404  .env
404  wrangler.jsonc
404  .gitignore
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is now a permanent check in my deploy notes. It takes four seconds and it's the difference between "I think it's fine" and "I know it's fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: my hotlinked images had already broken
&lt;/h2&gt;

&lt;p&gt;My site showed logos for four companies I've worked at, each hotlinked straight from the employer's own CDN. I'd never thought about it again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;u &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXL_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BNY_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COGNIZANT_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TCS_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;200
200
200
403   &amp;lt;- TCS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One had been returning &lt;strong&gt;403&lt;/strong&gt; — hotlink protection — for who knows how long. My live site had a broken image and I had no idea.&lt;/p&gt;

&lt;p&gt;This is worth internalizing beyond my specific case: &lt;strong&gt;hotlinked assets are an availability dependency on someone else's infrastructure.&lt;/strong&gt; I was migrating &lt;em&gt;for&lt;/em&gt; reliability while four images sat hostage to four other companies' CDNs. Any of them can add hotlink protection on a random Tuesday.&lt;/p&gt;

&lt;p&gt;Downloading them into &lt;code&gt;assets/logos/&lt;/code&gt; fixed the broken one, removed four third-party dependencies, and stopped leaking visitor IPs to those companies. Should have done it years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: an SVG without a viewBox won't scale
&lt;/h2&gt;

&lt;p&gt;The TCS logo needed a new source. I found one on Wikimedia Commons, dropped it in, and then noticed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt;
   &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"956.69287"&lt;/span&gt;
   &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"342.99213"&lt;/span&gt;
   &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"svg2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;, but &lt;strong&gt;no &lt;code&gt;viewBox&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That combination breaks CSS sizing. With &lt;code&gt;style="height:30px"&lt;/code&gt; and no viewBox, the SVG's internal coordinate system stays fixed at 956×343 user units while the viewport shrinks to 30px tall — so you see the top-left corner of the drawing, clipped, rather than the whole logo scaled down.&lt;/p&gt;

&lt;p&gt;The fix is one attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;viewBox="0 0 956.69287 342.99213"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same numbers as the width and height. Now the content scales to fit whatever box CSS gives it.&lt;/p&gt;

&lt;p&gt;I verified the framing was right by extracting every coordinate from the path data and checking the bounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;layer transform : -161.07883 -76.646639
after transform : x[35.4, 921.3]  y[35.4, 307.6]
viewBox         : 0 0 956.7 343.0
content fits    : YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A symmetric 35.4-unit margin on all four sides — the viewBox was correct.&lt;/p&gt;

&lt;p&gt;That padding had a second consequence: the mark only fills 79% of its box, so at &lt;code&gt;height:30px&lt;/code&gt; it rendered noticeably smaller than the neighbouring logos. Bumping it to &lt;code&gt;38px&lt;/code&gt; (&lt;code&gt;30 × 343/272&lt;/code&gt;) matched their visual weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 4: www was a whole saga
&lt;/h2&gt;

&lt;p&gt;The apex went live in one click. &lt;code&gt;www&lt;/code&gt; fought me for an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First:&lt;/strong&gt; adding &lt;code&gt;www&lt;/code&gt; as a Custom Domain failed with &lt;code&gt;Hostname already has externally managed DNS records&lt;/code&gt;. Fair — the tunnel owned that record. I deleted it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then:&lt;/strong&gt; &lt;code&gt;No zones match www.example.com. Start by adding the domain to Cloudflare.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This one is a trap. The domain &lt;strong&gt;was&lt;/strong&gt; already on Cloudflare — the apex Custom Domain had succeeded on the same Worker in the same zone minutes earlier. Following that "add the domain" prompt would have tried to onboard a duplicate zone. If you see this and you know your domain is already there, don't follow the prompt.&lt;/p&gt;

&lt;p&gt;I gave up on the dialog and used a proxied CNAME plus a Redirect Rule instead. Which produced:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP 522.&lt;/strong&gt; Connection timed out.&lt;/p&gt;

&lt;p&gt;The reason is worth understanding: a proxied CNAME &lt;code&gt;www → example.com&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; chain through to the Worker. &lt;code&gt;www&lt;/code&gt; had no Worker binding and no real origin, so Cloudflare tried to fetch from an origin that didn't exist and timed out.&lt;/p&gt;

&lt;p&gt;The Redirect Rule is what fixes it — redirect rules fire at the edge &lt;em&gt;before&lt;/em&gt; Cloudflare attempts any origin fetch. The CNAME alone will always 522. You need both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the rule matched only the homepage.&lt;/strong&gt; Every other path still 522'd. The cause was the match field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ URI Full  wildcard  "www.example.com"
✅ Hostname  equals    "www.example.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;URI Full&lt;/code&gt; is the &lt;em&gt;entire&lt;/em&gt; URI — &lt;code&gt;https://www.example.com/styles.css&lt;/code&gt;. A bare hostname pattern never matches it. Matching on &lt;strong&gt;Hostname&lt;/strong&gt; covers every path on that host.&lt;/p&gt;

&lt;p&gt;Final working configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="nv"&gt;"www.example.com"&lt;/span&gt;
&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Dynamic&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt;
&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;preserve&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One last trap:&lt;/strong&gt; right after saving, results were &lt;em&gt;mixed&lt;/em&gt; — some paths 301, others still 522. That's propagation across edge nodes, not a broken rule. Test twice, a minute apart, before concluding anything is wrong. I nearly rewrote a rule that was already correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML document&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DYNAMIC&lt;/code&gt;, round-trip to my house&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HIT&lt;/code&gt; at the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;scp&lt;/code&gt; from my laptop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git push&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback&lt;/td&gt;
&lt;td&gt;re-run scp and hope&lt;/td&gt;
&lt;td&gt;one click, every deploy retained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability&lt;/td&gt;
&lt;td&gt;one Pi, one ISP, one SD card&lt;/td&gt;
&lt;td&gt;Cloudflare's anycast network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken TCS logo&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;electricity&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After every change I re-ran the same check rather than trusting that it had worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://example.com/
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status, cache state, and byte count — three seconds, and it catches the difference between "deployed" and "actually serving what I think it's serving."&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure before you migrate.&lt;/strong&gt; Half my performance justification was already false.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're already behind Cloudflare, cutover risk is near zero&lt;/strong&gt; — the edge IPs never change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything in your assets directory is public.&lt;/strong&gt; Put site files in a subdirectory and verify with curl that the rest 404s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hotlinked images are an availability dependency.&lt;/strong&gt; One of mine had already broken silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An SVG with &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; but no &lt;code&gt;viewBox&lt;/code&gt; will not scale&lt;/strong&gt; — it clips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;www&lt;/code&gt; is a separate hostname&lt;/strong&gt;, not a synonym. It needs its own configuration and it will not fall back to your apex.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The migration itself was maybe ten minutes of actual work. The rest was discovering what had quietly rotted while the site sat there working — a dead image, an unscalable SVG, a performance story I'd told myself without checking. That part was more useful than the move.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site is &lt;a href="https://rubarajan.dev" rel="noopener noreferrer"&gt;rubarajan.dev&lt;/a&gt;, now served entirely from Cloudflare's edge and deployed with &lt;code&gt;git push&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>devops</category>
    </item>
    <item>
      <title>Build Your Own AI Answering Machine</title>
      <dc:creator>Rubarajan</dc:creator>
      <pubDate>Sun, 02 Aug 2026 18:36:40 +0000</pubDate>
      <link>https://dev.to/rubarajan/build-your-own-ai-answering-machine-1cg</link>
      <guid>https://dev.to/rubarajan/build-your-own-ai-answering-machine-1cg</guid>
      <description>&lt;p&gt;AI can answer the phone on your behalf. When you can't pick up, it takes the call, greets the caller by introducing itself, and holds a natural voice conversation — following a &lt;strong&gt;persona you define in plain instructions&lt;/strong&gt;: how it should sound, what it should ask, and what it's allowed to do. You can join the call and take over from it at any moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Powers It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Twilio&lt;/strong&gt; | &lt;strong&gt;Deepgram&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the whole hardware-free stack: Twilio owns the phone line, Deepgram&lt;br&gt;
owns the voice conversation.&lt;/p&gt;

&lt;p&gt;A large portion of this can be built with open-source freeware and trial&lt;br&gt;
accounts. The bridge itself runs entirely on open-source tooling, and both&lt;br&gt;
paid services are free to try — a Twilio trial account comes with &lt;strong&gt;$15&lt;/strong&gt; in&lt;br&gt;
credit and a Deepgram trial with &lt;strong&gt;$200&lt;/strong&gt;, which is more than enough to build&lt;br&gt;
and test everything in this post end to end.&lt;/p&gt;


&lt;h2&gt;
  
  
  How Can We Do It?
&lt;/h2&gt;

&lt;p&gt;The entire answering machine is a bridge between two websockets: Twilio&lt;br&gt;
streaming the call audio to you, and the Deepgram Voice Agent doing the&lt;br&gt;
listening, thinking, and speaking.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Twilio takes the call
&lt;/h3&gt;

&lt;p&gt;Buy a Twilio phone number and point its incoming-call webhook at your server.&lt;br&gt;
When a call comes in you respond with TwiML — Twilio's XML instructions. The&lt;br&gt;
key instruction is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Connect&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Stream&lt;/span&gt; &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"wss://your-server/ws/screening"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Connect&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;Connect&amp;gt;&amp;lt;Stream&amp;gt;&lt;/code&gt; opens a &lt;strong&gt;bidirectional&lt;/strong&gt; websocket: Twilio sends you the&lt;br&gt;
caller's audio as base64-encoded μ-law frames, and any audio you send back is&lt;br&gt;
played to the caller. From this point the phone call is just a websocket.&lt;/p&gt;

&lt;p&gt;You decide &lt;em&gt;when&lt;/em&gt; to hand the call to the AI. Three natural triggers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ring the human first (&lt;code&gt;&amp;lt;Dial timeout="20"&amp;gt;&lt;/code&gt;) and fall through to the AI
when nobody answers.&lt;/li&gt;
&lt;li&gt;Answer with the AI immediately when nobody is available at all.&lt;/li&gt;
&lt;li&gt;Let a human click a "screen this call" button and redirect a ringing call
to the AI on demand.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Deepgram has the conversation
&lt;/h3&gt;

&lt;p&gt;On your server, open a second websocket to the &lt;strong&gt;Deepgram Voice Agent&lt;/strong&gt;&lt;br&gt;
(&lt;code&gt;wss://agent.deepgram.com/v1/agent/converse&lt;/code&gt;). This one service replaces the&lt;br&gt;
whole speech pipeline you'd otherwise assemble yourself — speech-to-text, a&lt;br&gt;
reasoning model, and text-to-speech — behind a single socket. You configure&lt;br&gt;
it with one settings message:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audio&lt;/strong&gt; — μ-law at 8 kHz in and out, matching Twilio's telephony format
exactly. No transcoding needed: frames pass straight through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listen&lt;/strong&gt; — a Deepgram speech-recognition model transcribes the caller in
real time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Think&lt;/strong&gt; — a large language model drives the conversation. This is where
your &lt;strong&gt;persona&lt;/strong&gt; goes: the system prompt is
built from the instructions you wrote — who the AI is covering for, what
tone to take, what to collect, what it may and may not offer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speak&lt;/strong&gt; — a Deepgram voice speaks the replies. The voice itself is part
of the persona, so different teams can sound different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greeting&lt;/strong&gt; — the exact opening line, including the required disclosure
that the caller is speaking with an automated assistant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. The bridge
&lt;/h3&gt;

&lt;p&gt;Your server's job is now beautifully small: relay audio frames from the&lt;br&gt;
Twilio socket to the Deepgram socket, and relay the agent's audio back. Two&lt;br&gt;
details make it feel human:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Barge-in&lt;/strong&gt; — when the caller starts talking over the AI, send Twilio a
&lt;code&gt;clear&lt;/code&gt; event to flush the audio it hasn't played yet. The AI stops
mid-sentence instead of talking over the caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function calling&lt;/strong&gt; — when the caller asks something that needs real data
("is there a pharmacy near me?"), the Voice Agent sends a function-call
request over the same socket. Your server runs the lookup and returns the
result, and the AI speaks it. Which functions exist is gated by the
persona, so a switched-off capability is never even offered to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. The takeover
&lt;/h3&gt;

&lt;p&gt;Because the AI is just one leg of a normal Twilio call, a human can join it&lt;br&gt;
like any other call. Redirect the call back to an agent's line and the AI&lt;br&gt;
leg ends — same call, no hold music, no transfer, the caller never dials&lt;br&gt;
twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Here Is an Example
&lt;/h2&gt;

&lt;p&gt;Here's the same integration running inside our web app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI answering a live call&lt;/strong&gt; — the caller rang, nobody picked up, and&lt;br&gt;
the assistant took over. Everything the caller says streams onto the screen&lt;br&gt;
in real time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📸 **&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5otcygbnvh1v66lx4x7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5otcygbnvh1v66lx4x7.png" alt="Screen which has a telephony integrated when call comes in with three options Answer, Reject or Screen" width="800" height="1557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🎬 **&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jy8lwxvsn2ndilyw0pz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jy8lwxvsn2ndilyw0pz.gif" alt="A short recording of what it looks like when call comes in" width="700" height="1011"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Setting the persona&lt;/strong&gt; — the AI's greeting, voice, instructions, and&lt;br&gt;
capabilities are a form the user edits, not code. Change the instructions and&lt;br&gt;
the very next call follows them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📸 **&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh6nsso0kf3il8hlfbxf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh6nsso0kf3il8hlfbxf.png" alt="Option To Add persona description in the screen to control AI Persona" width="800" height="821"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;]**&lt;/p&gt;




&lt;h2&gt;
  
  
  Future Road Map
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Training Simulator for Teams&lt;/strong&gt; — the same voice pipeline pointed
inward: AI personas that play difficult callers so new team members
practice before their first real conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcript Intelligence&lt;/strong&gt; — emotion and compliance tracking over the
live transcript, flagging risky phrases and caller frustration as they
happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Call Intelligence&lt;/strong&gt; — live coaching and insights driven by the
transcript and the AI's tool usage during the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-Filling UI&lt;/strong&gt; — forms that fill themselves from what the caller
says, captured as structured data while the conversation is still going. j&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>twilio</category>
      <category>deepgram</category>
    </item>
  </channel>
</rss>
