<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Dave Cross</title>
    <description>The latest articles on Forem by Dave Cross (@davorg).</description>
    <link>https://forem.com/davorg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F114555%2F673c4753-2096-43a2-89fc-80ba2685b08b.png</url>
      <title>Forem: Dave Cross</title>
      <link>https://forem.com/davorg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/davorg"/>
    <language>en</language>
    <item>
      <title>Writing a TOON Module for Perl</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 29 Mar 2026 17:46:14 +0000</pubDate>
      <link>https://forem.com/davorg/writing-a-toon-module-for-perl-4bjo</link>
      <guid>https://forem.com/davorg/writing-a-toon-module-for-perl-4bjo</guid>
      <description>&lt;p&gt;Every so often, a new data serialisation format appears and people get excited about it. Recently, one of those formats is &lt;strong&gt;TOON&lt;/strong&gt; — Token-Oriented Object Notation. As the name suggests, it’s another way of representing the same kinds of data structures that you’d normally store in JSON or YAML: hashes, arrays, strings, numbers, booleans and nulls.&lt;/p&gt;

&lt;p&gt;So the obvious Perl question is: “Ok, where’s the CPAN module?”&lt;/p&gt;

&lt;p&gt;This post explains what TOON is, why some people think it’s useful, and why I decided to write a Perl module for it — with an interface that should feel very familiar to anyone who has used JSON.pm.&lt;/p&gt;

&lt;p&gt;I should point out that I knew about &lt;a href="https://metacpan.org/pod/Data::TOON" rel="noopener noreferrer"&gt;Data::Toon&lt;/a&gt; but I wanted something with an interface that was more like JSON.pm.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  What TOON Is
&lt;/h2&gt;

&lt;p&gt;TOON stands for &lt;strong&gt;Token-Oriented Object Notation&lt;/strong&gt;. It’s a textual format for representing structured data — the same data model as JSON:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Objects (hashes)
&lt;/li&gt;
&lt;li&gt;Arrays
&lt;/li&gt;
&lt;li&gt;Strings
&lt;/li&gt;
&lt;li&gt;Numbers
&lt;/li&gt;
&lt;li&gt;Booleans
&lt;/li&gt;
&lt;li&gt;Null&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea behind TOON is that it is designed to be &lt;strong&gt;easy for both humans and language models to read and write&lt;/strong&gt;. It tries to reduce punctuation noise and make the structure of data clearer.&lt;/p&gt;

&lt;p&gt;If you think of the landscape like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Human-friendly&lt;/th&gt;
&lt;th&gt;Machine-friendly&lt;/th&gt;
&lt;th&gt;Very common&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Very&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TOON&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TOON is trying to sit in the middle: simpler than YAML, more readable than JSON.&lt;/p&gt;

&lt;p&gt;Whether it succeeds at that is a matter of taste — but it’s an interesting idea.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  TOON vs JSON vs YAML
&lt;/h2&gt;

&lt;p&gt;It’s probably easiest to understand TOON by comparing it to JSON and YAML. Here’s the same “person” record written in all three formats.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;  
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Arthur Dent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arthur@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&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;"street"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"High Street"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Guildford"&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;"phones"&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="s2"&gt;"01234 567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"07700 900123"&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;h3&gt;
  
  
  YAML
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Arthur Dent&lt;/span&gt;  
&lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;42&lt;/span&gt;  
&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arthur@example.com&lt;/span&gt;  
&lt;span class="na"&gt;alive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  
&lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
  &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;High Street&lt;/span&gt;  
  &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Guildford&lt;/span&gt;  
&lt;span class="na"&gt;phones&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
  &lt;span class="s"&gt;– 01234 567890&lt;/span&gt;  
  &lt;span class="s"&gt;– 07700 &lt;/span&gt;&lt;span class="m"&gt;900123&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  TOON
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Arthur Dent&lt;/span&gt;
&lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;42&lt;/span&gt;
&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arthur@example.com”&lt;/span&gt;
&lt;span class="na"&gt;alive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;High Street&lt;/span&gt; 
  &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Guildford&lt;/span&gt;
&lt;span class="s"&gt;phones[2]&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;01234 567890,07700 &lt;/span&gt;&lt;span class="m"&gt;900123&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see that TOON sits somewhere between JSON and YAML:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Less punctuation and quoting than JSON
&lt;/li&gt;
&lt;li&gt;More explicit structure than YAML
&lt;/li&gt;
&lt;li&gt;Still very easy to parse
&lt;/li&gt;
&lt;li&gt;Still clearly structured for machines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the idea, anyway.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Why People Think TOON Is Useful
&lt;/h2&gt;

&lt;p&gt;The current interest in TOON is largely driven by AI/LLM workflows.&lt;/p&gt;

&lt;p&gt;People are using it because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is easier for humans to read than JSON.
&lt;/li&gt;
&lt;li&gt;It is less ambiguous and complex than YAML.
&lt;/li&gt;
&lt;li&gt;It maps cleanly to the JSON data model.
&lt;/li&gt;
&lt;li&gt;It is relatively easy to parse.
&lt;/li&gt;
&lt;li&gt;It works well in prompts and generated output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In other words, it’s not trying to replace JSON for APIs, and it’s not trying to replace YAML for configuration files. It’s aiming at the space where humans and machines are collaborating on structured data.&lt;/p&gt;

&lt;p&gt;You may or may not buy that argument — but it’s an interesting niche.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Wrote a Perl Module
&lt;/h2&gt;

&lt;p&gt;I don’t have particularly strong opinions about TOON as a format. It might take off, it might not. We’ve seen plenty of “next big data format” ideas over the years.&lt;/p&gt;

&lt;p&gt;But what I &lt;em&gt;do&lt;/em&gt; have a strong opinion about is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a data format exists, then Perl should have a CPAN module for it that works the way Perl programmers expect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Perl already has very good, very consistent interfaces for data serialisation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON
&lt;/li&gt;
&lt;li&gt;YAML
&lt;/li&gt;
&lt;li&gt;Storable
&lt;/li&gt;
&lt;li&gt;Sereal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all tend to follow the same pattern, particularly the object-oriented interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;pretty&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I wanted a TOON module that worked the same way.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Goals
&lt;/h2&gt;

&lt;p&gt;When designing the module, I had a few simple goals.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Familiar OO Interface
&lt;/h3&gt;

&lt;p&gt;The primary interface should be object-oriented and feel like JSON.pm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;  
               &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;pretty&lt;/span&gt;  
               &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;canonical&lt;/span&gt;  
               &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already know JSON, you already know how to use TOON.&lt;/p&gt;

&lt;p&gt;There are also convenience functions, but the OO interface is the main one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Pure Perl Implementation
&lt;/h3&gt;

&lt;p&gt;Version 0.001 is pure Perl. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Easy to install&lt;/li&gt;
&lt;li&gt;No compiler required&lt;/li&gt;
&lt;li&gt;Works everywhere Perl works&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If TOON becomes popular and performance matters, someone can always write an XS backend later.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Clean Separation of Components
&lt;/h3&gt;

&lt;p&gt;Internally, the module is split into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizer&lt;/strong&gt; – turns text into tokens
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parser&lt;/strong&gt; – turns tokens into Perl data structures
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emitter&lt;/strong&gt; – turns Perl data structures into TOON text
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error handling&lt;/strong&gt; – reports line/column errors cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes it easier to test and maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Do the Simple Things Well First
&lt;/h3&gt;

&lt;p&gt;Version 0.001 supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scalars&lt;/li&gt;
&lt;li&gt;Arrayrefs&lt;/li&gt;
&lt;li&gt;Hashrefs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;undef&lt;/code&gt; → null&lt;/li&gt;
&lt;li&gt;Pretty printing&lt;/li&gt;
&lt;li&gt;Canonical key ordering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; (yet) try to serialise blessed objects or do anything clever. That can come later if people actually want it.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Example Usage (OO Style)
&lt;/h2&gt;

&lt;p&gt;Here’s a simple Perl data structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Arthur Dent&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;drinks&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;["&lt;/span&gt;&lt;span class="s2"&gt;tea&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coffee&lt;/span&gt;&lt;span class="p"&gt;"],&lt;/span&gt;
  &lt;span class="s"&gt;alive&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Encoding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;pretty&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;print&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Decoding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$toon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;print&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Convenience Functions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;TOON&lt;/span&gt; &lt;span class="sx"&gt;qw(encode_toon decode_toon)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;encode_toon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;decode_toon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the OO interface is where most of the flexibility lives.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Command Line Tool
&lt;/h2&gt;

&lt;p&gt;There’s also a command-line tool, &lt;code&gt;toon_pp&lt;/code&gt;, similar to &lt;code&gt;json_pp&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;&lt;span class="nb"&gt;cat &lt;/span&gt;data.toon | toon_pp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which will pretty-print TOON data.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;I don’t know whether TOON will become widely used. Predicting the success of data formats is a fool’s game. But the cost of supporting it in Perl is low, and the potential usefulness is high enough to make it worth doing.&lt;/p&gt;

&lt;p&gt;And fundamentally, this is how CPAN has always worked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See a problem. Write a module. Upload it. See if anyone else finds it useful.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So now Perl has a TOON module. And if you already know how to use JSON.pm, you already know how to use it.&lt;/p&gt;

&lt;p&gt;That was the goal.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/" rel="noopener noreferrer"&gt;Writing a TOON Module for Perl&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cpan</category>
      <category>toon</category>
      <category>serialisation</category>
    </item>
    <item>
      <title>Still on the [b]leading edge</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sat, 21 Mar 2026 11:14:06 +0000</pubDate>
      <link>https://forem.com/davorg/still-on-the-bleading-edge-40df</link>
      <guid>https://forem.com/davorg/still-on-the-bleading-edge-40df</guid>
      <description>&lt;p&gt;About eighteen months ago, I wrote a post called &lt;a href="https://dev.to/davorg/on-the-bleading-edge-1olf"&gt;&lt;em&gt;On the Bleading Edge&lt;/em&gt;&lt;/a&gt; about my decision to start using Perl’s new &lt;code&gt;class&lt;/code&gt; feature in real code. I knew I was getting ahead of parts of the ecosystem. I knew there would be occasional pain. I decided the benefits were worth it.&lt;/p&gt;

&lt;p&gt;I still think that’s true.&lt;/p&gt;

&lt;p&gt;But every now and then, the bleading edge reminds you why it’s called that.&lt;/p&gt;

&lt;p&gt;Recently, I lost a couple of days to a bug that turned out not to be in my code, not in the module I was installing, and not even in the module that module depended on — but in the installer’s understanding of modern Perl syntax.&lt;/p&gt;

&lt;p&gt;This is the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;I was building a Docker image for &lt;a href="https://aphra.perlhacks.com/" rel="noopener noreferrer"&gt;Aphra&lt;/a&gt;. As part of the build, I needed to install &lt;a href="https://metacpan.org/pod/App::HTTPThis" rel="noopener noreferrer"&gt;App::HTTPThis&lt;/a&gt;, which depends on &lt;a href="https://metacpan.org/pod/Plack::App::DirectoryIndex" rel="noopener noreferrer"&gt;Plack::App::DirectoryIndex&lt;/a&gt;, which depends on &lt;a href="https://metacpan.org/pod/WebServer::DirIndex" rel="noopener noreferrer"&gt;WebServer::DirIndex&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Docker build failed with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;13 45.66 &lt;span class="nt"&gt;--&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; Working on WebServer::DirIndex
&lt;span class="gp"&gt;#&lt;/span&gt;13 45.66 Fetching https://www.cpan.org/authors/id/D/DA/DAVECROSS/WebServer-DirIndex-0.1.3.tar.gz ... OK
&lt;span class="gp"&gt;#&lt;/span&gt;13 45.83 Configuring WebServer-DirIndex-v0.1.3 ... OK
&lt;span class="gp"&gt;#&lt;/span&gt;13 46.21 Building WebServer-DirIndex-v0.1.3 ... OK
&lt;span class="gp"&gt;#&lt;/span&gt;13 46.75 Successfully installed WebServer-DirIndex-v0.1.3
&lt;span class="gp"&gt;#&lt;/span&gt;13 46.84 &lt;span class="o"&gt;!&lt;/span&gt; Installing the dependencies failed: Installed version &lt;span class="o"&gt;(&lt;/span&gt;undef&lt;span class="o"&gt;)&lt;/span&gt; of WebServer::DirIndex is not &lt;span class="k"&gt;in &lt;/span&gt;range &lt;span class="s1"&gt;'v0.1.0'&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;13 46.84 &lt;span class="o"&gt;!&lt;/span&gt; Bailing out the installation &lt;span class="k"&gt;for &lt;/span&gt;Plack-App-DirectoryIndex-v0.2.1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, that’s a deeply confusing error message.&lt;/p&gt;

&lt;p&gt;It clearly says that WebServer::DirIndex was successfully installed. And then immediately says that the installed version is &lt;code&gt;undef&lt;/code&gt; and not in the required range.&lt;/p&gt;

&lt;p&gt;At this point you start wondering if you’ve somehow broken version numbering, or if there’s a packaging error, or if the dependency chain is wrong.&lt;/p&gt;

&lt;p&gt;But the version number in WebServer::DirIndex was fine. The module built. The tests passed. Everything looked normal.&lt;/p&gt;

&lt;p&gt;So why did the installer think the version was &lt;code&gt;undef&lt;/code&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Bug Appears
&lt;/h2&gt;

&lt;p&gt;This only shows up in a fairly specific situation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A module uses modern Perl &lt;code&gt;class&lt;/code&gt; syntax&lt;/li&gt;
&lt;li&gt;The module defines a &lt;code&gt;$VERSION&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Another module declares a prerequisite with a specific version requirement&lt;/li&gt;
&lt;li&gt;The installer tries to check the installed version without loading the module&lt;/li&gt;
&lt;li&gt;It uses &lt;a href="https://metacpan.org/pod/Module::Metadata" rel="noopener noreferrer"&gt;Module::Metadata&lt;/a&gt; to extract &lt;code&gt;$VERSION&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;And the version of Module::Metadata it is using doesn’t properly understand &lt;code&gt;class&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don’t specify a version requirement, you’ll probably never see this. Which is why I hadn’t seen it before. I don’t often pin minimum versions of my own modules, but in this case, the modules are more tightly coupled than I’d like, and specific versions are required.&lt;/p&gt;

&lt;p&gt;So this bug only appears when you combine:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;modern Perl syntax + version checks + older toolchain&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is pretty much the definition of “bleading edge”.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Culprit
&lt;/h2&gt;

&lt;p&gt;The problem turned out to be an older version of Module::Metadata that had been fatpacked into &lt;code&gt;cpanm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cpanm&lt;/code&gt; uses &lt;code&gt;Module::Metadata&lt;/code&gt; to inspect modules and extract &lt;code&gt;$VERSION&lt;/code&gt; without loading the module. But the older &lt;code&gt;Module::Metadata&lt;/code&gt; didn’t correctly understand the &lt;code&gt;class&lt;/code&gt; keyword, so it couldn’t work out which package the &lt;code&gt;$VERSION&lt;/code&gt; belonged to.&lt;/p&gt;

&lt;p&gt;So when it checked the installed version, it found… nothing.&lt;/p&gt;

&lt;p&gt;Hence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Installed version (undef) of WebServer::DirIndex is not in range ‘v0.1.0’&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The version wasn’t wrong. The installer just couldn’t see it.&lt;/p&gt;

&lt;p&gt;An aside, you may find it amusing to hear an anecdote from my attempts to debug this problem.&lt;/p&gt;

