<?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: Jorel Fermin</title>
    <description>The latest articles on DEV Community by Jorel Fermin (@jorelfermin).</description>
    <link>https://dev.to/jorelfermin</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%2F4085254%2F995c7342-a719-4496-9e30-49e3af5e9a3e.jpg</url>
      <title>DEV Community: Jorel Fermin</title>
      <link>https://dev.to/jorelfermin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jorelfermin"/>
    <language>en</language>
    <item>
      <title>Social cards in pure Rust: no headless Chrome</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Thu, 10 Sep 2026 21:26:54 +0000</pubDate>
      <link>https://dev.to/jorelfermin/social-cards-in-pure-rust-no-headless-chrome-2ok2</link>
      <guid>https://dev.to/jorelfermin/social-cards-in-pure-rust-no-headless-chrome-2ok2</guid>
      <description>&lt;p&gt;Cloud Cost Analyzer (CCA) lets you share a read-only scan as a link. Someone drops that link in Slack or on LinkedIn, and instead of a bare URL I wanted it to unfurl into a proper card: the brand mark, the monthly savings we found, the number of findings.&lt;/p&gt;

&lt;p&gt;That was two separate problems. Figuring them out took me somewhere I didn't expect, because neither one wanted a headless browser, but both needed a card. One of them even ate an afternoon because the fonts wouldn't cooperate. Here's the whole thing.&lt;/p&gt;

&lt;p&gt;The dashboard is a single-page app, rendered entirely in the browser (&lt;code&gt;ssr: false&lt;/code&gt;). That's fine for humans. It's not fine for the crawlers Slack, LinkedIn, and X send at your link, because those don't run JavaScript. They fetch the HTML, find an empty shell with no &lt;code&gt;og:&lt;/code&gt; meta tags, and give up. You get the bare URL in the Slack thread.&lt;/p&gt;

&lt;p&gt;So the crawler gets served something different. A CloudFront function reads the &lt;code&gt;User-Agent&lt;/code&gt; on the viewer request: known bots get 302'd to a small server-rendered &lt;code&gt;/embed&lt;/code&gt; page that carries the Open Graph tags and a &lt;code&gt;meta refresh&lt;/code&gt; back to the real app. Humans get the SPA untouched. The crawler reads a fully-formed page with &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, and friends; a human clicking the same link lands on the interactive dashboard.&lt;/p&gt;

&lt;p&gt;This is user-agent-based dynamic rendering for social crawlers, not SEO cloaking. The &lt;code&gt;/embed&lt;/code&gt; page carries the same title and numbers a human would see, and it &lt;code&gt;meta refresh&lt;/code&gt;es straight to the real app, so nobody is handed content that contradicts what the link actually shows.&lt;/p&gt;

&lt;p&gt;That's the easy half, structurally. Now comes the picture, which is where I had a choice to make.&lt;/p&gt;

&lt;p&gt;The default path for "turn some text on a branded background into a PNG" is to render HTML in headless Chrome and screenshot it. It works. It also means shipping a browser alongside your service, keeping it patched, and paying its cold-start and memory cost on a code path that exists only to make links look nice. In a Rust backend that's a lot of weight to carry for a nice Slack unfurl.&lt;/p&gt;

&lt;p&gt;I built the card as an SVG string and rasterized it in-process with &lt;a href="https://crates.io/crates/resvg" rel="noopener noreferrer"&gt;&lt;code&gt;resvg&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;tiny-skia&lt;/code&gt;, both pure Rust. The whole renderer is about ten lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;FONTDB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LazyLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;LazyLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SG_700&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// include_bytes! at compile time&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SG_500&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INTER_600&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;render_png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FONTDB&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Tree&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pixmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tiny_skia&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CARD_W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CARD_H&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ServiceError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nn"&gt;resvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;tiny_skia&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="nf"&gt;.as_mut&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="nf"&gt;.encode_png&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things in there I'd do again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fonts are compiled into the binary&lt;/strong&gt; with &lt;code&gt;include_bytes!&lt;/code&gt;. No font files to ship, no system-font lookup that can differ between my laptop and the container. The binary just renders the same card everywhere, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LazyLock&amp;lt;Arc&amp;lt;Database&amp;gt;&amp;gt;&lt;/code&gt;&lt;/strong&gt; parses the font data once per process. Each render clones the &lt;code&gt;Arc&lt;/code&gt; (cheap) into its own &lt;code&gt;usvg::Options&lt;/code&gt;, so building a card is string formatting plus a rasterize. That's the whole cost model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The layout is single-column, left-aligned, on purpose.&lt;/strong&gt; &lt;code&gt;resvg&lt;/code&gt; doesn't expose a text-measurement API - you can't ask it how wide a string is before drawing it. Any centered element would need that. Committing to a left-aligned column means I never have to measure anything at all; the SVG is static positions with the numbers interpolated in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handler builds the SVG, calls &lt;code&gt;render_png&lt;/code&gt;, and returns it with a one-hour &lt;code&gt;Cache-Control&lt;/code&gt;. There's a &lt;code&gt;CCA_OG_SAMPLE&lt;/code&gt; env var that dumps a real card to disk so I can eyeball it, and the test just asserts the output starts with the PNG magic bytes and has the right dimensions. That's deliberately not much - I'm not going to pixel-diff a card, but I do want to hear about the day the renderer stops producing a valid PNG.&lt;/p&gt;