&lt;p&gt;I spun up a new Ubuntu Docker container, installed &lt;code&gt;cpanm&lt;/code&gt; and tried to install Plack::App::DirectoryIndex. Initially, this gave the same error message. At least the problem was easily reproducible.&lt;/p&gt;

&lt;p&gt;I then ran code that was very similar to the code &lt;code&gt;cpanm&lt;/code&gt; uses to work out what a module’s version is.&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;perl &lt;span class="nt"&gt;-MModule&lt;/span&gt;::Metadata &lt;span class="nt"&gt;-E&lt;/span&gt;&lt;span class="s1"&gt;'say Module::Metadata-&amp;gt;new_from_module("WebServer::DirIndex")-&amp;gt;version'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This displayed an empty string. I was really onto something here. Module::Metadata couldn’t find the version.&lt;/p&gt;

&lt;p&gt;I was using Module::Metadata version 1.000037 and, looking at the change log on CPAN, I saw this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;1.000038 2023-04-28 11:25:40Z&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-&lt;/code&gt; &lt;code&gt;detects "class" syntax&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I installed 1.000038 and reran my command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;perl &lt;span class="nt"&gt;-MModule&lt;/span&gt;::Metadata &lt;span class="nt"&gt;-E&lt;/span&gt;&lt;span class="s1"&gt;'say Module::Metadata-&amp;gt;new_from_module("WebServer::DirIndex")-&amp;gt;version'&lt;/span&gt;
&lt;span class="go"&gt;0.1.3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That seemed conclusive. Excitedly, I reran the Docker build.&lt;/p&gt;

&lt;p&gt;It failed again.&lt;/p&gt;

&lt;p&gt;You’ve probably worked out why. But it took me a frustrating half an hour to work it out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cpanm&lt;/code&gt; doesn’t use the installed version of Module::Metadata. It uses its own, fatpacked version. Updating Module::Metadata wouldn’t fix my problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workaround
&lt;/h2&gt;

&lt;p&gt;I found a workaround. That was to add a redundant &lt;code&gt;package&lt;/code&gt; declaration alongside the &lt;code&gt;class&lt;/code&gt; declaration, so older versions of Module::Metadata can still identify the package that owns &lt;code&gt;$VERSION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So instead of just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="nv"&gt;class&lt;/span&gt; &lt;span class="nn"&gt;WebServer::&lt;/span&gt;&lt;span class="nv"&gt;DirIndex&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;our&lt;/span&gt; &lt;span class="nv"&gt;$VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.1.3&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I now have this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="nb"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;WebServer::&lt;/span&gt;&lt;span class="nv"&gt;DirIndex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;class&lt;/span&gt; &lt;span class="nn"&gt;WebServer::&lt;/span&gt;&lt;span class="nv"&gt;DirIndex&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;our&lt;/span&gt; &lt;span class="nv"&gt;$VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.1.3&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks unnecessary. And in a perfect world, it would be unnecessary.&lt;/p&gt;

&lt;p&gt;But it allows older tooling to work out the version correctly, and everything installs cleanly again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Proper Fix
&lt;/h2&gt;

&lt;p&gt;Of course, the real fix was to update the toolchain.&lt;/p&gt;

&lt;p&gt;So I &lt;a href="https://github.com/miyagawa/cpanminus/issues/697" rel="noopener noreferrer"&gt;raised an issue against App::cpanminus&lt;/a&gt;, pointing out that the fatpacked Module::Metadata was too old to cope properly with modules that use &lt;code&gt;class&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tatsuhiko Miyagawa responded very quickly, and a new release of &lt;code&gt;cpanm&lt;/code&gt; appeared with an updated version of Module::Metadata.&lt;/p&gt;

&lt;p&gt;This is one of the nice things about the Perl ecosystem. Sometimes you report a problem and the right person fixes it almost immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Do I Remove the Workaround?
&lt;/h2&gt;

&lt;p&gt;This leaves me with an interesting question.&lt;/p&gt;

&lt;p&gt;The correct fix is “use a recent &lt;code&gt;cpanm&lt;/code&gt;”.&lt;/p&gt;

&lt;p&gt;But the workaround is “add a redundant &lt;code&gt;package&lt;/code&gt; line so older tooling doesn’t get confused”.&lt;/p&gt;

&lt;p&gt;So when do I remove the workaround?&lt;/p&gt;

&lt;p&gt;The answer is probably: not yet.&lt;/p&gt;

&lt;p&gt;Because although a fixed &lt;code&gt;cpanm&lt;/code&gt; exists, that doesn’t mean everyone is using it. Old Docker base images, CI environments, bootstrap scripts, and long-lived servers can all have surprisingly ancient versions of &lt;code&gt;cpanm&lt;/code&gt; lurking in them.&lt;/p&gt;

&lt;p&gt;And the workaround is harmless. It just offends my sense of neatness slightly.&lt;/p&gt;

&lt;p&gt;So for now, the redundant &lt;code&gt;package&lt;/code&gt; line stays. Not because modern Perl needs it, but because parts of the world around modern Perl are still catching up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Life on the Bleading Edge
&lt;/h2&gt;

&lt;p&gt;This is what life on the bleading edge actually looks like.&lt;/p&gt;

&lt;p&gt;Not dramatic crashes. Not language bugs. Not catastrophic failures.&lt;/p&gt;

&lt;p&gt;Just a tool, somewhere in the install chain, that looks at perfectly valid modern Perl code and quietly decides that your module doesn’t have a version number.&lt;/p&gt;

&lt;p&gt;And then you lose two days proving that you are not, in fact, going mad.&lt;/p&gt;

&lt;p&gt;But I’m still using &lt;code&gt;class&lt;/code&gt;. And I’m still happy I am.&lt;/p&gt;

&lt;p&gt;You just have to keep an eye on the whole toolchain — not just the language — when you decide to live a little closer to the future than everyone else.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2026/03/still-on-the-bleading-edge/" rel="noopener noreferrer"&gt;Still on the [b]leading edge&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>cpan</category>
      <category>docker</category>
      <category>modulemetadata</category>
    </item>
    <item>
      <title>Your README Is Already a Website</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sat, 28 Feb 2026 17:42:48 +0000</pubDate>
      <link>https://forem.com/davorg/your-readme-is-already-a-website-dg7</link>
      <guid>https://forem.com/davorg/your-readme-is-already-a-website-dg7</guid>
      <description>&lt;h2&gt;
  
  
  Announcing &lt;code&gt;readme-to-index&lt;/code&gt; — My First GitHub Marketplace Release 🎉
&lt;/h2&gt;

&lt;p&gt;Today I published my first GitHub Action to the &lt;a href="https://github.com/marketplace/" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It’s called &lt;a href="https://github.com/marketplace/actions/readme-to-index-html" rel="noopener noreferrer"&gt;&lt;strong&gt;readme-to-index&lt;/strong&gt;&lt;/a&gt;, and it does something very simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It turns your &lt;code&gt;README.md&lt;/code&gt; into a clean, styled &lt;code&gt;index.html&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;p&gt;No Jekyll.&lt;br&gt;
No Ruby.&lt;br&gt;
No themes.&lt;br&gt;
No &lt;code&gt;_config.yml&lt;/code&gt;.&lt;br&gt;
No implicit behaviour.&lt;/p&gt;

&lt;p&gt;Just Markdown → HTML → Done.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;I have a lot of small projects.&lt;/p&gt;

&lt;p&gt;Many of them already have good READMEs. In fact, for most of them, the README &lt;em&gt;is&lt;/em&gt; the documentation.&lt;/p&gt;

&lt;p&gt;So the obvious question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why create a separate site when the README already exists?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub Pages + Jekyll is great. But for small libraries and utilities, it can feel like overkill.&lt;/p&gt;

&lt;p&gt;I wanted something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimal&lt;/li&gt;
&lt;li&gt;Deterministic&lt;/li&gt;
&lt;li&gt;Easy to drop into any workflow&lt;/li&gt;
&lt;li&gt;Friendly to CI pipelines&lt;/li&gt;
&lt;li&gt;With zero hidden conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built a GitHub Action that does exactly one thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;README.md → index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Styled using &lt;a href="https://simplecss.org/" rel="noopener noreferrer"&gt;Simple.css&lt;/a&gt; (by default, but you can use any stylesheet you like).&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Converts &lt;code&gt;README.md&lt;/code&gt; to &lt;code&gt;index.html&lt;/code&gt; using &lt;a href="https://pandoc.org/" rel="noopener noreferrer"&gt;Pandoc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Automatically sets the HTML &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; from the first &lt;code&gt;# Heading&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Applies Simple.css for clean, classless styling&lt;/li&gt;
&lt;li&gt;Suppresses Pandoc’s injected syntax-highlighting CSS&lt;/li&gt;
&lt;li&gt;Leaves your original README untouched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your README remains the canonical source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;p&gt;Add this to your workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;davorg/readme-to-index@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;_site/index.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy using the standard GitHub Pages artifact flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available Options
&lt;/h2&gt;

&lt;p&gt;The action supports a few optional inputs:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;readme&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Path to the Markdown source file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default:&lt;/strong&gt; &lt;code&gt;README.md&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;output&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Path to the generated HTML file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default:&lt;/strong&gt; &lt;code&gt;index.html&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;css_url&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;CSS stylesheet to include in the generated HTML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default:&lt;/strong&gt; &lt;code&gt;https://cdn.simplecss.org/simple.min.css&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;install_pandoc&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Whether to install Pandoc automatically using &lt;code&gt;apt-get&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default:&lt;/strong&gt; &lt;code&gt;true&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Set this to &lt;code&gt;false&lt;/code&gt; if your workflow already installs Pandoc.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;extra_pandoc_args&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Additional arguments passed directly to the Pandoc command.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;davorg/readme-to-index@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extra_pandoc_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--toc"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Enabling GitHub Pages
&lt;/h2&gt;

&lt;p&gt;For this to deploy as a website, you’ll need to enable GitHub Pages in your repository settings.&lt;/p&gt;

&lt;p&gt;Go to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository → Settings → Pages → Build and deployment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; GitHub Actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s it. The workflow will handle the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example Complete Workflow
&lt;/h2&gt;

&lt;p&gt;Here’s a minimal working example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish README to GitHub Pages&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-pages&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.deployment.outputs.page_url }}&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;davorg/readme-to-index@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;_site/index.html&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/configure-pages@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-pages-artifact@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;_site&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/deploy-pages@v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why Not Just Use Jekyll?
&lt;/h2&gt;

&lt;p&gt;You absolutely can.&lt;/p&gt;

&lt;p&gt;But this approach has two real advantages:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Simplicity
&lt;/h3&gt;

&lt;p&gt;There’s no Ruby toolchain.&lt;/p&gt;

&lt;p&gt;There are no implicit layouts.&lt;/p&gt;

&lt;p&gt;There are no theme conventions to understand.&lt;/p&gt;

&lt;p&gt;It takes one Markdown file and produces one HTML file.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Pipeline-Friendly
&lt;/h3&gt;

&lt;p&gt;Because it’s "just a step", you can use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;As part of a release build&lt;/li&gt;
&lt;li&gt;In CI pipelines&lt;/li&gt;
&lt;li&gt;Before publishing documentation artifacts&lt;/li&gt;
&lt;li&gt;Outside GitHub entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It doesn’t rely on GitHub Pages’ default behaviour.&lt;/p&gt;

&lt;p&gt;It works wherever Pandoc runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Small Milestone
&lt;/h2&gt;

&lt;p&gt;Shipping something to the GitHub Marketplace feels surprisingly significant.&lt;/p&gt;

&lt;p&gt;It’s a tiny tool.&lt;br&gt;
It does one thing.&lt;br&gt;
But it does it cleanly.&lt;/p&gt;

&lt;p&gt;That’s the kind of tooling I like building.&lt;/p&gt;

&lt;p&gt;If you’re interested:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/marketplace/actions/readme-to-index-html" rel="noopener noreferrer"&gt;https://github.com/marketplace/actions/readme-to-index-html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback welcome. Stars appreciated. Minimalism encouraged.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>githubpages</category>
      <category>readme</category>
      <category>website</category>
    </item>
    <item>
      <title>Treating GitHub Copilot as a Contributor</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 22 Feb 2026 11:51:15 +0000</pubDate>
      <link>https://forem.com/davorg/treating-github-copilot-as-a-contributor-186m</link>
      <guid>https://forem.com/davorg/treating-github-copilot-as-a-contributor-186m</guid>
      <description>&lt;p&gt;For some time, we’ve talked about GitHub Copilot as if it were a clever autocomplete engine.&lt;/p&gt;

&lt;p&gt;It isn’t.&lt;/p&gt;

&lt;p&gt;Or rather, that’s not all it is.&lt;/p&gt;

&lt;p&gt;The interesting thing — the thing that genuinely changes how you work — is that you can assign GitHub issues to Copilot.&lt;/p&gt;

&lt;p&gt;And it behaves like a contributor.&lt;/p&gt;

&lt;p&gt;Over the past day, I’ve been doing exactly that on my new CPAN module, &lt;a href="https://metacpan.org/pod/WebServer::DirIndex" rel="noopener noreferrer"&gt;WebServer::DirIndex&lt;/a&gt;. I’ve opened issues, assigned them to Copilot, and watched a steady stream of pull requests land. Ten issues closed in about a day, each one implemented via a Copilot-generated PR, reviewed and merged like any other contribution.&lt;/p&gt;

&lt;p&gt;That still feels faintly futuristic. But it’s not “vibe coding”. It’s surprisingly structured.&lt;/p&gt;

&lt;p&gt;Let me explain how it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  It Starts With a Proper Issue
&lt;/h2&gt;

&lt;p&gt;This workflow depends on discipline. You don’t type “please refactor this” into a chat window. You create a proper GitHub issue. The sort you would assign to another human maintainer. For example, here are some of the recent issues Copilot handled in WebServer::DirIndex:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add CPAN scaffolding&lt;/li&gt;
&lt;li&gt;Update the classes to use Feature::Compat::Class&lt;/li&gt;
&lt;li&gt;Replace DirHandle&lt;/li&gt;
&lt;li&gt;Add WebServer::DirIndex::File&lt;/li&gt;
&lt;li&gt;Move &lt;code&gt;render()&lt;/code&gt; method&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;:reader&lt;/code&gt; attribute where useful&lt;/li&gt;
&lt;li&gt;Remove dependency on Plack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one was a focused, bounded piece of work. Each one had clear expectations.&lt;/p&gt;

&lt;p&gt;The key is this: Copilot works best when you behave like a maintainer, not a magician.&lt;/p&gt;

&lt;p&gt;You describe the change precisely. You state constraints. You mention compatibility requirements. You indicate whether tests need to be updated.&lt;/p&gt;

&lt;p&gt;Then you assign the issue to Copilot.&lt;/p&gt;

&lt;p&gt;And wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pull Request Arrives
&lt;/h2&gt;

&lt;p&gt;After a few minutes — sometimes ten, sometimes less — Copilot creates a branch and opens a pull request.&lt;/p&gt;

&lt;p&gt;The PR contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code changes&lt;/li&gt;
&lt;li&gt;Updated or new tests&lt;/li&gt;
&lt;li&gt;A descriptive PR message&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And because it’s a real PR, your CI runs automatically. The code is evaluated in the same way as any other contribution.&lt;/p&gt;

&lt;p&gt;This is already a major improvement over editor-based prompting. The work is isolated, reviewable, and properly versioned.&lt;/p&gt;

&lt;p&gt;But the most interesting part is what happens in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  Watching Copilot Think
&lt;/h2&gt;

&lt;p&gt;If you visit the &lt;strong&gt;Agents&lt;/strong&gt; tab in the repository, you can see Copilot reasoning through the issue.&lt;/p&gt;

&lt;p&gt;It reads like a junior developer narrating their approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interpreting the problem&lt;/li&gt;
&lt;li&gt;Identifying the relevant files&lt;/li&gt;
&lt;li&gt;Planning changes&lt;/li&gt;
&lt;li&gt;Considering test updates&lt;/li&gt;
&lt;li&gt;Running validation steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you can interrupt it.&lt;/p&gt;

&lt;p&gt;If it starts drifting toward unnecessary abstraction or broad refactoring, you can comment and steer it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Please don’t change the public API.&lt;/li&gt;
&lt;li&gt;Avoid experimental Perl features.&lt;/li&gt;
&lt;li&gt;This must remain compatible with Perl 5.40.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It responds. It adjusts course.&lt;/p&gt;

&lt;p&gt;This ability to intervene mid-flight is one of the most useful aspects of the system. You are not passively accepting generated code — you’re supervising it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Teaching Copilot About Your Project
&lt;/h2&gt;

&lt;p&gt;Out of the box, Copilot doesn’t really know how your repository works. It sees code, but it doesn’t know policy.&lt;/p&gt;

&lt;p&gt;That’s where repository-level configuration becomes useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Custom Repository Instructions
&lt;/h3&gt;

&lt;p&gt;GitHub allows you to provide a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; file that gives Copilot repository-specific guidance. The documentation for this lives here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions" rel="noopener noreferrer"&gt;Adding repository custom instructions for GitHub Copilot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When GitHub offers to generate this file for you, say yes.&lt;/p&gt;

&lt;p&gt;Then customise it properly.&lt;/p&gt;

&lt;p&gt;In a CPAN module, I tend to include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimum supported Perl version&lt;/li&gt;
&lt;li&gt;Whether Feature::Compat::Class is preferred&lt;/li&gt;
&lt;li&gt;Whether experimental features are forbidden&lt;/li&gt;
&lt;li&gt;CPAN layout expectations (&lt;code&gt;lib/&lt;/code&gt;, &lt;code&gt;t/&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Test conventions (Test::More, no stray diagnostics)&lt;/li&gt;
&lt;li&gt;A strong preference for not breaking the public API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without this file, Copilot guesses.&lt;/p&gt;

&lt;p&gt;With this file, Copilot aligns itself with your house style.&lt;/p&gt;

&lt;p&gt;That difference is impressive.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Customising the Copilot Development Environment
&lt;/h3&gt;

&lt;p&gt;There’s another piece that many people miss: Copilot can run a special workflow event called &lt;code&gt;copilot_agent_setup&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can define a workflow that prepares the environment Copilot works in. GitHub documents this here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a&gt;Customizing the development environment for GitHub Copilot coding agent&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my Perl projects, I use this standard setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Copilot Setup Steps

on:
  workflow_dispatch:
  push:
    paths:
      - .github/workflows/copilot-setup-steps.yml
  pull_request:
    paths:
      - .github/workflows/copilot-setup-steps.yml

jobs:
  copilot-setup-steps:
    runs-on: ubuntu-latest
    permissions:
      contents: read
  steps:
    - name: Check out repository
      uses: actions/checkout@v4

    - name: Set up Perl 5.40
      uses: shogo82148/actions-setup-perl@v1
      with:
        perl-version: '5.40'

    - name: Install dependencies
      run: cpanm --installdeps --with-develop --notest .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Obviously, that was originally written for me by Copilot!)&lt;/p&gt;

&lt;p&gt;This does two important things.&lt;/p&gt;

&lt;p&gt;Firstly, it ensures Copilot is working with the correct Perl version.&lt;/p&gt;

&lt;p&gt;Secondly, it installs the distribution dependencies, meaning Copilot can reason in a context that actually resembles my real development environment.&lt;/p&gt;

&lt;p&gt;Without this workflow, Copilot operates in a kind of generic space.&lt;/p&gt;

&lt;p&gt;With it, Copilot behaves like a contributor who has actually checked out your code and run &lt;code&gt;cpanm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That’s a useful difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reviewing the Work
&lt;/h2&gt;

&lt;p&gt;This is the part where it’s important not to get starry-eyed.&lt;/p&gt;

&lt;p&gt;I still review the PR carefully.&lt;/p&gt;

&lt;p&gt;I still check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has it changed behaviour unintentionally?&lt;/li&gt;
&lt;li&gt;Has it introduced unnecessary abstraction?&lt;/li&gt;
&lt;li&gt;Are the tests meaningful?&lt;/li&gt;
&lt;li&gt;Has it expanded scope beyond the issue?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I check out the branch and run the tests. Exactly as I would with a PR from a human co-worker.&lt;/p&gt;

&lt;p&gt;You can request changes and reassign the PR to Copilot. It will revise its branch.&lt;/p&gt;

&lt;p&gt;The loop is fast. Faster than traditional asynchronous code review.&lt;/p&gt;

&lt;p&gt;But the responsibility is unchanged. You are still the maintainer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Feels Different
&lt;/h2&gt;

&lt;p&gt;What’s happening here isn’t just “AI writing code”. It’s AI integrated into the contribution workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Issues&lt;/li&gt;
&lt;li&gt;Structured reasoning&lt;/li&gt;
&lt;li&gt;Pull requests&lt;/li&gt;
&lt;li&gt;CI&lt;/li&gt;
&lt;li&gt;Review cycles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That architecture matters.&lt;/p&gt;

&lt;p&gt;It means you can use Copilot in a controlled, auditable way.&lt;/p&gt;

&lt;p&gt;In my experience with WebServer::DirIndex, this model works particularly well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mechanical refactors&lt;/li&gt;
&lt;li&gt;Adding attributes (e.g. &lt;code&gt;:reader&lt;/code&gt; where appropriate)&lt;/li&gt;
&lt;li&gt;Removing dependencies&lt;/li&gt;
&lt;li&gt;Moving methods cleanly&lt;/li&gt;
&lt;li&gt;Adding new internal classes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is less strong when the issue itself is vague or architectural. Copilot cannot infer the intent you didn’t articulate.&lt;/p&gt;

&lt;p&gt;But given a clear issue, it’s remarkably capable — even with modern Perl using tools like Feature::Compat::Class.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Small but Important Point for the Perl Community
&lt;/h2&gt;

&lt;p&gt;I’ve seen people saying that AI tools don’t handle Perl well. That has not been my experience.&lt;/p&gt;

&lt;p&gt;With a properly described issue, repository instructions, and a defined development environment, Copilot works competently with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modern Perl syntax&lt;/li&gt;
&lt;li&gt;CPAN distribution layouts&lt;/li&gt;
&lt;li&gt;Test suites&lt;/li&gt;
&lt;li&gt;Feature::Compat::Class (or whatever OO framework I’m using on a particular project)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The constraint isn’t the language. It’s how clearly you explain the task.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Shift
&lt;/h2&gt;

&lt;p&gt;The most interesting thing here isn’t that Copilot writes Perl. It’s that GitHub allows you to treat AI as a contributor.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You file an issue.&lt;/li&gt;
&lt;li&gt;You assign it.&lt;/li&gt;
&lt;li&gt;You supervise its reasoning.&lt;/li&gt;
&lt;li&gt;You review its PR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s not autocomplete. It’s not magic. It’s just another developer on the project. One who works quickly, doesn’t argue, and reads your documentation very carefully.&lt;/p&gt;

&lt;p&gt;Have you been using AI tools to write or maintain Perl code? What successes (or failures!) have you had? Are there other tools I should be using?&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;p&gt;If you want to have a closer look at the issues and PRs I’m talking about, here are some links?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/davorg-cpan/webserver-dirindex" rel="noopener noreferrer"&gt;WebServer::DirIndex repo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/davorg-cpan/webserver-dirindex/issues?q=is%3Aissue%20state%3Aclosed" rel="noopener noreferrer"&gt;Closed issues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/davorg-cpan/webserver-dirindex/pulls?q=is%3Apr+is%3Aclosed" rel="noopener noreferrer"&gt;Closed PRs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/" rel="noopener noreferrer"&gt;Treating GitHub Copilot as a Contributor&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>perl</category>
      <category>github</category>
      <category>githubcopilot</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built a Tiny Domain Inventory Tool (Because I Used to Buy Too Many Domains)</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 08 Feb 2026 12:22:22 +0000</pubDate>
      <link>https://forem.com/davorg/i-built-a-tiny-domain-inventory-tool-because-i-used-to-buy-too-many-domains-45lg</link>
      <guid>https://forem.com/davorg/i-built-a-tiny-domain-inventory-tool-because-i-used-to-buy-too-many-domains-45lg</guid>
      <description>&lt;p&gt;(Or: how I stopped losing track of the domains I swore I wouldn’t buy)&lt;/p&gt;

&lt;p&gt;I used to be a serial domain-buyer.&lt;/p&gt;

&lt;p&gt;Not in a dramatic, “lost a fortune” way — just lots of &lt;em&gt;“oh, that might be useful one day”&lt;/em&gt; moments spread over many years. At one point, I honestly couldn’t tell you exactly what I owned, where it was registered, or what half of it pointed at.&lt;/p&gt;

&lt;p&gt;I’m mostly better now. Mostly.&lt;/p&gt;

&lt;p&gt;But I still own more domains than I like to admit, and I wanted a &lt;strong&gt;simple, honest way to see what I’ve got&lt;/strong&gt; without:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;another SaaS subscription&lt;/li&gt;
&lt;li&gt;a spreadsheet that immediately goes stale&lt;/li&gt;
&lt;li&gt;or building a backend I’d then have to maintain forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built this instead:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://davorg.dev/mydomains/" rel="noopener noreferrer"&gt;https://davorg.dev/mydomains/&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
👉 &lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/davorg/mydomains" rel="noopener noreferrer"&gt;https://github.com/davorg/mydomains&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s a small, static, browser-only tool for keeping track of domains, their DNS, and where they’re hosted — and it turns out to be surprisingly useful.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I wanted (and what I very deliberately didn’t)
&lt;/h2&gt;

&lt;p&gt;I wanted something that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;runs entirely in the browser&lt;/li&gt;
&lt;li&gt;stores its data locally&lt;/li&gt;
&lt;li&gt;can be hosted as static files&lt;/li&gt;
&lt;li&gt;gives me &lt;em&gt;useful facts&lt;/em&gt;, not a false sense of certainty&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I explicitly didn’t want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accounts&lt;/li&gt;
&lt;li&gt;logins&lt;/li&gt;
&lt;li&gt;databases&lt;/li&gt;
&lt;li&gt;background jobs&lt;/li&gt;
&lt;li&gt;“AI-powered insights” (whatever that would even mean here)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a tool for &lt;em&gt;me&lt;/em&gt;. I trust myself with my own data.&lt;/p&gt;

&lt;p&gt;If that resonates with you, read on.&lt;/p&gt;


&lt;h2&gt;
  
  
  What the tool does
&lt;/h2&gt;