&lt;p&gt;Then I spent an afternoon wondering why everything was in the wrong font.&lt;/p&gt;

&lt;p&gt;The cards looked structurally fine - right size, right colors, text in the right places - but the typeface was not what I wanted. Headings were supposed to be Space Grotesk. They were coming out in resvg's fallback face. Getting the &lt;code&gt;font-family&lt;/code&gt; string "more correct" did not help, which took me a while to be okay with.&lt;/p&gt;

&lt;p&gt;The SVG references the font the normal way:&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;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"Space Grotesk"&lt;/span&gt; &lt;span class="na"&gt;font-weight=&lt;/span&gt;&lt;span class="s"&gt;"700"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"Space Grotesk"&lt;/span&gt; &lt;span class="na"&gt;font-weight=&lt;/span&gt;&lt;span class="s"&gt;"500"&lt;/span&gt; &lt;span class="err"&gt;...&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;The actual problem was upstream, in the font files themselves, which I only found when I finally opened one. I'd grabbed static TTFs from Fontsource, which ships each weight as a separate file - and, the part that got me, each file declares its &lt;strong&gt;own family name&lt;/strong&gt; in the OpenType &lt;code&gt;name&lt;/code&gt; table. The 700 file is not "Space Grotesk, weight 700." Depending on the face it calls itself "Space Grotesk" for one weight and "Space Grotesk Medium"/"Space Grotesk Light" for others. The filenames and the internal family names don't necessarily agree.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resvg&lt;/code&gt; (via &lt;code&gt;usvg&lt;/code&gt;) matches fonts the CSS way: find a family named exactly "Space Grotesk," then pick the closest weight within that family. When the only family that literally exists in the database is "Space Grotesk Light," a request for &lt;code&gt;font-family="Space Grotesk"&lt;/code&gt; matches nothing and silently falls back. No error, no warning - just the wrong font, which looked close enough that I kept debugging the SVG instead of the font.&lt;/p&gt;

&lt;p&gt;The fix was normalizing the &lt;code&gt;name&lt;/code&gt; table so both faces report the same family and distinguish themselves by weight class instead. A few lines of &lt;code&gt;fonttools&lt;/code&gt; does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fontTools.ttLib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTFont&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space-grotesk-700.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nameID&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# family name
&lt;/span&gt;        &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Space Grotesk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OS/2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;usWeightClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;        &lt;span class="c1"&gt;# keep the weight distinct
&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space-grotesk-700.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both faces belong to one "Space Grotesk" family with &lt;code&gt;usWeightClass&lt;/code&gt; 500 and 700, and &lt;code&gt;font-family="Space Grotesk" font-weight="700"&lt;/code&gt; resolves to exactly the face I meant.&lt;/p&gt;

&lt;p&gt;One licensing note, since editing a font's name table can be a violation in some situations: the SIL Open Font License lets you modify and rename fonts freely &lt;em&gt;unless&lt;/em&gt; the font declares a Reserved Font Name, in which case you're not allowed to keep that name on a modified copy. I checked - the faces I was using don't reserve one - so collapsing them to a single "Space Grotesk" family is fine. If yours does reserve a name, rename to something new instead. It's thirty seconds to confirm before you ship a modified &lt;code&gt;.ttf&lt;/code&gt;, and I'd rather not find out the hard way.&lt;/p&gt;

&lt;p&gt;Where it landed: a service that turns a shared scan into a branded PNG in-process, fonts baked into the binary, no browser anywhere in the pipeline. Share a scan link and it unfurls with a card showing the monthly savings and the finding count, generated the same way in every environment because there's nothing external left to disagree with itself.&lt;/p&gt;

&lt;p&gt;If you're reaching for headless Chrome only to rasterize some text on a background, &lt;code&gt;resvg&lt;/code&gt; plus &lt;code&gt;tiny-skia&lt;/code&gt; is worth an hour of your time before you commit to it. And check your font's &lt;code&gt;name&lt;/code&gt; table before you spend an afternoon wondering why "Space Grotesk" isn't Space Grotesk.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://cca.dragonfractal.com/" rel="noopener noreferrer"&gt;Cloud Cost Analyzer&lt;/a&gt; in Rust.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opengraph</category>
      <category>resvg</category>
      <category>devops</category>
    </item>
    <item>
      <title>Idle load balancers: the ~$16/month each you forgot to delete"</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 18:35:59 +0000</pubDate>
      <link>https://dev.to/jorelfermin/idle-load-balancers-the-16month-each-you-forgot-to-delete-548f</link>
      <guid>https://dev.to/jorelfermin/idle-load-balancers-the-16month-each-you-forgot-to-delete-548f</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; An Application or Network Load Balancer costs &lt;strong&gt;~$0.0225/hour, about $16/month, just to exist&lt;/strong&gt;, plus capacity units. Classic Load Balancers run ~$18/month. Load balancers outlive the services behind them: the app gets torn down, the ALB keeps billing. Here's how to find load balancers with no real traffic or no healthy targets, and remove them safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why idle load balancers linger
&lt;/h2&gt;