&lt;p&gt;At its core, it’s just an inventory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a list of domains you own&lt;/li&gt;
&lt;li&gt;the hostnames you care about (&lt;code&gt;@&lt;/code&gt;, &lt;code&gt;www&lt;/code&gt;, &lt;code&gt;mail&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;some notes and keywords so future-you knows &lt;em&gt;why&lt;/em&gt; you bought it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that, it can enrich each domain with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DNS data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NS, MX, TXT for the domain&lt;/li&gt;
&lt;li&gt;A / AAAA / CNAME for each host&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;RDAP data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;registrar&lt;/li&gt;
&lt;li&gt;expiry date&lt;/li&gt;
&lt;li&gt;registration status&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Best-effort guesses&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DNS provider&lt;/li&gt;
&lt;li&gt;hosting provider per host&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is opt-in and cached. Nothing happens unless you click “refresh”.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture: aggressively boring (by design)
&lt;/h2&gt;

&lt;p&gt;There are three files that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;index.html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;style.css&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;script.js&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No build step. No framework. No external dependencies beyond public HTTP APIs.&lt;/p&gt;

&lt;p&gt;All state lives in &lt;code&gt;localStorage&lt;/code&gt; under a single key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domainInventory.v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you clear your browser data, it’s gone. Which is why import/export exists.&lt;/p&gt;

&lt;p&gt;That’s not a bug — that’s the trade-off.&lt;/p&gt;




&lt;h2&gt;
  
  
  DNS and RDAP: best-effort, not gospel
&lt;/h2&gt;

&lt;p&gt;DNS is fetched using Google’s DNS-over-HTTPS endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://dns.google/resolve?name=example.com&amp;amp;type=A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RDAP is fetched via &lt;code&gt;rdap.org&lt;/code&gt;, with a couple of TLD-specific exceptions where needed.&lt;/p&gt;

&lt;p&gt;This immediately gives you two realities to accept:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Not everything has RDAP&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CORS sometimes says no&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code treats RDAP as optional. If it works, great. If not, nothing breaks.&lt;/p&gt;

&lt;p&gt;That pattern shows up a lot in this project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Provider guessing (aka “useful lies”)
&lt;/h2&gt;

&lt;p&gt;One of the more interesting parts was deciding how far to go with &lt;em&gt;interpretation&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS provider detection
&lt;/h3&gt;

&lt;p&gt;This is intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;guessDnsProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsList&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nsLower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsList&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsLower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsLower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;awsdns&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWS Route 53&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsLower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gandi.net&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Gandi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is it exhaustive? No.&lt;br&gt;
Is it useful for &lt;em&gt;my&lt;/em&gt; domains? Yes.&lt;/p&gt;

&lt;p&gt;That’s a recurring theme here.&lt;/p&gt;
&lt;h3&gt;
  
  
  Hosting provider detection (and Cloudflare honesty)
&lt;/h3&gt;

&lt;p&gt;Hosting detection looks at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CNAME targets&lt;/li&gt;
&lt;li&gt;A / AAAA addresses&lt;/li&gt;
&lt;li&gt;known IP ranges&lt;/li&gt;
&lt;li&gt;known patterns (&lt;code&gt;.github.io&lt;/code&gt;, &lt;code&gt;.cloudfront.net&lt;/code&gt;, &lt;code&gt;.a.run.app&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But DNS can lie — or at least obscure.&lt;/p&gt;

&lt;p&gt;So if a host resolves to Cloudflare IPs, the tool does this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If it recognised the origin → “GitHub Pages (via Cloudflare)”&lt;/li&gt;
&lt;li&gt;Otherwise → “Unknown (via Cloudflare)”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the data doesn’t support a confident claim, I’d rather say &lt;em&gt;“I don’t know”&lt;/em&gt; than pretend.&lt;/p&gt;


&lt;h2&gt;
  
  
  Caching with intent
&lt;/h2&gt;

&lt;p&gt;All fetched data is cached per domain, along with a timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastChecked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But here’s the important bit: &lt;strong&gt;the cache is only cleared when it should be&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you edit notes or keywords, nothing is invalidated.&lt;/p&gt;

&lt;p&gt;If you change the domain name or host list, the cache is wiped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nameChanged&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hostsChanged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&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;That one small decision makes the tool feel calm instead of twitchy.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this tool is &lt;em&gt;not&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;It is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an auto-discovery system&lt;/li&gt;
&lt;li&gt;a real-time monitor&lt;/li&gt;
&lt;li&gt;a source of legal truth about domain ownership&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;you know which domains you own&lt;/li&gt;
&lt;li&gt;you know which hosts matter&lt;/li&gt;
&lt;li&gt;you’ll refresh things when you care&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In return, it stays small, fast, and understandable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Possible future improvements (and non-improvements)
&lt;/h2&gt;

&lt;p&gt;Things I might add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more provider heuristics (DigitalOcean, Fly.io, etc.)&lt;/li&gt;
&lt;li&gt;configurable IP allow-lists per provider&lt;/li&gt;
&lt;li&gt;better handling of registrar quirks per TLD&lt;/li&gt;
&lt;li&gt;bulk refresh with throttling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Things I probably won’t:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accounts&lt;/li&gt;
&lt;li&gt;sync&lt;/li&gt;
&lt;li&gt;background polling&lt;/li&gt;
&lt;li&gt;turning this into a product&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment it needs a backend, it stops being &lt;em&gt;this&lt;/em&gt; tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I’m happy with it
&lt;/h2&gt;

&lt;p&gt;This started as a way to get a handle on my own past enthusiasm for buying domains.&lt;/p&gt;

&lt;p&gt;It ended up as a reminder that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a browser is a perfectly good runtime&lt;/li&gt;
&lt;li&gt;not every problem needs infrastructure&lt;/li&gt;
&lt;li&gt;“good enough and honest” beats “comprehensive but fragile”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you own more domains than you’d like to admit, you might find it useful too.&lt;/p&gt;

&lt;p&gt;And if nothing else, it’s a nice excuse to build something small, tidy, and under your control.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>dns</category>
    </item>
    <item>
      <title>App::HTTPThis: the tiny web server I keep reaching for</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 04 Jan 2026 13:46:13 +0000</pubDate>
      <link>https://forem.com/davorg/apphttpthis-the-tiny-web-server-i-keep-reaching-for-2mf0</link>
      <guid>https://forem.com/davorg/apphttpthis-the-tiny-web-server-i-keep-reaching-for-2mf0</guid>
      <description>&lt;p&gt;Whenever I’m building a static website, I almost never start by reaching for Apache, nginx, Docker, or anything that feels like “proper infrastructure”. Nine times out of ten I just want a directory served over HTTP so I can click around, test routes, check assets, and see what happens in a real browser.&lt;/p&gt;

&lt;p&gt;For that job, I’ve been using &lt;a href="https://metacpan.org/dist/App-HTTPThis/view/bin/http_this" rel="noopener noreferrer"&gt;&lt;strong&gt;App::HTTPThis&lt;/strong&gt;&lt;/a&gt; for years.&lt;/p&gt;

&lt;p&gt;It’s a simple local web server you run from the command line. Point it at a directory, and it serves it. That’s it. No vhosts. No config bureaucracy. No “why is this module not enabled”. Just: &lt;em&gt;run a command and you’ve got a website&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I’ve used it for years
&lt;/h2&gt;

&lt;p&gt;Static sites are deceptively simple… right up until they aren’t.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You want to check that relative links behave the way you think they do.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want to confirm your CSS and images are loading with the paths you expect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want to reproduce “real HTTP” behaviour (caching headers, MIME types, directory handling) rather than viewing files directly from disk.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sure, you &lt;em&gt;can&lt;/em&gt; open &lt;code&gt;file:///.../index.html&lt;/code&gt; in a browser, but that’s not the same thing as serving it over HTTP. And setting up Apache (or friends) feels like bringing a cement mixer to butter some toast.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;http_this&lt;/code&gt;, the workflow is basically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; into your site directory&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;run a single command&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;open a URL&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;get on with your life&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s the “tiny screwdriver” that’s always on my desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I took it over
&lt;/h2&gt;

&lt;p&gt;A couple of years ago, the original maintainer had (entirely reasonably!) become too busy elsewhere and the distribution wasn’t getting attention. That happens. Open source is like that.&lt;/p&gt;

&lt;p&gt;But I was using App::HTTPThis regularly, and I had one small-but-annoying itch: when you visited a directory URL, it would always show a directory listing - even if that directory contained an &lt;code&gt;index.html.&lt;/code&gt; So instead of behaving like a typical web server (serve index.html by default), it treated &lt;code&gt;index.html&lt;/code&gt; as just another file you had to click.&lt;/p&gt;

&lt;p&gt;That’s exactly the sort of thing you notice when you’re using a tool every day, and it was irritating enough that I volunteered to take over maintenance.&lt;/p&gt;

&lt;p&gt;(If you want to read more on this story, I wrote a couple of &lt;a href="https://dev.to/davorg/the-story-behind-a-new-module-2gkp"&gt;blog&lt;/a&gt; &lt;a href="https://dev.to/davorg/mission-almost-accomplished-3ccg"&gt;posts&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’ve done since taking it over
&lt;/h2&gt;

&lt;p&gt;Most of the changes are about making the “serve a directory” experience smoother, without turning it into a kitchen-sink web server.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Serve index pages by default (autoindex)
&lt;/h3&gt;

&lt;p&gt;The first change was to make directory URLs behave like you’d expect: if &lt;code&gt;index.html&lt;/code&gt; exists, serve it automatically. If it doesn’t, you still get a directory listing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Prettier index pages
&lt;/h3&gt;

&lt;p&gt;Once autoindex was in place, I then turned my attention to the &lt;em&gt;fallback&lt;/em&gt; directory listing page. If there isn’t an &lt;code&gt;index.html&lt;/code&gt;, you still need a useful listing — but it doesn’t have to look like it fell out of 1998. So I cleaned up the listing output and made it a bit nicer to read when you do end up browsing raw directories.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) A config file
&lt;/h3&gt;

&lt;p&gt;Once you’ve used a tool for a while, you start to realise you run it &lt;em&gt;the same way&lt;/em&gt; most of the time.&lt;/p&gt;

&lt;p&gt;A config file lets you keep your common preferences in one place instead of re-typing options. It keeps the “one command” feel, but gives you repeatability when you want it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) &lt;code&gt;--host&lt;/code&gt; option
&lt;/h3&gt;

&lt;p&gt;The ability to control the host binding sounds like an edge case until it isn’t.&lt;/p&gt;

&lt;p&gt;Sometimes you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;only &lt;code&gt;localhost&lt;/code&gt; access for safety;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;access from other devices on your network (phone/tablet testing);&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;behaviour that matches a particular environment.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;--host&lt;/code&gt; option gives you that control without adding complexity to the default case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bonjour feature (and what it’s for)
&lt;/h2&gt;

&lt;p&gt;This is the part I only really appreciated recently: App::HTTPThis can advertise itself on your local network using &lt;strong&gt;mDNS / DNS-SD&lt;/strong&gt; – commonly called &lt;em&gt;Bonjour&lt;/em&gt; on Apple platforms, &lt;em&gt;Avahi&lt;/em&gt; on Linux, and various other names depending on who you’re talking to.&lt;/p&gt;

&lt;p&gt;It’s switched on with the &lt;code&gt;--name&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;http_this --name MyService&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;When you do that, &lt;code&gt;http_this&lt;/code&gt; publishes an &lt;code&gt;_http._tcp&lt;/code&gt; service on your local network with the instance name you chose (&lt;code&gt;MyService&lt;/code&gt; in this case). Any device on the same network that understands mDNS/DNS-SD can then discover it and resolve it to an address and port, without you having to tell anyone, “go to &lt;code&gt;http://192.168.1.23:7007/&lt;/code&gt;”.&lt;/p&gt;

&lt;p&gt;Confession time: I ignored this feature for ages because I’d mentally filed it under “Apple-only magic” (Bonjour! very shiny! probably proprietary!). It turns out it’s not Apple-only at all; it’s a set of standard networking technologies that are supported on pretty much everything, just under a frankly ridiculous number of different names. So: &lt;strong&gt;not Apple magic&lt;/strong&gt; , just &lt;strong&gt;local-network service discovery&lt;/strong&gt; with a branding problem.&lt;/p&gt;

&lt;p&gt;Because I’d never really used it, I finally sat down and tested it properly after someone emailed me about it last week, and it worked nicely, nicely enough that I’ve now added a &lt;a href="https://github.com/davorg-cpan/app-httpthis/blob/master/BONJOUR.md" rel="noopener noreferrer"&gt;&lt;code&gt;BONJOUR.md&lt;/code&gt;&lt;/a&gt; file to the repo with a practical explanation of what’s going on, how to enable it, and a few ways to browse/discover the advertised service.&lt;/p&gt;

&lt;p&gt;(If you’re curious, look for &lt;code&gt;_http._tcp&lt;/code&gt; and your chosen service name.)&lt;/p&gt;

&lt;p&gt;It’s a neat quality-of-life feature if you’re doing cross-device testing or helping someone else on the same network reach what you’re running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools in the same family
&lt;/h2&gt;

&lt;p&gt;App::HTTPThis is part of a little ecosystem of “run a thing &lt;em&gt;here&lt;/em&gt; quickly” command-line apps. If you like the shape of &lt;code&gt;http_this&lt;/code&gt;, you might also want to look at these siblings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://metacpan.org/pod/https_this" rel="noopener noreferrer"&gt;&lt;strong&gt;https_this&lt;/strong&gt;&lt;/a&gt; : like &lt;code&gt;http_this&lt;/code&gt;, but served over HTTPS (useful when you need to test secure contexts, service workers, APIs that require HTTPS, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://metacpan.org/pod/cgi_this" rel="noopener noreferrer"&gt;&lt;strong&gt;cgi_this&lt;/strong&gt;&lt;/a&gt; : for quick CGI-style testing without setting up a full web server stack&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://metacpan.org/pod/dav_this" rel="noopener noreferrer"&gt;&lt;strong&gt;dav_this&lt;/strong&gt;&lt;/a&gt; : serves content over WebDAV (handy for testing clients or workflows that expect DAV)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://metacpan.org/pod/ftp_this" rel="noopener noreferrer"&gt;&lt;strong&gt;ftp_this&lt;/strong&gt;&lt;/a&gt; : quick FTP server for those rare-but-real moments when you need one&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all share the same basic philosophy: remove the friction between “I have a directory” and “I want to interact with it like a service”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I like tools that do one job, do it well, and get out of the way. App::HTTPThis has been that tool for me for years and it’s been fun (and useful) to nudge it forward as a maintainer.&lt;/p&gt;

&lt;p&gt;If you’re doing any kind of static site work — docs sites, little prototypes, generated output, local previews — it’s worth keeping in your toolbox.&lt;/p&gt;

&lt;p&gt;And if you’ve got ideas, bug reports, or platform notes (especially around Bonjour/Avahi weirdness), I’m always &lt;a href="https://github.com/davorg-cpan/app-httpthis/issues" rel="noopener noreferrer"&gt;happy to hear them&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/" rel="noopener noreferrer"&gt;App::HTTPThis: the tiny web server I keep reaching for&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>web</category>
      <category>httpthis</category>
      <category>perl</category>
      <category>staticsites</category>
    </item>
    <item>
      <title>Behind the scenes at Perl School Publishing</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 14 Dec 2025 17:46:53 +0000</pubDate>
      <link>https://forem.com/davorg/behind-the-scenes-at-perl-school-publishing-27g9</link>
      <guid>https://forem.com/davorg/behind-the-scenes-at-perl-school-publishing-27g9</guid>
      <description>&lt;p&gt;We’ve just published a new &lt;a href="https://perlschool.com/" rel="noopener noreferrer"&gt;Perl School&lt;/a&gt; book: &lt;a href="https://perlschool.com/books/design-patterns/" rel="noopener noreferrer"&gt;&lt;em&gt;Design Patterns in Modern Perl&lt;/em&gt;&lt;/a&gt; by Mohammad Sajid Anwar.&lt;/p&gt;

&lt;p&gt;It’s been a while since we last released a new title, and in the meantime, the world of eBooks has moved on – Amazon don’t use &lt;code&gt;.mobi&lt;/code&gt; any more, tools have changed, and my old “it mostly works if you squint” build pipeline was starting to creak.&lt;/p&gt;

&lt;p&gt;On top of that, we had a hard deadline: we wanted the book ready in time for the London Perl Workshop. As the date loomed, last-minute fixes and manual tweaks became more and more terrifying. We really needed a reliable, reproducible way to go from manuscript to “good quality PDF + EPUB” every time.&lt;/p&gt;

&lt;p&gt;So over the last couple of weeks, I’ve been rebuilding the Perl School book pipeline from the ground up. This post is the story of that process, the tools I ended up using, and how you can steal it for your own books.&lt;/p&gt;




&lt;h2&gt;
  
  
  The old world, and why it wasn’t good enough
&lt;/h2&gt;

&lt;p&gt;The original Perl School pipeline dates back to a very different era:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Amazon wanted &lt;code&gt;.mobi&lt;/code&gt; files.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;EPUB support was patchy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I was happy to glue things together with shell scripts and hope for the best.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked… until it didn’t. Each book had slightly different scripts, slightly different assumptions, and a slightly different set of last-minute manual tweaks. It certainly wasn’t something I’d hand to a new author and say, “trust this”.&lt;/p&gt;

&lt;p&gt;Coming back to it for &lt;em&gt;Design Patterns in Modern Perl&lt;/em&gt; made that painfully obvious. The book itself is modern and well-structured; the pipeline that produced it shouldn’t feel like a relic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing tools: Pandoc and &lt;code&gt;wkhtmltopdf&lt;/code&gt; (and no LaTeX, thanks)
&lt;/h2&gt;

&lt;p&gt;The new pipeline is built around two main tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://pandoc.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;Pandoc&lt;/strong&gt;&lt;/a&gt; – the Swiss Army knife of document conversion. It can take Markdown/Markua plus metadata and produce HTML, EPUB, and much, much more.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://wkhtmltopdf.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;wkhtmltopdf&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; – which turns HTML into a print-ready PDF using a headless browser engine.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why not LaTeX? Because I’m allergic. LaTeX is enormously powerful, but every time I’ve tried to use it seriously, I end up debugging page breaks in a language I don’t enjoy. HTML + CSS I can live with; browsers I can reason about. So the PDF route is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Markdown → HTML (via Pandoc) → PDF (via &lt;code&gt;wkhtmltopdf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the EPUB route is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Markdown → EPUB (via Pandoc) → validated with &lt;code&gt;epubcheck&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The front matter (cover page, title page, copyright, etc.) is generated with Template Toolkit from a simple &lt;code&gt;book-metadata.yml&lt;/code&gt; file, and then stitched together with the chapters to produce a nice, consistent book.&lt;/p&gt;

&lt;p&gt;That got us a long way… but then a reader found a bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  The iBooks bug report
&lt;/h2&gt;

&lt;p&gt;Shortly after publication, I got an email from a reader who’d bought the Leanpub EPUB and was reading it in Apple Books (iBooks). Instead of happily flipping through &lt;em&gt;Design Patterns in Modern Perl&lt;/em&gt;, they were greeted with a big pink error box.&lt;/p&gt;

&lt;p&gt;Apple’s error message boiled down to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There’s something wrong with the XHTML in this EPUB.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was slightly worrying. But, hey, every day is a learning opportunity. And, after a bit of digging, this is what I found out.&lt;/p&gt;

&lt;p&gt;EPUB 3 files are essentially a ZIP containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;XHTML content files&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;a bit of XML metadata&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CSS, images, and so on&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apple Books is quite strict about the “X” in XHTML: it expects well-formed XML, not just “kind of valid HTML”. So when working with EPUB, you need to forget all of that nice HTML5 flexibility that you’ve got used to over the last decade or so.&lt;/p&gt;

&lt;p&gt;The first job was to see if we could reproduce the error and work out where it was coming from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Discovering &lt;code&gt;epubcheck&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Enter &lt;code&gt;epubcheck&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;epubcheck&lt;/code&gt; is the reference validator for EPUB files. Point it at an &lt;code&gt;.epub&lt;/code&gt; and it will unpack it, parse all the XML/XHTML, check the metadata and manifest, and tell you exactly what’s wrong.&lt;/p&gt;

&lt;p&gt;Running it on the book immediately produced this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fatal Error while parsing file: The element type &lt;code&gt;br&lt;/code&gt; must be terminated by the matching end-tag &lt;code&gt;&amp;lt;/br&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s the XML parser’s way of saying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;In HTML, &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; is fine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In XHTML (which is XML), you must use &lt;code&gt;&amp;lt;br /&amp;gt;&lt;/code&gt; (self-closing) or &lt;code&gt;&amp;lt;br&amp;gt;&amp;lt;/br&amp;gt;&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And there were a number of these scattered across a few chapters.&lt;/p&gt;

&lt;p&gt;In other words: perfectly reasonable raw HTML in the manuscript had been passed straight through by Pandoc into the EPUB, but that HTML was not strictly valid XHTML, so Apple Books rejected it. I should note at this point that the documentation for EPUB explicitly says that it won’t touch HTML fragments it finds in a Markdown file when converting it to EPUB. It’s down to the author to ensure they’re using valid XHTML&lt;/p&gt;




&lt;h2&gt;
  
  
  A quick (but not scalable) fix
&lt;/h2&gt;

&lt;p&gt;Under time pressure, the quickest way to confirm the diagnosis was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Unzip the generated EPUB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open the offending XHTML file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Manually turn &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; into &lt;code&gt;&amp;lt;br /&amp;gt;&lt;/code&gt; in a couple of places.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Re-zip the EPUB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run &lt;code&gt;epubcheck&lt;/code&gt; again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Try it in Apple Books.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That worked. The errors vanished, &lt;code&gt;epubcheck&lt;/code&gt; was happy, and the reader confirmed that the fixed file opened fine in iBooks.&lt;/p&gt;

&lt;p&gt;But clearly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Open the EPUB in a text editor and fix the XHTML by hand&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;is not a sustainable publishing strategy.&lt;/p&gt;

&lt;p&gt;So the next step was to move from “hacky manual fix” to “the pipeline prevents this from happening again”.&lt;/p&gt;




&lt;h2&gt;
  
  
  HTML vs XHTML, and why linters matter
&lt;/h2&gt;

&lt;p&gt;The underlying issue is straightforward once you remember it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;HTML is very forgiving. Browsers will happily fix up all kinds of broken markup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;XHTML is XML, so it’s not forgiving:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;EPUB 3 content files are XHTML. If you feed them sloppy HTML, some readers (like Apple Books) will just refuse to load the chapter.&lt;/p&gt;

&lt;p&gt;So I added a manuscript HTML linter to the toolchain, before we ever get to Pandoc or &lt;code&gt;epubcheck&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Roughly, the linter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Reads the manuscript (ignoring fenced code blocks so it doesn’t complain about &lt;code&gt;&amp;lt;&lt;/code&gt; in Perl examples).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Extracts any raw HTML chunks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wraps those chunks in a temporary root element.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uses &lt;code&gt;XML::LibXML&lt;/code&gt; to check they’re well-formed XML.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reports any errors with file and line number.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s not trying to be a full HTML validator; it’s just checking: “If this HTML ends up in an EPUB, will the XML parser choke?”&lt;/p&gt;

&lt;p&gt;That would have caught the &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; problem before the book ever left my machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hardening the pipeline: &lt;code&gt;epubcheck&lt;/code&gt; in the loop
&lt;/h2&gt;

&lt;p&gt;The linter catches the obvious issues in the manuscript; &lt;code&gt;epubcheck&lt;/code&gt; is still the final authority on the finished EPUB.&lt;/p&gt;

&lt;p&gt;So the pipeline now looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lint the manuscript HTML&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Catch broken raw HTML/XHTML before conversion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build PDF + EPUB via &lt;code&gt;make_book&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;epubcheck&lt;/code&gt; on the EPUB&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Ensure the final file is standards-compliant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Only then do we upload it to Leanpub and Amazon, making it available to eager readers.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The nice side-effect of this is that &lt;em&gt;any&lt;/em&gt; future changes (new CSS, new template, different metadata) still go through the same gauntlet. If something breaks, the pipeline shouts at me long before a reader has to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker and GitHub Actions: making it reproducible
&lt;/h2&gt;

&lt;p&gt;Having a nice Perl script and a list of tools installed on my laptop is fine for a solo project; it’s not great if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;other authors might want to build their own drafts, or&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I want the build to happen automatically in CI.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the next step was to package everything into a Docker image and wire it into GitHub Actions.&lt;/p&gt;

&lt;p&gt;The Docker image is based on a slim Ubuntu and includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Perl + &lt;code&gt;cpanm&lt;/code&gt; + all CPAN modules from the repo’s &lt;code&gt;cpanfile&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;pandoc&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;wkhtmltopdf&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Java + &lt;code&gt;epubcheck&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Perl School utility scripts themselves (&lt;code&gt;make_book&lt;/code&gt;, &lt;code&gt;check_ms_html&lt;/code&gt;, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow in a book repo is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Mount the book’s Git repo into &lt;code&gt;/work&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run &lt;code&gt;check_ms_html&lt;/code&gt; to lint the manuscript.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run &lt;code&gt;make_book&lt;/code&gt; to build &lt;code&gt;built/*.pdf&lt;/code&gt; and &lt;code&gt;built/*.epub&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run &lt;code&gt;epubcheck&lt;/code&gt; on the EPUB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upload the &lt;code&gt;built/&lt;/code&gt; artefacts.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitHub Actions then uses that same image as a &lt;code&gt;container&lt;/code&gt; for the job, so every push or pull request can build the book in a clean, consistent environment, without needing each author to install Pandoc, &lt;code&gt;wkhtmltopdf&lt;/code&gt;, Java, and a large chunk of CPAN locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I’m making this public
&lt;/h2&gt;

&lt;p&gt;At this point, the pipeline feels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;modern (Pandoc, HTML/CSS layout, EPUB 3),&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;robust (lint + &lt;code&gt;epubcheck&lt;/code&gt;),&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;reproducible (Docker + Actions),&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;and not tied to Perl in any deep way.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, &lt;em&gt;Design Patterns in Modern Perl&lt;/em&gt; is a Perl book, and the utilities live under the “Perl School” banner, but nothing is stopping you from using the same setup for your own book on whatever topic you care about.&lt;/p&gt;

&lt;p&gt;So I’ve made the utilities available in a public repository (the &lt;a href="https://github.com/davorg/perlschool-util" rel="noopener noreferrer"&gt;&lt;code&gt;perlschool-util&lt;/code&gt;&lt;/a&gt; repo on GitHub). There you’ll find:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;the build scripts,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the Dockerfile and helper script,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;example GitHub Actions configuration,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;and notes on how to structure a book repo.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’ve ever thought:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I’d like to write a small technical book, but I don’t want to fight with LaTeX or invent a build system from scratch…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;then you’re very much the person I had in mind.&lt;/p&gt;

&lt;p&gt;eBook publishing really is pretty easy once you’ve got a solid pipeline. If these tools help you get your ideas out into the world, that’s a win.&lt;/p&gt;

&lt;p&gt;And, of course, if you’d like to write a book &lt;em&gt;for&lt;/em&gt; Perl School, I’m still &lt;a href="https://perlschool.com/write/" rel="noopener noreferrer"&gt;very interested in talking to potential authors&lt;/a&gt; – especially if you’re doing interesting modern Perl in the real world.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/" rel="noopener noreferrer"&gt;Behind the scenes at Perl School Publishing&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>perlschool</category>
      <category>epub</category>
      <category>linter</category>
      <category>pandoc</category>
    </item>
    <item>
      <title>Dotcom Survivor Syndrome – How Perl’s Early Success Created the Seeds of Its Downfall</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 30 Nov 2025 15:50:59 +0000</pubDate>
      <link>https://forem.com/davorg/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall-1o2c</link>
      <guid>https://forem.com/davorg/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall-1o2c</guid>
      <description>&lt;p&gt;If you were building web applications during the first dot-com boom, chances are you wrote Perl. And if you’re now a CTO, tech lead, or senior architect, you may instinctively steer teams away from it—even if you can’t quite explain why.&lt;/p&gt;

&lt;p&gt;This reflexive aversion isn’t just a preference. It’s what I call &lt;strong&gt;Dotcom Survivor Syndrome&lt;/strong&gt; : a long-standing bias formed by the messy, experimental, high-pressure environment of the early web, where Perl was both a lifeline and a liability.&lt;/p&gt;

&lt;p&gt;Perl wasn’t the problem. The conditions under which we used it were. And unfortunately, those conditions, combined with a separate, prolonged misstep over versioning, continue to distort Perl’s reputation to this day.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;The Glory Days: Perl at the Heart of the Early Web&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;In the mid- to late-1990s, Perl was the web’s duct tape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;It powered CGI scripts on Apache servers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It automated deployments before DevOps had a name.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It parsed logs, scraped data, processed form input, and glued together whatever needed glueing.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Perl 5&lt;/strong&gt; , released in 1994, introduced real structure: references, modules, and the birth of &lt;strong&gt;CPAN&lt;/strong&gt; , which became one of the most effective software ecosystems in the world.&lt;/p&gt;

&lt;p&gt;Perl wasn’t just part of the early web—it was &lt;strong&gt;instrumental in creating it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;The Dotcom Boom: Shipping Fast and Breaking Everything&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;To understand the long shadow Perl casts, you have to understand the speed and pressure of the dot-com boom.&lt;/p&gt;

&lt;p&gt;We weren’t just building websites.&lt;br&gt;&lt;br&gt;
We were inventing &lt;strong&gt;how&lt;/strong&gt; to build websites.&lt;/p&gt;

&lt;p&gt;Best practices? Mostly unwritten.&lt;br&gt;&lt;br&gt;
Frameworks? Few existed.&lt;br&gt;&lt;br&gt;
Code reviews? Uncommon.&lt;br&gt;&lt;br&gt;
Continuous integration? Still a dream.&lt;/p&gt;

&lt;p&gt;The pace was frantic. You built something overnight, demoed it in the morning, and deployed it that afternoon. And Perl let you do that.&lt;/p&gt;

&lt;p&gt;But that same flexibility—its greatest strength—became its greatest weakness in that environment. With deadlines looming and scalability an afterthought, we ended up with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Thousands of lines of unstructured CGI scripts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Minimal documentation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Global variables everywhere&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inline HTML mixed with business logic&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Security holes you could drive a truck through&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the crash came, these codebases didn’t age gracefully. The people who inherited them, often the same people who now run engineering orgs, remember Perl not as a powerful tool, but as the source of late-night chaos and technical debt.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Dotcom Survivor Syndrome: Bias with a Backstory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Many senior engineers today carry these memories with them. They associate Perl with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Fragile legacy systems&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inconsistent, “write-only” code&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The bad old days of early web development&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that’s understandable. But it also creates a bias—often unconscious—that prevents Perl from getting a fair hearing in modern development discussions.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Version Number Paralysis: The Perl 6 Effect&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If Dotcom Boom Survivor Syndrome created the emotional case against Perl, then Perl 6 created the optical one.&lt;/p&gt;

&lt;p&gt;In 2000, Perl 6 was announced as a ground-up redesign of the language. It promised modern syntax, new paradigms, and a bright future. But it didn’t ship—not for a very long time.&lt;/p&gt;

&lt;p&gt;In the meantime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Perl 5 continued to evolve quietly,&lt;/strong&gt; but with the &lt;em&gt;implied expectation&lt;/em&gt; that it would eventually be replaced.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Years turned into decades&lt;/strong&gt; , and confusion set in. Was Perl 5 deprecated? Was Perl 6 compatible? What was the future of Perl?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To outsiders—and even many Perl users—it looked like the language was stalled. Perl 5 releases were labelled 5.8, 5.10, 5.12… but never 6. Perl 6 finally emerged in 2015, but as an entirely different language, not a successor.&lt;/p&gt;

&lt;p&gt;Eventually, the community admitted what everyone already knew: Perl 6 wasn’t Perl. In 2019, it was renamed Raku.&lt;/p&gt;

&lt;p&gt;But the damage was done. For nearly two decades, the version number “6” hung over Perl 5 like a storm cloud – a constant reminder that its future was uncertain, even when that wasn’t true.&lt;/p&gt;

&lt;p&gt;This is what I call &lt;strong&gt;Version Number Paralysis&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A stalled major version that made the language look obsolete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A missed opportunity to signal continued relevance and evolution.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A marketing failure that deepened the sense that Perl was a thing of the past.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even today, many developers believe Perl is “stuck at version 5,” unaware that modern Perl is actively maintained, well-supported, and quite capable.&lt;/p&gt;

&lt;p&gt;While Dotcom Survivor Syndrome left many people with an aversion to Perl, Version Number Paralysis gave them an excuse not to look closely at Perl to see if it had changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What They Missed While Looking Away&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;While the world was confused or looking elsewhere, Perl 5 gained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Modern object systems (Moo, Moose)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A mature testing culture (Test::More, Test2)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Widespread use of best practices (Perl::Critic, perltidy, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Core team stability and annual releases&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Huge CPAN growth and refinements&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But those who weren’t paying attention, especially those still carrying dotcom-era baggage, never saw it. They still think Perl looks like it did in 2002.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Can We Move On?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Dotcom Survivor Syndrome is real. So is Version Number Paralysis. Together, they’ve unfairly buried a language that remains fast, expressive, and battle-tested.&lt;/p&gt;

&lt;p&gt;We can’t change the past. But we can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Acknowledge the emotional and historical baggage&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Celebrate the role Perl played in inventing the modern web&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Educate developers about what Perl really is today&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Push back against the assumption that old == obsolete&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Perl’s early success was its own undoing. It became the default tool for the first web boom, and in doing so, it took the brunt of that era’s chaos. Then, just as it began to mature, its versioning story confused the industry into thinking it had stalled.&lt;/p&gt;

&lt;p&gt;But the truth is that modern Perl is thriving quietly in the margins – maintained by a loyal community, used in production, and capable of great things.&lt;/p&gt;

&lt;p&gt;The only thing holding it back is a generation of developers still haunted by memories of CGI scripts, and a version number that suggested a future that never came.&lt;/p&gt;

&lt;p&gt;Maybe it’s time we looked again.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/" rel="noopener noreferrer"&gt;Dotcom Survivor Syndrome – How Perl’s Early Success Created the Seeds of Its Downfall&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>perl</category>
      <category>developerbias</category>
      <category>dotcom</category>
      <category>survivorsyndrome</category>
    </item>
    <item>
      <title>Elderly Camels in the Cloud</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Sun, 23 Nov 2025 16:12:23 +0000</pubDate>
      <link>https://forem.com/davorg/elderly-camels-in-the-cloud-3pog</link>
      <guid>https://forem.com/davorg/elderly-camels-in-the-cloud-3pog</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/davorg/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run-omb"&gt;last week’s post&lt;/a&gt; I showed how to run a &lt;strong&gt;modern Dancer2 app&lt;/strong&gt; on Google Cloud Run. That’s lovely if your codebase already speaks PSGI and lives in a nice, testable, framework-shaped box.&lt;/p&gt;

&lt;p&gt;But that’s not where a lot of Perl lives.&lt;/p&gt;

&lt;p&gt;Plenty of useful Perl on the internet is still stuck in &lt;strong&gt;old-school CGI&lt;/strong&gt; – the kind of thing you’d drop into &lt;code&gt;cgi-bin&lt;/code&gt; on a shared host in 2003 and then try not to think about too much.&lt;/p&gt;

&lt;p&gt;So in this post, I want to show that:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you can run a Dancer2 app on Cloud Run, you can also run &lt;strong&gt;ancient CGI&lt;/strong&gt; on Cloud Run – without rewriting it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To keep things on the right side of history, we’ll use &lt;strong&gt;nms FormMail&lt;/strong&gt; rather than Matt Wright’s original script, but the principle is exactly the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites: Google Cloud and Cloud Run
&lt;/h2&gt;

&lt;p&gt;If you already followed the Dancer2 post and have Cloud Run working, you can skip this section and go straight to “Wrapping nms FormMail in PSGI”.&lt;/p&gt;

&lt;p&gt;If not, here’s the minimum you need.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Google account and project&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable billing&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install the &lt;code&gt;gcloud&lt;/code&gt; CLI&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable required APIs&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create a Docker repository in Artifact Registry&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s all the GCP groundwork. Now we can worry about Perl.&lt;/p&gt;




&lt;h2&gt;
  
  
  The starting point: an old CGI FormMail
&lt;/h2&gt;

&lt;p&gt;Our starting assumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You already have a CGI script like &lt;strong&gt;nms FormMail&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It’s a single “.pl” file, intended to be dropped into “cgi-bin”&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It expects to be called via the CGI interface and send mail using:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;open my $mail, '|-', '/usr/sbin/sendmail -t'&lt;br&gt;
or die "Can't open sendmail: $!";&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On a traditional host, Apache (or similar) would:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;parse the HTTP request&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;set CGI environment variables (&lt;code&gt;REQUEST_METHOD&lt;/code&gt;, &lt;code&gt;QUERY_STRING&lt;/code&gt;, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;run &lt;code&gt;formmail.pl&lt;/code&gt; as a process&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;let it call &lt;code&gt;/usr/sbin/sendmail&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloud Run gives us none of that. It gives us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;a HTTP endpoint&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;backed by a container&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;listening on a port (&lt;code&gt;$PORT&lt;/code&gt;)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our job is to recreate &lt;em&gt;just enough&lt;/em&gt; of that old environment inside a container.&lt;/p&gt;

&lt;p&gt;We’ll do that in two small pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;PSGI wrapper&lt;/strong&gt; that emulates CGI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;sendmail shim&lt;/strong&gt; so the script can still “talk” sendmail.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Architecture in one paragraph
&lt;/h2&gt;

&lt;p&gt;Inside the container we’ll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;nms FormMail&lt;/strong&gt; – unchanged CGI script at &lt;code&gt;/app/formmail.pl&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PSGI wrapper&lt;/strong&gt; (&lt;code&gt;app.psgi&lt;/code&gt;) – using &lt;code&gt;CGI::Compile&lt;/code&gt; and &lt;code&gt;CGI::Emulate::PSGI&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Plack/Starlet&lt;/strong&gt; – a simple HTTP server exposing &lt;code&gt;app.psgi&lt;/code&gt; on &lt;code&gt;$PORT&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;msmtp-mta&lt;/strong&gt; – providing &lt;code&gt;/usr/sbin/sendmail&lt;/code&gt; and relaying mail to a real SMTP server&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloud Run just sees “HTTP service running in a container”. Our CGI script still thinks it’s on a early-2000s shared host.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1 – Wrapping nms FormMail in PSGI
&lt;/h2&gt;

&lt;p&gt;First we write a tiny PSGI wrapper. This is the only new Perl we need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# app.psgi

use strict;
use warnings;

use CGI::Compile;
use CGI::Emulate::PSGI;

# Path inside the container
my $cgi_script = "/app/formmail.pl";

# Compile the CGI script into a coderef
my $cgi_app = CGI::Compile-&amp;gt;compile($cgi_script);

# Wrap it in a PSGI-compatible app
my $app = CGI::Emulate::PSGI-&amp;gt;handler($cgi_app);

# Return PSGI app
$app;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;CGI::Compile&lt;/code&gt; loads the CGI script and turns its &lt;code&gt;main&lt;/code&gt; package into a coderef.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;CGI::Emulate::PSGI&lt;/code&gt; fakes the CGI environment for each request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The CGI script doesn’t know or care that it’s no longer being run by Apache.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Later, we’ll run this with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;plackup -s Starlet -p ${PORT:-8080} app.psgi&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 – Adding a sendmail shim
&lt;/h2&gt;

&lt;p&gt;Next problem: &lt;strong&gt;Cloud Run doesn’t give you a local mail transfer agent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There is no real &lt;code&gt;/usr/sbin/sendmail&lt;/code&gt;, and you wouldn’t want to run a full MTA in a stateless container anyway.&lt;/p&gt;

&lt;p&gt;Instead, we’ll install &lt;strong&gt;msmtp-mta&lt;/strong&gt; , a light-weight SMTP client that includes a &lt;strong&gt;sendmail-compatible wrapper&lt;/strong&gt;. It gives you a &lt;code&gt;/usr/sbin/sendmail&lt;/code&gt; binary that forwards mail to a remote SMTP server (Mailgun, SES, your mail provider, etc.).&lt;/p&gt;

&lt;p&gt;From the CGI script’s point of view, nothing changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open my $mail, '|-', '/usr/sbin/sendmail -t'
  or die "Can't open sendmail: $!";
# ... write headers and body ...
close $mail;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, msmtp ships it off to your configured SMTP server.&lt;/p&gt;

&lt;p&gt;We’ll configure msmtp from environment variables at &lt;strong&gt;container start-up&lt;/strong&gt; , so Cloud Run’s &lt;code&gt;--set-env-vars&lt;/code&gt; values are actually used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 – Dockerfile (+ entrypoint) for Perl, PSGI and sendmail shim
&lt;/h2&gt;

&lt;p&gt;Here’s a complete Dockerfile that pulls this together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM perl:5.40

# Install msmtp-mta as a sendmail-compatible shim
RUN apt-get update &amp;amp;&amp;amp; \
    apt-get install -y --no-install-recommends msmtp-mta ca-certificates &amp;amp;&amp;amp; \
    rm -rf /var/lib/apt/lists/*

# Install Perl dependencies
RUN cpanm --notest \
    CGI::Compile \
    CGI::Emulate::PSGI \
    Plack \
    Starlet

WORKDIR /app

# Copy nms FormMail (unchanged) and the PSGI wrapper
COPY formmail.pl app.psgi /app/
RUN chmod 755 /app/formmail.pl

# Entrypoint script that:
# 1. writes /etc/msmtprc from environment variables
# 2. starts the PSGI server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENV PORT=8080

EXPOSE 8080

CMD ["docker-entrypoint.sh"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here’s the &lt;code&gt;docker-entrypoint.sh&lt;/code&gt; script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/sh

set -e

# Reasonable defaults

: "${MSMTP_ACCOUNT:=default}"
: "${MSMTP_PORT:=587}"

if [-z "$MSMTP_HOST"] || [-z "$MSMTP_USER"] || [-z "$MSMTP_PASSWORD"] || [-z "$MSMTP_FROM"]; then
  echo "Warning: MSMTP_* environment variables not fully set; mail probably won't work." &amp;gt;&amp;amp;2
fi

cat &amp;gt; /etc/msmtprc &amp;lt;&amp;lt;EOF
defaults
auth on
tls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile /var/log/msmtp.log

account ${MSMTP_ACCOUNT}
host ${MSMTP_HOST}
port ${MSMTP_PORT}
user ${MSMTP_USER}
password ${MSMTP_PASSWORD}
from ${MSMTP_FROM}

account default : ${MSMTP_ACCOUNT}
EOF

chmod 600 /etc/msmtprc

# Start the PSGI app
exec plackup -s Starlet -p "${PORT:-8080}" app.psgi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key points you might want to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;We &lt;strong&gt;never touch &lt;code&gt;formmail.pl&lt;/code&gt;&lt;/strong&gt;. It goes into &lt;code&gt;/app&lt;/code&gt; and that’s it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;msmtp gives us &lt;code&gt;/usr/sbin/sendmail&lt;/code&gt;, so the CGI script stays in its 1990s comfort zone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The entrypoint writes &lt;code&gt;/etc/msmtprc&lt;/code&gt; at runtime, so Cloud Run’s environment variables are actually used.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 4 – Building and pushing the image
&lt;/h2&gt;

&lt;p&gt;With the Dockerfile and &lt;code&gt;docker-entrypoint.sh&lt;/code&gt; in place, we can build and push the image to Artifact Registry.&lt;/p&gt;

&lt;p&gt;I’ll assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Project ID: &lt;code&gt;PROJECT_ID&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Region: &lt;code&gt;europe-west1&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Repository: &lt;code&gt;formmail-repo&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Image name: &lt;code&gt;nms-formmail&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, build the image &lt;strong&gt;locally&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker build -t europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure Docker to authenticate against Artifact Registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud auth configure-docker europe-west1-docker.pkg.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now push the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker push europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’d rather not install Docker locally, you can let Google Cloud Build do this for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud builds submit \
  --tag europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use whichever workflow your team is happier with; Cloud Run doesn’t care how the image got there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 – Deploying to Cloud Run
&lt;/h2&gt;

&lt;p&gt;Now we can create a Cloud Run service from that image.&lt;/p&gt;

&lt;p&gt;You’ll need SMTP settings from somewhere (Mailgun, SES, your mail provider). I’ll use “Mailgun-ish” examples here; adjust as required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud run deploy nms-formmail \
  --image=europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest \
  --platform=managed \
  --region=europe-west1 \
  --allow-unauthenticated \
  --set-env-vars MSMTP_HOST=smtp.mailgun.org \
  --set-env-vars MSMTP_PORT=587 \
  --set-env-vars MSMTP_USER=postmaster@mg.example.com \
  --set-env-vars MSMTP_PASSWORD=YOUR_SMTP_PASSWORD \
  --set-env-vars MSMTP_FROM=webforms@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud Run will give you a HTTPS URL, something like:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://nms-formmail-abcdefgh-uk.a.run.app&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Your HTML form (on whatever website you like) can now post to that URL.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;form action="https://nms-formmail-abcdefgh-uk.a.run.app/formmail.pl" method="post"&amp;gt;
  &amp;lt;input type="hidden" name="recipient" value="contact@example.com"&amp;gt;
  &amp;lt;input type="email" name="email" required&amp;gt;
  &amp;lt;textarea name="comments" required&amp;gt;&amp;lt;/textarea&amp;gt;
  &amp;lt;button type="submit"&amp;gt;Send&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on how you wire the routes, you may also just post to &lt;code&gt;/&lt;/code&gt; – the important point is that the request hits the PSGI app, which faithfully re-creates the CGI environment and hands control to &lt;code&gt;formmail.pl&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much work did we actually do?
&lt;/h2&gt;

&lt;p&gt;Compared to &lt;a href="https://dev.to/davorg/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run-omb"&gt;the Dancer2 example&lt;/a&gt;, the interesting bit here is what we &lt;strong&gt;didn’t&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;We didn’t convert the CGI script to PSGI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We didn’t add a framework.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;We didn’t touch its mail-sending code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We just:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Wrapped it with &lt;code&gt;CGI::Emulate::PSGI&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dropped a sendmail shim in front of a real SMTP service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Put it in a container and let Cloud Run handle the scaling and HTTPS.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’ve still got a cupboard full of old CGI scripts doing useful work, this is a nice way to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;get them off fragile shared hosting&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;put them behind HTTPS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;run them in an environment you understand (Docker + Cloud Run)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;without having to justify a full rewrite up front&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When should you rewrite instead?
&lt;/h2&gt;

&lt;p&gt;This trick is handy, but it’s not a time machine.&lt;/p&gt;

&lt;p&gt;If you find yourself wanting to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;add tests&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;share logic between multiple scripts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;integrate with a modern app or API&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;do anything more complex than “receive a form, send an email”&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…then you probably &lt;em&gt;do&lt;/em&gt; want to migrate the logic into a Dancer2 (or other PSGI) app properly.&lt;/p&gt;

&lt;p&gt;But as a &lt;strong&gt;first step&lt;/strong&gt; – or as a way to de-risk moving away from legacy hosting – wrapping CGI for Cloud Run works surprisingly well.&lt;/p&gt;




&lt;h2&gt;
  
  
  FormMail is still probably a bad idea
&lt;/h2&gt;

&lt;p&gt;All of this proves that you &lt;em&gt;can&lt;/em&gt; take a very old CGI script and run it happily on Cloud Run. It does &lt;strong&gt;not&lt;/strong&gt; magically turn FormMail into a good idea in 2025.&lt;/p&gt;

&lt;p&gt;The usual caveats still apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spam and abuse&lt;/strong&gt; – anything that will send arbitrary email based on untrusted input is a magnet for bots. You’ll want rate limiting, CAPTCHA, some basic content checks, and probably logging and alerting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Validation and sanitisation&lt;/strong&gt; – a lot of classic FormMail deployments were “drop it in and hope”. If you’re going to the trouble of containerising it, you should at least ensure it’s a recent nms version, configured properly, and locked down to only the recipients you expect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Better alternatives&lt;/strong&gt; – for any new project, you’d almost certainly build a tiny API endpoint or Dancer2 route that validates input, talks to a proper mail-sending service, and returns JSON. The CGI route is really a migration trick, not a recommendation for fresh code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So think of this pattern as a &lt;strong&gt;bridge&lt;/strong&gt; for legacy, not a template for greenfield development.&lt;/p&gt;




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

&lt;p&gt;In the &lt;a href="https://dev.to/davorg/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run-omb"&gt;previous post&lt;/a&gt; we saw how nicely a modern Dancer2 app fits on Cloud Run: PSGI all the way down, clean deployment, no drama. This time we’ve taken almost the opposite starting point – a creaky old CGI FormMail – and shown that you can still bring it along for the ride with surprisingly little effort.&lt;/p&gt;

&lt;p&gt;We didn’t rewrite the script, we didn’t introduce a framework, and we didn’t have to fake an entire 90s LAMP stack. We just wrapped the CGI in PSGI, dropped in a sendmail shim, and let Cloud Run do what it does best: run a container that speaks HTTP.&lt;/p&gt;

&lt;p&gt;If you’ve got a few ancient Perl scripts quietly doing useful work on shared hosting, this might be enough to get them onto modern infrastructure without a big-bang rewrite. And once they’re sitting in containers, behind HTTPS, with proper logging and observability, you’ll be in a much better place to decide which ones deserve a full Dancer2 makeover – and which ones should finally be retired.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/" rel="noopener noreferrer"&gt;Elderly Camels in the Cloud&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>web</category>
      <category>cloud</category>
      <category>deployment</category>
      <category>docker</category>
    </item>
    <item>
      <title>Dancing in the Clouds: Moving Dancer2 Apps from a VPS to Cloud Run</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Thu, 13 Nov 2025 16:48:00 +0000</pubDate>
      <link>https://forem.com/davorg/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run-omb</link>
      <guid>https://forem.com/davorg/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run-omb</guid>
      <description>&lt;p&gt;For years, most of my Perl web apps lived happily enough on a VPS. I had full control of the box, I could install whatever I liked, and I knew where everything lived.&lt;/p&gt;

&lt;p&gt;In fact, over the last eighteen months or so, I wrote a series of blog posts explaining how I developed &lt;a href="https://dev.to/davorg/deploying-dancer-apps-c0l"&gt;a system for deploying&lt;/a&gt; &lt;a href="https://dev.to/davorg/deploying-dancer-apps-addendum-4a32"&gt;Dancer2 apps&lt;/a&gt; and, eventually, &lt;a href="https://dev.to/davorg/deploying-dancer-apps-the-next-generation-3nc8"&gt;controlling them using systemd&lt;/a&gt;. I’m slightly embarrassed by those posts now.&lt;/p&gt;

&lt;p&gt;Because the control that my VPS gave me also came with a price: I also had to worry about OS upgrades, SSL renewals, kernel updates, and the occasional morning waking up to automatic notifications that one of my apps had been offline since midnight.&lt;/p&gt;

&lt;p&gt;Back in 2019, I started writing a series of blog posts called &lt;a href="https://dev.to/davorg/moving-into-the-cloud-4p4"&gt;Into the Cloud&lt;/a&gt; that would follow my progress as I moved all my apps into Docker containers. But real life intruded and I never made much progress on the project.&lt;/p&gt;

&lt;p&gt;Recently, I returned to this idea (yes, I’m at least five years late here!) I’ve been working on migrating those old Dancer2 applications from my IONOS VPS to Google Cloud Run. The difference has been amazing. My apps now run in their own containers, scale automatically, and the server infrastructure requires almost no maintenance.&lt;/p&gt;

&lt;p&gt;This post walks through how I made the jump – and how you can too – using &lt;strong&gt;Perl, Dancer2, Docker, GitHub Actions, and Google Cloud Run&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why move away from a VPS?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Running everything on a single VPS used to make sense. You could &lt;code&gt;ssh&lt;/code&gt; in, restart services, and feel like you were in control. But over time, the drawbacks grow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You have to maintain the OS and packages yourself.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One bad app or memory leak can affect everything else.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You’re paying for full-time CPU and RAM even when nothing’s happening.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Scaling means provisioning a new server — not something you do in a coffee break.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloud Run, on the other hand, runs each app as a container and &lt;strong&gt;only charges you while requests are being served&lt;/strong&gt;. When no-one’s using your app, it scales to zero and costs nothing.&lt;/p&gt;

&lt;p&gt;Even better: no servers to patch, no ports to open, no SSL certificates to renew — Google does all of that for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What we’ll build&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here’s the plan. We’ll take a simple &lt;strong&gt;Dancer2&lt;/strong&gt; app and:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Package it as a &lt;strong&gt;Docker container&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build that container automatically in &lt;strong&gt;GitHub Actions&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deploy it to &lt;strong&gt;Google Cloud Run&lt;/strong&gt; , where it runs securely and scales automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Map a custom domain to it and forget about server admin forever.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’ve never touched Docker or Cloud Run before, don’t worry – I’ll explain what’s going on as we go.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why Cloud Run fits Perl surprisingly well&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Perl’s ecosystem has always valued stability and control. Containers give you both: you can lock in a Perl version, CPAN modules, and any shared libraries your app needs. The image you build today will still work next year.&lt;/p&gt;

&lt;p&gt;Cloud Run runs those containers on demand. It’s effectively a managed &lt;code&gt;starman&lt;/code&gt; farm where Google handles the hard parts – scaling, routing, and HTTPS.&lt;/p&gt;

&lt;p&gt;You pay for CPU and memory per request, not per server. For small or moderate-traffic Perl apps, it’s often &lt;strong&gt;well under £1/month&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 1: Dockerising a Dancer2 app&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you’re new to Docker, think of it as a way of bundling &lt;em&gt;your whole environment&lt;/em&gt; — Perl, modules, and configuration — into a portable image. It’s like freezing a working copy of your app so it can run identically anywhere.&lt;/p&gt;

&lt;p&gt;Here’s a minimal &lt;code&gt;Dockerfile&lt;/code&gt; for a Dancer2 app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM perl:5.42
LABEL maintainer="dave@perlhacks.com"

# Install Carton and Starman
RUN cpanm Carton Starman

# Copy the app into the container
COPY . /app
WORKDIR /app

# Install dependencies
RUN carton install --deployment

EXPOSE 8080
CMD ["carton", "exec", "starman", "--port", "8080", "bin/app.psgi"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s break that down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;FROM perl:5.42&lt;/code&gt; — starts from an official Perl image on Docker Hub.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Carton&lt;/code&gt; keeps dependencies consistent between environments.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The app is copied into &lt;code&gt;/app&lt;/code&gt;, and &lt;code&gt;carton install --deployment&lt;/code&gt; installs exactly what’s in your &lt;code&gt;cpanfile.snapshot&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The container exposes port 8080 (Cloud Run’s default).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &lt;code&gt;CMD&lt;/code&gt; runs Starman, serving your Dancer2 app.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test it locally:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker build -t myapp .&lt;br&gt;
docker run -p 8080:8080 myapp&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Then visit &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;. If you see your Dancer2 homepage, you’ve successfully containerised your app.&lt;/p&gt;


&lt;h2&gt;
  
  
  &lt;strong&gt;Step 2: Building the image in GitHub Actions&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once it works locally, we can automate it. GitHub Actions will build and push our image to &lt;strong&gt;Google Artifact Registry&lt;/strong&gt; whenever we push to &lt;code&gt;main&lt;/code&gt; or tag a release.&lt;/p&gt;

&lt;p&gt;Here’s a simplified workflow file (&lt;code&gt;.github/workflows/build.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Build container

on:
  push:
    branches: [main]
    tags: ['v*']
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: google-github-actions/setup-gcloud@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT }}
          service_account_email: ${{ secrets.GCP_SA_EMAIL }}
          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}

      - name: Build and push image
        run: |
          IMAGE="europe-west1-docker.pkg.dev/${{ secrets.GCP_PROJECT }}/containers/myapp:$GITHUB_SHA"
          docker build -t $IMAGE .
          docker push $IMAGE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll notice a few secrets referenced in the workflow — things like your Google Cloud project ID and credentials. These are stored &lt;strong&gt;securely in GitHub Actions&lt;/strong&gt;. When the workflow runs, GitHub uses those secrets to authenticate as you and access your Google Cloud account, so it can push the new container image or deploy your app.&lt;/p&gt;

&lt;p&gt;You only set those secrets up once, and they’re encrypted and hidden from everyone else — even if your repository is public.&lt;/p&gt;

&lt;p&gt;Once that’s set up, every push builds a fresh, versioned container image.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 3: Deploying to Cloud Run&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now we’re ready to run it in the cloud. We’ll do that using Google’s command line program, &lt;code&gt;gcloud&lt;/code&gt;. It’s available from &lt;a href="https://cloud.google.com/sdk/docs/install" rel="noopener noreferrer"&gt;Google’s official downloads&lt;/a&gt; or through most Linux package managers — for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Fedora, RedHat or similar
sudo dnf install google-cloud-cli
# or on Debian/Ubuntu:
sudo apt install google-cloud-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, authenticate it with your Google account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud auth login
gcloud config set project your-project-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That links the CLI to your Google Cloud project and lets it perform actions like deploying to Cloud Run.&lt;/p&gt;

&lt;p&gt;Once that’s done, you can deploy manually from the command line:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gcloud run deploy myapp \&lt;br&gt;
--image=europe-west1-docker.pkg.dev/MY_PROJECT/containers/myapp:$GITHUB_SHA \&lt;br&gt;
--region=europe-west1 \&lt;br&gt;
--allow-unauthenticated \&lt;br&gt;
--port=8080&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This tells Cloud Run to start a new service called &lt;code&gt;myapp&lt;/code&gt;, using the image we just built.&lt;/p&gt;

&lt;p&gt;After a minute or two, Google will give you a live HTTPS URL, like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://myapp-abcdef12345-ew.a.run.app" rel="noopener noreferrer"&gt;https://myapp-abcdef12345-ew.a.run.app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visit it — and if all went well, you’ll see your familiar Dancer2 app, running happily on Cloud Run.&lt;/p&gt;

&lt;p&gt;To connect your own domain, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud run domain-mappings create \
--service=myapp \
--domain=myapp.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update your DNS records as instructed. Within an hour or so, Cloud Run will issue a free SSL certificate for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 4: Automating the deployment&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once the manual deployment works, we can automate it too.&lt;/p&gt;

&lt;p&gt;Here’s a second GitHub Actions workflow (&lt;code&gt;deploy.yml&lt;/code&gt;) that triggers after a successful build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Deploy container

on:
  workflow_run:
    workflows: ["Build container"]
    types: [completed]

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - uses: google-github-actions/setup-gcloud@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT }}
          service_account_email: ${{ secrets.GCP_SA_EMAIL }}
          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy myapp \
            --image=europe-west1-docker.pkg.dev/${{ secrets.GCP_PROJECT }}/containers/myapp:$GITHUB_SHA \
            --region=europe-west1 \
            --allow-unauthenticated \
            --port=8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every successful push to &lt;code&gt;main&lt;/code&gt; results in an automatic deployment to production.&lt;/p&gt;

&lt;p&gt;You can take it further by splitting environments — e.g. &lt;code&gt;main&lt;/code&gt; deploys to staging, tagged releases to production — but even this simple setup is a big step forward from &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;git pull&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 5: Environment variables and configuration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Each Cloud Run service can have its own configuration and secrets. You can set these from the console or CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud run services update myapp \
  --set-env-vars="DANCER_ENV=production,DATABASE_URL=postgres://..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your Dancer2 app, you can then access them with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ENV{DATABASE_URL}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s a good idea to keep database credentials and API keys out of your code and inject them at deploy time like this.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 6: Monitoring and logs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cloud Run integrates neatly with Google Cloud’s logging tools.&lt;/p&gt;

&lt;p&gt;To see recent logs from your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcloud logs read --project=$PROJECT_NAME --service=myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll see your Dancer2 &lt;code&gt;warn&lt;/code&gt; and &lt;code&gt;die&lt;/code&gt; messages there too, because STDOUT and STDERR are automatically captured.&lt;/p&gt;

&lt;p&gt;If you prefer a UI, you can use the Cloud Console’s Log Explorer to filter by service or severity.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Step 7: The payoff&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once you’ve done one migration, the next becomes almost trivial. Each Dancer2 app gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Its own Dockerfile and GitHub workflows.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Its own Cloud Run service and domain.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Its own scaling and logging.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And none of them share a single byte of RAM with each other.&lt;/p&gt;

&lt;p&gt;Here’s how the experience compares:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Old VPS&lt;/th&gt;
&lt;th&gt;Cloud Run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS maintenance&lt;/td&gt;
&lt;td&gt;Manual upgrades&lt;/td&gt;
&lt;td&gt;Managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Fixed size&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL&lt;/td&gt;
&lt;td&gt;Let’s Encrypt renewals&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;SSH + git pull&lt;/td&gt;
&lt;td&gt;Push to GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Fixed monthly&lt;/td&gt;
&lt;td&gt;Pay-per-request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downtime risk&lt;/td&gt;
&lt;td&gt;One app can crash all&lt;/td&gt;
&lt;td&gt;Each isolated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For small apps with light traffic, Cloud Run often costs &lt;strong&gt;pennies per month&lt;/strong&gt; – less than the price of a coffee for peace of mind.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Lessons learned&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After a few migrations, a few patterns emerged:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep apps self-contained.&lt;/strong&gt; Don’t share config or code across services; treat each app as a unit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use digest-based deploys.&lt;/strong&gt; Deploy by image digest (&lt;code&gt;@sha256:...&lt;/code&gt;) rather than tag for true immutability.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Logs are your friend.&lt;/strong&gt; Cloud Run’s logs are rich; you rarely need to &lt;code&gt;ssh&lt;/code&gt; anywhere again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cold starts exist, but aren’t scary.&lt;/strong&gt; If your app is infrequently used, expect the first request after a while to take a second longer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI/CD is liberating.&lt;/strong&gt; Once the pipeline’s in place, deployment becomes a non-event.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Costs and practicalities&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One of the most pleasant surprises was the cost. My smallest Dancer2 app, which only gets a handful of requests each day, usually costs &lt;strong&gt;under £0.50/month&lt;/strong&gt; on Cloud Run. Heavier ones rarely top a few pounds.&lt;/p&gt;

&lt;p&gt;Compare that to the £10–£15/month I was paying for the old VPS — and the VPS didn’t scale, didn’t auto-restart cleanly, and didn’t come with HTTPS certificates for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What’s next&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This post covers the essentials: containerising a Dancer2 app and deploying it to Cloud Run via GitHub Actions.&lt;/p&gt;

&lt;p&gt;In future articles, I’ll look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Connecting to persistent databases.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Using caching.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Adding monitoring and dashboards.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Managing secrets with &lt;strong&gt;Google Secret Manager&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;After two decades of running Perl web apps on traditional servers, Cloud Run feels like the future has finally caught up with me.&lt;/p&gt;

&lt;p&gt;You still get to write your code in Dancer2 – the framework that’s made Perl web development fun for years – but you deploy it in a way that’s modern, repeatable, and blissfully low-maintenance.&lt;/p&gt;

&lt;p&gt;No more patching kernels. No more 3 a.m. alerts. Just code, commit, and dance in the clouds.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/" rel="noopener noreferrer"&gt;Dancing in the Clouds: Moving Dancer2 Apps from a VPS to Cloud Run&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>deployment</category>
      <category>docker</category>
      <category>perl</category>
    </item>
    <item>
      <title>Easy SEO for lazy programmers</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Wed, 24 Sep 2025 10:22:53 +0000</pubDate>
      <link>https://forem.com/davorg/easy-seo-for-lazy-programmers-2lb2</link>
      <guid>https://forem.com/davorg/easy-seo-for-lazy-programmers-2lb2</guid>
      <description>&lt;p&gt;A few of my recent projects—like &lt;a href="https://cookingvinyl.dave.org.uk/" rel="noopener noreferrer"&gt;&lt;strong&gt;Cooking Vinyl Compilations&lt;/strong&gt;&lt;/a&gt; and &lt;a href="https://readabooker.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;ReadABooker&lt;/strong&gt;&lt;/a&gt;—aim to earn a little money via affiliate links. That only works if people actually find the pages, share them, and get decent previews in social apps. In other words: the boring, fragile glue of SEO and social meta tags matters.&lt;/p&gt;

&lt;p&gt;As I lined up a couple more sites in the same vein, I noticed I was writing &lt;em&gt;very&lt;/em&gt; similar code again and again: take an object with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, and spray out the right &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tags for Google, Twitter, Facebook, iMessage, Slack, and so on. It’s fiddly, easy to get 80% right, and annoying to maintain across projects. So I pulled it into a small Moo role—&lt;a href="https://metacpan.org/pod/MooX::Role::SEOTa" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;MooX::Role::SEOTags&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;—that any page-ish class can consume and just emit the right tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are these tags and why should you care?
&lt;/h2&gt;

&lt;p&gt;When someone shares your page, platforms read a handful of standardised tags to decide what to show in the preview:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph (&lt;code&gt;og:*&lt;/code&gt;)&lt;/strong&gt; — The de-facto standard for title, description, URL, image, and type. Used by Facebook, WhatsApp, Slack, iMessage and others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter Cards (&lt;code&gt;twitter:*&lt;/code&gt;)&lt;/strong&gt; — Similar idea for Twitter/X; the common pattern is &lt;code&gt;twitter:card=summary_large_image&lt;/code&gt; plus title/description/image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classic SEO tags&lt;/strong&gt; — &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, and a canonical URL tell search engines what the page is &lt;em&gt;about&lt;/em&gt; and which URL is the “official” one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MooX::Role::SEOTags gives you one method that renders all of that, consistently, from your object’s attributes.&lt;/p&gt;

&lt;p&gt;For more information about OpenGraph, see &lt;a href="https://ogp.me/" rel="noopener noreferrer"&gt;ogp.me&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MooX::Role::SEOTags&lt;/code&gt; adds a handful of attributes and helper methods so any Moo (or Moose) class can declare the bits of information that power social previews and search snippets, then render them as HTML.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open Graph tags (&lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Twitter Card tags (&lt;code&gt;twitter:card&lt;/code&gt;, &lt;code&gt;twitter:title&lt;/code&gt;, &lt;code&gt;twitter:description&lt;/code&gt;, &lt;code&gt;twitter:image&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Standard SEO: &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, meta &lt;code&gt;description&lt;/code&gt;, canonical &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A single method to render the whole block with one call&lt;/li&gt;
&lt;li&gt;But also individual methods to give you more control over tag placement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the whole job: define attributes → get valid tags out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;

&lt;p&gt;Install the role using your favourite CPAN module installation tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cpanm MooX::Role::SEOTags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in your code, you will need to add some attributes or methods that define the pieces of information the role needs. The role requires four pieces of information – &lt;code&gt;og_title&lt;/code&gt;, &lt;code&gt;og_description&lt;/code&gt;, &lt;code&gt;og_url&lt;/code&gt; and &lt;code&gt;og_type&lt;/code&gt; – and &lt;code&gt;og_image&lt;/code&gt; is optional (but highly recommended).&lt;/p&gt;

&lt;p&gt;So a simple class might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package MyPage;
use Moo;
with 'MooX::Role::SEOTags';

# minimal OG fields
has og_title =&amp;gt; (is =&amp;gt; 'ro', required =&amp;gt; 1);
has og_type =&amp;gt; (is =&amp;gt; 'ro', required =&amp;gt; 1); # e.g. 'article'
has og_url =&amp;gt; (is =&amp;gt; 'ro', required =&amp;gt; 1);
has og_description =&amp;gt; (is =&amp;gt; 'ro');

# optional niceties
has og_image =&amp;gt; (is =&amp;gt; 'ro'); # absolute URL
has twitter_card =&amp;gt; (is =&amp;gt; 'ro', default =&amp;gt; sub { 'summary_large_image' });

1;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then you create the object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my $page = MyPage-&amp;gt;new(
  og_title =&amp;gt; 'How to Title a Title',
  og_type =&amp;gt; 'article',
  og_url =&amp;gt; 'https://example.com/post/title',
  og_image =&amp;gt; 'https://example.com/img/hero.jpg',
  og_description =&amp;gt; 'A short, human description of the page.',
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you can call the various &lt;code&gt;*_tag&lt;/code&gt; and &lt;code&gt;*_tags&lt;/code&gt; methods to get the correct HTML for the various tags.&lt;/p&gt;

&lt;p&gt;The easiest option is to just produce all of the tags in one go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;say $page-&amp;gt;tags;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But, for more control, you can call individual methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;say $page-&amp;gt;title_tag;
say $page-&amp;gt;canonical_tag;
say $page-&amp;gt;og_tags;
# etc...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on which combination of method calls you use, the output will look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;title&amp;gt;How to Title a Title&amp;lt;/title&amp;gt;
&amp;lt;meta name="description" content="A short, human description of the page."&amp;gt;
&amp;lt;link rel="canonical" href="https://example.com/post/title"&amp;gt;

&amp;lt;meta property="og:title" content="How to Title a Title"&amp;gt;
&amp;lt;meta property="og:type" content="article"&amp;gt;
&amp;lt;meta property="og:url" content="https://example.com/post/title"&amp;gt;
&amp;lt;meta property="og:image" content="https://example.com/img/hero.jpg"&amp;gt;

&amp;lt;meta name="twitter:card" content="summary_large_image"&amp;gt;
&amp;lt;meta name="twitter:title" content="How to Title a Title"&amp;gt;
&amp;lt;meta name="twitter:description" content="A short, human description of the page."&amp;gt;
&amp;lt;meta name="twitter:image" content="https://example.com/img/hero.jpg"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In many cases, you’ll be pulling the data from a database and displaying the output using a templating system like the Template Toolkit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my $tt = Template-&amp;gt;new;

my $object = $resultset-&amp;gt;find({ slug =&amp;gt; $some_slug });

$tt-&amp;gt;process('page.tt', { object =&amp;gt; $object }, "$some_slug/index.html");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case, you’d just add a single call to the &lt;/p&gt; of your page template.&lt;br&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;head&amp;gt;

  &amp;lt;!-- lots of other HTML --&amp;gt;

[% object.tags %]

&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;h2&gt;
  
  
  A note if you used my earlier Open Graph role
&lt;/h2&gt;

&lt;p&gt;If you spotted &lt;code&gt;MooX::Role::OpenGraph&lt;/code&gt; arrive on MetaCPAN recently: &lt;code&gt;SEOTags&lt;/code&gt; is the “grown-up” superset. It does Open Graph &lt;em&gt;and&lt;/em&gt; Twitter &lt;em&gt;and&lt;/em&gt; standard tags, so you only need one role. The old module is scheduled for deletion from MetaCPAN.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO tags and JSON-LD
&lt;/h2&gt;

&lt;p&gt;These tags are only one item in the SEO toolkit that you’d use to increase the visibility of your website. Another useful tool is &lt;a href="https://json-ld.org/" rel="noopener noreferrer"&gt;JSON-LD&lt;/a&gt; – which allows you to add a machine-readable description of the information that your page contains. Google loves JSON-LD. And it just happens that I have another Moo role called &lt;a href="https://metacpan.org/pod/MooX::Role::JSON_LD" rel="noopener noreferrer"&gt;MooX::Role::JSON_LD&lt;/a&gt; which makes it easy to add that to your page too. I wrote &lt;a href="https://dev.to/davorg/adding-structured-data-with-perl-6f0"&gt;a blog post about using that&lt;/a&gt; earlier this year.&lt;/p&gt;

&lt;h3&gt;
  
  
  In conclusion
&lt;/h3&gt;

&lt;p&gt;If you’ve got even one page that deserves to look smarter in search and social previews, now’s the moment. Pick a page, add a title, description, canonical URL and a decent image, and let &lt;code&gt;MooX::Role::SEOTags&lt;/code&gt; spit out the right tags every time (and, if you fancy richer results, pair it with &lt;code&gt;MooX::Role::JSON_LD&lt;/code&gt;). Share the link in Slack/WhatsApp/Twitter to preview it, fix anything that looks off, and ship. It’s a 20-minute tidy-up that can lift click-throughs for years—so go on, give one of your pages a quick SEO spruce-up today.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/" rel="noopener noreferrer"&gt;Easy SEO for lazy programmers&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>web</category>
      <category>opengraph</category>
      <category>perl</category>
      <category>seo</category>
    </item>
    <item>
      <title>Stop using your system Perl</title>
      <dc:creator>Dave Cross</dc:creator>
      <pubDate>Fri, 27 Jun 2025 12:58:04 +0000</pubDate>
      <link>https://forem.com/davorg/stop-using-your-system-perl-3ap5</link>
      <guid>https://forem.com/davorg/stop-using-your-system-perl-3ap5</guid>
      <description>&lt;p&gt;Recently, Gabor ran a poll in a Perl Facebook community asking which version of Perl people used in their production systems. The results were eye-opening—and not in a good way. A surprisingly large number of developers replied with something along the lines of &lt;em&gt;“whatever version is included with my OS.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If that’s you, this post is for you. I don’t say that to shame or scold—many of us started out this way. But if you’re serious about writing and running Perl in 2025, it’s time to stop relying on the system Perl.&lt;/p&gt;

&lt;p&gt;Let’s unpack why.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is “System Perl”?
&lt;/h2&gt;

&lt;p&gt;When we talk about the &lt;em&gt;system Perl&lt;/em&gt;, we mean the version of Perl that comes pre-installed with your operating system—be it a Linux distro like Debian or CentOS, or even macOS. This is the version used by the OS itself for various internal tasks and scripts. It’s typically located in &lt;code&gt;/usr/bin/perl&lt;/code&gt; and tied closely to system packages.&lt;/p&gt;

&lt;p&gt;It’s tempting to just use what’s already there. But that decision brings a lot of hidden baggage—and some very real risks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which versions of Perl are officially supported?
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://perldoc.perl.org/perlpolicy#MAINTENANCE-AND-SUPPORT" rel="noopener noreferrer"&gt;Perl Core Support Policy&lt;/a&gt; states that only the &lt;strong&gt;two most recent stable release series&lt;/strong&gt; of Perl are supported by the Perl development team [&lt;strong&gt;Update:&lt;/strong&gt; fixed text in previous sentence]. As of mid-2025, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Perl 5.40 (released May 2024)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Perl 5.38 (released July 2023)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re using anything older—like 5.36, 5.32, or 5.16—you’re outside the officially supported window. That means no guaranteed bug fixes, security patches, or compatibility updates from core CPAN tools like &lt;code&gt;ExtUtils::MakeMaker&lt;/code&gt;, &lt;code&gt;Module::Build&lt;/code&gt;, or &lt;code&gt;Test::More&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Using an old system Perl often means you’re &lt;strong&gt;several versions behind&lt;/strong&gt; , and no one upstream is responsible for keeping that working anymore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why using System Perl is a problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. It’s often outdated
&lt;/h3&gt;

&lt;p&gt;System Perl is frozen in time—usually the version that was current when the OS release cycle began. Depending on your distro, that could mean Perl 5.10, 5.16, or 5.26—versions that are &lt;strong&gt;years&lt;/strong&gt; behind the latest stable Perl (currently 5.40).&lt;/p&gt;

&lt;p&gt;This means you’re missing out on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;New language features (&lt;code&gt;builtin&lt;/code&gt;, &lt;code&gt;class/method/field&lt;/code&gt;, &lt;code&gt;signatures&lt;/code&gt;, &lt;code&gt;try/catch&lt;/code&gt;)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Performance improvements&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Bug fixes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Critical security patches&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Support: anything older than Perl 5.38 is no longer officially maintained by the core Perl team&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’ve ever looked at modern Perl documentation and found your code mysteriously breaking, chances are your system Perl is too old.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. It’s not yours to mess with
&lt;/h3&gt;

&lt;p&gt;System Perl isn’t just a convenience—it’s a dependency. Your operating system relies on it for package management, system maintenance tasks, and assorted glue scripts. If you install or upgrade CPAN modules into the system Perl (especially with &lt;code&gt;cpan&lt;/code&gt; or &lt;code&gt;cpanm&lt;/code&gt; as root), you run the risk of breaking something your OS depends on.&lt;/p&gt;

&lt;p&gt;It’s a kind of dependency hell that’s completely avoidable— &lt;strong&gt;if you stop using system Perl&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. It’s a barrier to portability and reproducibility
&lt;/h3&gt;

&lt;p&gt;When you use system Perl, your environment is essentially defined by your distro. That’s fine until you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Move your application to another system&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run CI tests on a different platform&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upgrade your OS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Onboard a new developer&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You lose the ability to create predictable, portable environments. That’s not a luxury— &lt;strong&gt;it’s a requirement for sane development&lt;/strong&gt; in modern software teams.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you should be doing instead
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Use &lt;code&gt;perlbrew&lt;/code&gt; or &lt;code&gt;plenv&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;These tools let you install multiple versions of Perl in your home directory and switch between them easily. Want to test your code on Perl 5.32 and 5.40? &lt;code&gt;perlbrew&lt;/code&gt; makes it a breeze.&lt;/p&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A clean separation from system Perl&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The freedom to upgrade or downgrade at will&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zero risk of breaking your OS&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It takes minutes to set up and pays for itself tenfold in flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use &lt;code&gt;local::lib&lt;/code&gt; or &lt;code&gt;Carton&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Managing CPAN dependencies globally is a recipe for pain. Instead, use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;local::lib&lt;/code&gt;: keeps modules in your home directory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Carton&lt;/code&gt;: locks your CPAN dependencies (like &lt;code&gt;npm&lt;/code&gt; or &lt;code&gt;pip&lt;/code&gt;) so deployments are repeatable.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your production system should run with &lt;em&gt;exactly&lt;/em&gt; the same modules and versions as your dev environment. Carton helps you achieve that.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Consider Docker
&lt;/h3&gt;

&lt;p&gt;If you’re building larger apps or APIs, containerising your Perl environment ensures true consistency across dev, test, and production. You can even start &lt;em&gt;from&lt;/em&gt; a system Perl inside the container—as long as it’s isolated and under your control.&lt;/p&gt;

&lt;p&gt;You never want to be the person debugging a bug that &lt;em&gt;only happens on production&lt;/em&gt;, because prod is using the distro’s ancient Perl and no one can remember which CPAN modules got installed by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  The benefits of managing your own Perl
&lt;/h2&gt;

&lt;p&gt;Once you step away from the system Perl, you gain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Access to the full language.&lt;/strong&gt; Use the latest features without backports or compatibility hacks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Freedom from fear.&lt;/strong&gt; Install CPAN modules freely without the risk of breaking your OS.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portability.&lt;/strong&gt; Move projects between machines or teams with minimal friction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Better testing.&lt;/strong&gt; Easily test your code across multiple Perl versions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; Stay up to date with patches and fixes on &lt;em&gt;your&lt;/em&gt; schedule, not the distro’s.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Modern practices.&lt;/strong&gt; Align your Perl workflow with the kinds of practices standard in other languages (think &lt;code&gt;virtualenv&lt;/code&gt;, &lt;code&gt;rbenv&lt;/code&gt;, &lt;code&gt;nvm&lt;/code&gt;, etc.).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  “But it just works…”
&lt;/h2&gt;

&lt;p&gt;I know the argument. You’ve got a handful of scripts, or maybe a cron job or two, and they seem fine. Why bother with all this?&lt;/p&gt;

&lt;p&gt;Because “it just works” only holds true until:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You upgrade your OS and Perl changes under you.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A script stops working and you don’t know why.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want to install a module and suddenly &lt;code&gt;apt&lt;/code&gt; is yelling at you about conflicts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You realise the module you need requires Perl 5.34, but your system has 5.16.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don’t wait for it to break. Get ahead of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The first step
&lt;/h2&gt;

&lt;p&gt;You don’t have to refactor your entire setup overnight. But you &lt;em&gt;can&lt;/em&gt; do this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Install &lt;code&gt;perlbrew&lt;/code&gt; and try it out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Start a new project with &lt;code&gt;Carton&lt;/code&gt; to lock dependencies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choose a current version of Perl and commit to using it moving forward.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you’ve seen how smooth things can be with a clean, controlled Perl environment, you won’t want to go back.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Your system Perl is for your operating system—not for your apps. Treat it as off-limits. Modern Perl deserves modern tools, and so do you.&lt;/p&gt;

&lt;p&gt;Take the first step. Your future self (and probably your ops team) will thank you.&lt;/p&gt;




&lt;p&gt;The post &lt;a href="https://perlhacks.com/2025/06/stop-using-your-system-perl/" rel="noopener noreferrer"&gt;Stop using your system Perl&lt;/a&gt; first appeared on &lt;a href="https://perlhacks.com" rel="noopener noreferrer"&gt;Perl Hacks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>perl</category>
      <category>carton</category>
      <category>docker</category>
      <category>perlbrew</category>
    </item>
  </channel>
</rss>