&lt;p&gt;The hourly base charge is fixed - an ALB with zero requests bills the same ~$16/month as a busy one. Load balancers are usually created early (with an app or an IaC module) and deleted last, if ever. A handful of abandoned ALBs from old environments is real, recurring money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - List load balancers and their traffic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-load-balancers &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'LoadBalancers[].{Name:LoadBalancerName,Type:Type,ARN:LoadBalancerArn}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an ALB, check request volume over the last 7 days (the metric dimension is the tail of the ARN, e.g. &lt;code&gt;app/my-alb/50dc6c495c0c9188&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudwatch get-metric-statistics &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; AWS/ApplicationELB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metric-name&lt;/span&gt; RequestCount &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dimensions&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;LoadBalancer,Value&lt;span class="o"&gt;=&lt;/span&gt;app/my-alb/50dc6c495c0c9188 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'7 days ago'&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end-time&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--period&lt;/span&gt; 86400 &lt;span class="nt"&gt;--statistics&lt;/span&gt; Sum &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Datapoints[].Sum'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Near-zero request counts over a week is a strong idle signal. (For NLBs, use the &lt;code&gt;AWS/NetworkELB&lt;/code&gt; namespace and &lt;code&gt;ActiveFlowCount&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Check for empty or unhealthy target groups
&lt;/h2&gt;

&lt;p&gt;A load balancer with no healthy targets is doing nothing useful:&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;tg &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws elbv2 describe-target-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetGroups[].TargetGroupArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&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="nv"&gt;$tg&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  aws elbv2 describe-target-health &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetHealthDescriptions[].TargetHealth.State'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output (no targets) or all &lt;code&gt;unhealthy&lt;/code&gt; alongside near-zero requests is a confident "delete me."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 - Delete safely
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 delete-load-balancer &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; a load balancer with no requests isn't &lt;em&gt;always&lt;/em&gt; dead - it might be a disaster-recovery endpoint, a rarely-hit admin panel, or the target of a DNS record something depends on. Before deleting, check Route 53 (and any external DNS) for records pointing at the load balancer's DNS name, and confirm nothing references it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-load-balancers &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'LoadBalancers[].DNSName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grep your DNS zones for that name. No references + no traffic + no healthy targets = safe to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it across every account, automatically
&lt;/h2&gt;

&lt;p&gt;Pulling CloudWatch for every load balancer by hand doesn't scale. It's one of the checks in a read-only CLI I built, Cloud Cost Analyzer - its &lt;code&gt;idle-load-balancer&lt;/code&gt; rule flags load balancers serving fewer than 100 requests/day over a 7-day window, alongside 89 other AWS cost rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://releases.dragonfractal.com/install.sh | sh
cca scan &lt;span class="nt"&gt;--provider&lt;/span&gt; aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in your environment with read-only access, so your AWS credentials never leave it. Free tier if you want to try it on one account, and dashboards if you want to review or share reports.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://cca.dragonfractal.com/blog/delete-idle-load-balancers" rel="noopener noreferrer"&gt;Dragon Fractal Cloud Cost Analyzer blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>networking</category>
      <category>finop</category>
      <category>devops</category>
    </item>
    <item>
      <title>Migrate EBS gp2 volumes to gp3 and cut storage cost by about 20%</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 17:27:10 +0000</pubDate>
      <link>https://dev.to/jorelfermin/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20-2fk7</link>
      <guid>https://dev.to/jorelfermin/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20-2fk7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; AWS gp3 EBS volumes cost about &lt;strong&gt;~20% less per GB than gp2&lt;/strong&gt; ($0.08 vs $0.10 per GB-month in us-east-1) and includes 3,000 IOPS and 125 MB/s of baseline performance for free. You can convert a volume from gp2 to gp3 &lt;strong&gt;online, with no downtime and no snapshot&lt;/strong&gt; with a single &lt;code&gt;modify-volume&lt;/code&gt; call. For most volumes, it's a real savings. Here's how to find those volumes, migrate them, and the one caveat to keep in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gp3 is cheaper (and usually faster)
&lt;/h2&gt;

&lt;p&gt;gp2 pricing couples performance to its size: you get &lt;strong&gt;3 IOPS per GB&lt;/strong&gt;, so the only way to get more IOPS on gp2 is to over-provision storage you don't need. gp3 removes that coupling:&lt;/p&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;gp2&lt;/th&gt;
&lt;th&gt;gp3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;$0.10 / GB-month&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.08 / GB-month&lt;/strong&gt; (-20%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline IOPS&lt;/td&gt;
&lt;td&gt;3 IOPS/GB (burst to 3,000)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3,000 included&lt;/strong&gt; at any size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline throughput&lt;/td&gt;
&lt;td&gt;scales with size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;125 MB/s included&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra IOPS / throughput&lt;/td&gt;
&lt;td&gt;not possible&lt;/td&gt;
&lt;td&gt;provision independently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Prices are us-east-1 list; the ratio holds across regions.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a 500 GB volume, that's &lt;strong&gt;$50/month to $40/month, i.e., $120/year saved, per volume&lt;/strong&gt;, with equal or better performance. Now multiply this by every gp2 volume in every account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Find your gp2 volumes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-volumes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].{ID:VolumeId,GiB:Size,AZ:AvailabilityZone,State:State}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it per region. EBS is regional, so a volume only shows up in its own region:&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;region &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-regions &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Regions[].RegionName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&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="nv"&gt;$region&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  aws ec2 describe-volumes &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$region&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].{ID:VolumeId,GiB:Size}'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 - Migrate (online, no downtime)
&lt;/h2&gt;

&lt;p&gt;Converting the type is a live &lt;code&gt;modify-volume&lt;/code&gt;. The volume stays attached and readable/writable the whole time; it briefly enters an &lt;code&gt;optimizing&lt;/code&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; vol-0abc123def456 &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To convert everything in a region at once:&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;vol &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-volumes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].VolumeId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&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;"migrating &lt;/span&gt;&lt;span class="nv"&gt;$vol&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$vol&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No snapshot, no detach, no reboot. (AWS allows one modification per volume per 6 hours, so migrate and then wait before modifying the same volume.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The one caveat: high-IOPS / high-throughput volumes
&lt;/h2&gt;

&lt;p&gt;Because gp2 IOPS scale with size, a &lt;strong&gt;large&lt;/strong&gt; gp2 volume may already deliver more than gp3's 3,000 baseline IOPS. A 2 TB gp2 volume provides 6,000 IOPS; if your workload actually uses them, migrate &lt;strong&gt;and&lt;/strong&gt; provision matching performance on gp3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; vol-0abc123def456 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3 &lt;span class="nt"&gt;--iops&lt;/span&gt; 6000 &lt;span class="nt"&gt;--throughput&lt;/span&gt; 250
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first 3,000 IOPS and 125 MB/s are free; beyond that, extra IOPS are $0.005/provisioned-IOPS-month and extra throughput $0.040/MB/s-month. Even fully matched, gp3 is usually still cheaper than the equivalent gp2, but check CloudWatch &lt;code&gt;VolumeReadOps&lt;/code&gt;/&lt;code&gt;VolumeWriteOps&lt;/code&gt; before assuming you need the headroom. Volumes under ~1 TB with normal workloads are a straight 20% win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it across every account, automatically
&lt;/h2&gt;

&lt;p&gt;Finding gp2 volumes and estimating the savings across every account and region by hand doesn't scale. It's one of the checks in a read-only CLI I built, Cloud Cost Analyzer. It flags every gp2 volume with the estimated monthly savings, alongside 89 other AWS cost rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://releases.dragonfractal.com/install.sh | sh
cca scan &lt;span class="nt"&gt;--provider&lt;/span&gt; aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in your environment with read-only access, so your AWS credentials never leave it. Free tier if you want to try it on one account, and dashboards if you want to review or share reports.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://cca.dragonfractal.com/blog/migrate-ebs-gp2-to-gp3" rel="noopener noreferrer"&gt;Dragon Fractal Cloud Cost Analyzer blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>finops</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
