<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Perl Hacks</title>
	<atom:link href="https://perlhacks.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://perlhacks.com</link>
	<description>Just another Perl Hacker&#039;s blog</description>
	<lastBuildDate>Tue, 31 Mar 2026 09:53:48 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>
<site xmlns="com-wordpress:feed-additions:1">40678030</site>	<item>
		<title>Writing a TOON Module for Perl</title>
		<link>https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/</link>
					<comments>https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 29 Mar 2026 17:46:14 +0000</pubDate>
				<category><![CDATA[CPAN]]></category>
		<category><![CDATA[cpan]]></category>
		<category><![CDATA[serialisation]]></category>
		<category><![CDATA[toon]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2417</guid>

					<description><![CDATA[<p>Every so often, a new data serialisation format appears and people get excited about it. Recently, one of those formats is **TOON** — 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 [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/">Writing a TOON Module for Perl</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>Every so often, a new data serialisation format appears and people get excited about it. Recently, one of those formats is **TOON** — 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.</p>
<p>So the obvious Perl question is: *“Ok, where’s the CPAN module?”*</p>
<p>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.</p>
<p>I should point out  that I knew about [Data::Toon](https://metacpan.org/pod/Data::TOON) but I wanted something with an interface that was more like JSON.pm.</p>
<p>&#8212;</p>
<p>## What TOON Is</p>
<p>TOON stands for **Token-Oriented Object Notation**. It’s a textual format for representing structured data — the same data model as JSON:</p>
<p>* Objects (hashes)<br />
* Arrays<br />
* Strings<br />
* Numbers<br />
* Booleans<br />
* Null</p>
<p>The idea behind TOON is that it is designed to be **easy for both humans and language models to read and write**. It tries to reduce punctuation noise and make the structure of data clearer.</p>
<p>If you think of the landscape like this:</p>
<p>| Format | Human-friendly | Machine-friendly | Very common |<br />
| &#8212;&#8212; | &#8212;&#8212;&#8212;&#8212;&#8211; | &#8212;&#8212;&#8212;&#8212;&#8212;- | &#8212;&#8212;&#8212;&#8211; |<br />
| JSON   | Medium         | Very             | Yes         |<br />
| YAML   | High           | Medium           | Yes         |<br />
| TOON   | High           | High             | Not yet     |</p>
<p>TOON is trying to sit in the middle: simpler than YAML, more readable than JSON.</p>
<p>Whether it succeeds at that is a matter of taste — but it’s an interesting idea.</p>
<p>&#8212;</p>
<p>## TOON vs JSON vs YAML</p>
<p>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.</p>
<p>### JSON</p>
<p>    {<br />
      &#8220;name&#8221;: &#8220;Arthur Dent&#8221;,<br />
      &#8220;age&#8221;: 42,<br />
      &#8220;email&#8221;: &#8220;arthur@example.com&#8221;,<br />
      &#8220;alive&#8221;: true,<br />
      &#8220;address&#8221;: {<br />
        &#8220;street&#8221;: &#8220;High Street&#8221;,<br />
        &#8220;city&#8221;: &#8220;Guildford&#8221;<br />
      },<br />
      &#8220;phones&#8221;: [<br />
        &#8220;01234 567890&#8221;,<br />
        &#8220;07700 900123&#8221;<br />
      ]<br />
    }</p>
<p>### YAML</p>
<p>    name: Arthur Dent<br />
    age: 42<br />
    email: arthur@example.com<br />
    alive: true<br />
    address:<br />
      street: High Street<br />
      city: Guildford<br />
    phones:<br />
      &#8211; 01234 567890<br />
      &#8211; 07700 900123</p>
<p>### TOON</p>
<p>    name: Arthur Dent<br />
    age: 42<br />
    email: arthur@example.com<br />
    alive: true<br />
    address:<br />
      street: High Street<br />
      city: Guildford<br />
    phones[2]: 01234 567890,07700 900123</p>
<p>You can see that TOON sits somewhere between JSON and YAML:</p>
<p>* Less punctuation and quoting than JSON<br />
* More explicit structure than YAML<br />
* Still very easy to parse<br />
* Still clearly structured for machines</p>
<p>That’s the idea, anyway.</p>
<p>&#8212;</p>
<p>## Why People Think TOON Is Useful</p>
<p>The current interest in TOON is largely driven by AI/LLM workflows.</p>
<p>People are using it because:</p>
<p>1. It is easier for humans to read than JSON.<br />
2. It is less ambiguous and complex than YAML.<br />
3. It maps cleanly to the JSON data model.<br />
4. It is relatively easy to parse.<br />
5. It works well in prompts and generated output.</p>
<p>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.</p>
<p>You may or may not buy that argument — but it’s an interesting niche.</p>
<p>&#8212;</p>
<p>## Why I Wrote a Perl Module</p>
<p>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.</p>
<p>But what I *do* have a strong opinion about is this:</p>
<p>> If a data format exists, then Perl should have a CPAN module for it that works the way Perl programmers expect.</p>
<p>Perl already has very good, very consistent interfaces for data serialisation:</p>
<p>* JSON<br />
* YAML<br />
* Storable<br />
* Sereal</p>
<p>They all tend to follow the same pattern, particularly the object-oriented interface:</p>
<p>    use JSON;<br />
    my $json = JSON->new->pretty->canonical;<br />
    my $text = $json->encode($data);<br />
    my $data = $json->decode($text);</p>
<p>So I wanted a TOON module that worked the same way.</p>
<p>&#8212;</p>
<p>## Design Goals</p>
<p>When designing the module, I had a few simple goals.</p>
<p>### 1. Familiar OO Interface</p>
<p>The primary interface should be object-oriented and feel like <code>JSON.pm</code>:</p>
<p>    use TOON;<br />
    my $toon = TOON->new<br />
                   ->pretty<br />
                   ->canonical<br />
                   ->indent(2);<br />
    my $text = $toon->encode($data);<br />
    my $data = $toon->decode($text);</p>
<p>If you already know JSON, you already know how to use TOON.</p>
<p>There are also convenience functions, but the OO interface is the main one.</p>
<p>### 2. Pure Perl Implementation</p>
<p>Version 0.001 is pure Perl. That means:</p>
<p>* Easy to install<br />
* No compiler required<br />
* Works everywhere Perl works</p>
<p>If TOON becomes popular and performance matters, someone can always write an XS backend later.</p>
<p>### 3. Clean Separation of Components</p>
<p>Internally, the module is split into:</p>
<p>* **Tokenizer** – turns text into tokens<br />
* **Parser** – turns tokens into Perl data structures<br />
* **Emitter** – turns Perl data structures into TOON text<br />
* **Error handling** – reports line/column errors cleanly</p>
<p>This makes it easier to test and maintain.</p>
<p>### 4. Do the Simple Things Well First</p>
<p>Version 0.001 supports:</p>
<p>* Scalars<br />
* Arrayrefs<br />
* Hashrefs<br />
* <code>undef</code> → null<br />
* Pretty printing<br />
* Canonical key ordering</p>
<p>It does **not** (yet) try to serialise blessed objects or do anything clever. That can come later if people actually want it.</p>
<p>&#8212;</p>
<p>## Example Usage (OO Style)</p>
<p>Here’s a simple Perl data structure:</p>
<p>    my $data = {<br />
      name   => &#8220;Arthur Dent&#8221;,<br />
      age    => 42,<br />
      drinks => [ &#8220;tea&#8221;, &#8220;coffee&#8221; ],<br />
      alive  => 1,<br />
    };</p>
<p>### Encoding</p>
<p>    use TOON;<br />
    my $toon = TOON->new->pretty->canonical;<br />
    my $text = $toon->encode($data);<br />
    print $text;</p>
<p>### Decoding</p>
<p>    use TOON;<br />
    my $toon = TOON->new;<br />
    my $data = $toon->decode($text);<br />
    print $data->{name};</p>
<p>### Convenience Functions</p>
<p>    use TOON qw(encode_toon decode_toon);<br />
    my $text = encode_toon($data);<br />
    my $data = decode_toon($text);</p>
<p>But the OO interface is where most of the flexibility lives.</p>
<p>&#8212;</p>
<p>## Command Line Tool</p>
<p>There’s also a command-line tool, <code>toon_pp</code>, similar to <code>json_pp</code>:</p>
<p>    cat data.toon | toon_pp</p>
<p>Which will pretty-print TOON data.</p>
<p>&#8212;</p>
<p>## Final Thoughts</p>
<p>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.</p>
<p>And fundamentally, this is how CPAN has always worked:</p>
<p>> See a problem. Write a module. Upload it. See if anyone else finds it useful.</p>
<p>So now Perl has a TOON module. And if you already know how to use <code>JSON.pm</code>, you already know how to use it.</p>
<p>That was the goal.</p><p>The post <a href="https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/">Writing a TOON Module for Perl</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2026/03/writing-a-toon-module-for-perl/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2417</post-id>	</item>
		<item>
		<title>Still on the [b]leading edge</title>
		<link>https://perlhacks.com/2026/03/still-on-the-bleading-edge/</link>
					<comments>https://perlhacks.com/2026/03/still-on-the-bleading-edge/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sat, 21 Mar 2026 11:14:06 +0000</pubDate>
				<category><![CDATA[Programming]]></category>
		<category><![CDATA[cpan]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[module metadata]]></category>
		<category><![CDATA[perl]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2413</guid>

					<description><![CDATA[<p>About eighteen months ago, I wrote a post called On the Bleading Edge about my decision to start using Perl’s new class 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. I still think that’s [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2026/03/still-on-the-bleading-edge/">Still on the [b]leading edge</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="349" data-end="680">About eighteen months ago, I wrote a post called <a class="decorated-link" href="https://perlhacks.com/2024/08/on-the-bleading-edge/" target="_new" rel="noopener" data-start="398" data-end="475"><em data-start="399" data-end="421">On the Bleading Edge</em></a> about my decision to start using Perl’s new <code data-start="520" data-end="527">class</code> 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.</p>
<p data-start="682" data-end="708">I still think that’s true.</p>
<p data-start="710" data-end="785">But every now and then, the bleading edge reminds you why it’s called that.</p>
<p data-start="787" data-end="1016">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.</p>
<p data-start="1018" data-end="1036">This is the story.</p>
<h2 data-section-id="vf7x19" data-start="1038" data-end="1052">The Symptom</h2>
<p data-start="1054" data-end="1265">I was building a Docker image for <a href="https://aphra.perlhacks.com/">Aphra</a>. As part of the build, I needed to install <a href="https://metacpan.org/pod/App::HTTPThis">App::HTTPThis</a>, which depends on <a href="https://metacpan.org/pod/Plack::App::DirectoryIndex">Plack::App::DirectoryIndex</a>, which depends on <a href="https://metacpan.org/pod/WebServer::DirIndex">WebServer::DirIndex</a>.</p>
<p data-start="1267" data-end="1307">The Docker build failed with this error:</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">#13 45.66 --&gt; Working on WebServer::DirIndex
#13 45.66 Fetching https://www.cpan.org/authors/id/D/DA/DAVECROSS/WebServer-DirIndex-0.1.3.tar.gz ... OK
#13 45.83 Configuring WebServer-DirIndex-v0.1.3 ... OK
#13 46.21 Building WebServer-DirIndex-v0.1.3 ... OK
#13 46.75 Successfully installed WebServer-DirIndex-v0.1.3
#13 46.84 ! Installing the dependencies failed: Installed version (undef) of WebServer::DirIndex is not in range 'v0.1.0'
#13 46.84 ! Bailing out the installation for Plack-App-DirectoryIndex-v0.2.1.</pre><p></p>
<p data-start="1834" data-end="1879">Now, that’s a deeply confusing error message.</p>
<p data-start="1881" data-end="2046">It clearly says that WebServer::DirIndex was successfully installed. And then immediately says that the installed version is <code data-start="2008" data-end="2015">undef</code> and not in the required range.</p>
<p data-start="2048" data-end="2195">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.</p>
<p data-start="2197" data-end="2316">But the version number in WebServer::DirIndex was fine. The module built. The tests passed. Everything looked normal.</p>
<p data-start="2318" data-end="2373">So why did the installer think the version was <code data-start="2365" data-end="2372">undef</code>?</p>
<h2 data-section-id="1qj34oz" data-start="2375" data-end="2399">When This Bug Appears</h2>
<p data-start="2401" data-end="2451">This only shows up in a fairly specific situation:</p>
<ul data-start="2453" data-end="2825">
<li data-section-id="14t2040" data-start="2453" data-end="2495">A module uses modern Perl <code data-start="2481" data-end="2488">class</code> syntax</li>
<li data-section-id="1t2er2a" data-start="2496" data-end="2529">The module defines a <code data-start="2519" data-end="2529">$VERSION</code></li>
<li data-section-id="14f6awz" data-start="2530" data-end="2606">Another module declares a prerequisite with a specific version requirement</li>
<li data-section-id="1noiju1" data-start="2607" data-end="2686">The installer tries to check the installed version without loading the module</li>
<li data-section-id="1713qy" data-start="2687" data-end="2737">It uses <a href="https://metacpan.org/pod/Module::Metadata">Module::Metadata</a> to extract <code data-start="2727" data-end="2737">$VERSION</code></li>
<li data-section-id="1jsln84" data-start="2738" data-end="2825">And the version of Module::Metadata it is using doesn’t properly understand <code data-start="2818" data-end="2825">class</code></li>
</ul>
<p data-start="2827" data-end="3100">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.</p>
<p data-start="3102" data-end="3144">So this bug only appears when you combine:</p>
<blockquote data-start="3146" data-end="3201">
<p data-start="3148" data-end="3201">modern Perl syntax + version checks + older toolchain</p>
</blockquote>
<p data-start="3203" data-end="3258">Which is pretty much the definition of “bleading edge”.</p>
<h2 data-section-id="1fxbvyb" data-start="3260" data-end="3279">The Real Culprit</h2>
<p data-start="3281" data-end="3386">The problem turned out to be an older version of Module::Metadata that had been fatpacked into <code data-start="3378" data-end="3385">cpanm</code>.</p>
<p data-start="3388" data-end="3637"><code data-start="3388" data-end="3395">cpanm</code> uses <code data-start="3401" data-end="3419">Module::Metadata</code> to inspect modules and extract <code data-start="3451" data-end="3461">$VERSION</code> without loading the module. But the older <code data-start="3504" data-end="3522">Module::Metadata</code> didn’t correctly understand the <code data-start="3555" data-end="3562">class</code> keyword, so it couldn’t work out which package the <code data-start="3614" data-end="3624">$VERSION</code> belonged to.</p>
<p data-start="3639" data-end="3699">So when it checked the installed version, it found… nothing.</p>
<p data-start="3701" data-end="3707">Hence:</p>
<blockquote data-start="3709" data-end="3784">
<p data-start="3711" data-end="3784">Installed version (undef) of WebServer::DirIndex is not in range &#8216;v0.1.0&#8217;</p>
</blockquote>
<p data-start="3786" data-end="3847">The version wasn’t wrong. The installer just couldn’t see it.</p>
<p data-start="3786" data-end="3847">An aside, you may find it amusing to hear an anecdote from my attempts to debug this problem.</p>
<p data-start="3786" data-end="3847">I spun up a new Ubuntu Docker container, installed <code>cpanm</code> and tried to install Plack::App::DirectoryIndex. Initially, this gave the same error message. At least the problem was easily reproducible.</p>
<p data-start="3786" data-end="3847">I then ran code that was very similar to the code <code>cpanm</code> uses to work out what a module&#8217;s version is.</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ perl -MModule::Metadata -E'say Module::Metadata-&gt;new_from_module("WebServer::DirIndex")-&gt;version'</pre><p>This displayed an empty string. I was really onto something here. Module::Metadata couldn&#8217;t find the version.</p>
<p>I was using Module::Metadata version 1.000037 and, looking at the change log on CPAN, I saw this:</p>
<blockquote>
<div class="line number3 index2 alt2"><code class="cpanchanges constants">1.000038  2023-04-28 11:25:40Z</code></div>
<div class="line number4 index3 alt1"><code class="cpanchanges functions">-</code> <code class="cpanchanges plain">detects "class" syntax</code></div>
</blockquote>
<div>I installed 1.000038 and reran my command.</div>
<div></div>
<div>
<pre class="urvanov-syntax-highlighter-plain-tag">$ perl -MModule::Metadata -E'say Module::Metadata-&gt;new_from_module("WebServer::DirIndex")-&gt;version'
0.1.3</pre><br />
That seemed conclusive. Excitedly, I reran the Docker build.</p>
<p>It failed again.</p>
<p>You&#8217;ve probably worked out why. But it took me a frustrating half an hour to work it out.</p>
<p><code>cpanm</code> doesn&#8217;t use the installed version of Module::Metadata. It uses its own, fatpacked version. Updating Module::Metadata wouldn&#8217;t fix my problem.</p>
</div>
<h2 data-section-id="1is82ri" data-start="3849" data-end="3866">The Workaround</h2>
<p data-start="3868" data-end="4067">I found a workaround. That was to add a redundant <code data-start="3918" data-end="3927">package</code> declaration alongside the <code data-start="3954" data-end="3961">class</code> declaration, so older versions of Module::Metadata can still identify the package that owns <code data-start="4056" data-end="4066">$VERSION</code>.</p>
<p data-start="4069" data-end="4093">So instead of just this:</p>
<div class="relative w-full mt-4 mb-1">
<div class="">
<div class="relative">
<div class="h-full min-h-0 min-w-0">
<div class="h-full min-h-0 min-w-0">
<div class="border border-token-border-light border-radius-3xl corner-superellipse/1.1 rounded-3xl">
<div class="h-full w-full border-radius-3xl bg-token-bg-elevated-secondary corner-superellipse/1.1 overflow-clip rounded-3xl lxnfua_clipPathFallback">
<div class="pointer-events-none absolute inset-x-4 top-12 bottom-4">
<div class="pointer-events-none sticky z-40 shrink-0 z-1!">
<div class="sticky bg-token-border-light">
<pre class="urvanov-syntax-highlighter-plain-tag">class WebServer::DirIndex {
  our $VERSION = '0.1.3';
  ...
}</pre><br />
I now have this:</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="relative w-full mt-4 mb-1">
<div class="">
<div class="relative">
<div class="h-full min-h-0 min-w-0">
<div class="h-full min-h-0 min-w-0">
<div class="border border-token-border-light border-radius-3xl corner-superellipse/1.1 rounded-3xl">
<div class="h-full w-full border-radius-3xl bg-token-bg-elevated-secondary corner-superellipse/1.1 overflow-clip rounded-3xl lxnfua_clipPathFallback">
<div class="pointer-events-none absolute inset-x-4 top-12 bottom-4">
<div class="pointer-events-none sticky z-40 shrink-0 z-1!">
<div class="sticky bg-token-border-light">
<pre class="urvanov-syntax-highlighter-plain-tag">package WebServer::DirIndex;

class WebServer::DirIndex {
  our $VERSION = '0.1.3';
  ...
}</pre><br />
It looks unnecessary. And in a perfect world, it would be unnecessary.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p data-start="4373" data-end="4474">But it allows older tooling to work out the version correctly, and everything installs cleanly again.</p>
<h2 data-section-id="1qco1hd" data-start="4476" data-end="4493">The Proper Fix</h2>
<p data-start="4495" data-end="4547">Of course, the real fix was to update the toolchain.</p>
<p data-start="4549" data-end="4706">So I <a href="https://github.com/miyagawa/cpanminus/issues/697">raised an issue against App::cpanminus</a>, pointing out that the fatpacked Module::Metadata was too old to cope properly with modules that use <code data-start="4698" data-end="4705">class</code>.</p>
<p data-start="4708" data-end="4815">Tatsuhiko Miyagawa responded very quickly, and a new release of <code data-start="4772" data-end="4779">cpanm</code> appeared with an updated version of Module::Metadata.</p>
<p data-start="4817" data-end="4954">This is one of the nice things about the Perl ecosystem. Sometimes you report a problem and the right person fixes it almost immediately.</p>
<h2 data-section-id="16mnfbl" data-start="4956" data-end="4991">When Do I Remove the Workaround?</h2>
<p data-start="4993" data-end="5037">This leaves me with an interesting question.</p>
<p data-start="5039" data-end="5081">The correct fix is “use a recent <code data-start="5072" data-end="5079">cpanm</code>”.</p>
<p data-start="5083" data-end="5176">But the workaround is “add a redundant <code data-start="5122" data-end="5131">package</code> line so older tooling doesn’t get confused”.</p>
<p data-start="5178" data-end="5213">So when do I remove the workaround?</p>
<p data-start="5215" data-end="5247">The answer is probably: not yet.</p>
<p data-start="5249" data-end="5483">Because although a fixed <code data-start="5274" data-end="5281">cpanm</code> 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 <code data-start="5459" data-end="5466">cpanm</code> lurking in them.</p>
<p data-start="5485" data-end="5563">And the workaround is harmless. It just offends my sense of neatness slightly.</p>
<p data-start="5565" data-end="5720">So for now, the redundant <code data-start="5591" data-end="5600">package</code> line stays. Not because modern Perl needs it, but because parts of the world around modern Perl are still catching up.</p>
<h2 data-section-id="1ca3cnq" data-start="5722" data-end="5750">Life on the Bleading Edge</h2>
<p data-start="5752" data-end="5811">This is what life on the bleading edge actually looks like.</p>
<p data-start="5813" data-end="5880">Not dramatic crashes. Not language bugs. Not catastrophic failures.</p>
<p data-start="5882" data-end="6041">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.</p>
<p data-start="6043" data-end="6115">And then you lose two days proving that you are not, in fact, going mad.</p>
<p data-start="6117" data-end="6171">But I’m still using <code data-start="6137" data-end="6144">class</code>. And I’m still happy I am.</p>
<p data-start="6173" data-end="6324">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.</p><p>The post <a href="https://perlhacks.com/2026/03/still-on-the-bleading-edge/">Still on the [b]leading edge</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2026/03/still-on-the-bleading-edge/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2413</post-id>	</item>
		<item>
		<title>Treating GitHub Copilot as a Contributor</title>
		<link>https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/</link>
					<comments>https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 22 Feb 2026 11:51:15 +0000</pubDate>
				<category><![CDATA[Miscellaneous]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2404</guid>

					<description><![CDATA[<p>For some time, we’ve talked about GitHub Copilot as if it were a clever autocomplete engine. It isn’t. Or rather, that&#8217;s not all it is. The interesting thing — the thing that genuinely changes how you work — is that you can assign GitHub issues to Copilot. And it behaves like a contributor. Over the [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/">Treating GitHub Copilot as a Contributor</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="394" data-end="481">For some time, we’ve talked about GitHub Copilot as if it were a clever autocomplete engine.</p>
<p data-start="483" data-end="492">It isn’t.</p>
<p data-start="494" data-end="549">Or rather, that&#8217;s not all it is.</p>
<p data-start="551" data-end="671">The interesting thing — the thing that genuinely changes how you work — is that you can assign GitHub issues to Copilot.</p>
<p data-start="673" data-end="707">And it behaves like a contributor.</p>
<p data-start="709" data-end="1031">Over the past day, I’ve been doing exactly that on my new CPAN module, <a href="https://metacpan.org/pod/WebServer::DirIndex">WebServer::DirIndex</a>. 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.</p>
<p data-start="1033" data-end="1069">That still feels faintly futuristic. But it’s not “vibe coding”. It’s surprisingly structured.</p>
<p data-start="1131" data-end="1159">Let me explain how it works.</p>
<hr />
<h2 data-start="1166" data-end="1198">It Starts With a Proper Issue</h2>
<p data-start="1200" data-end="1236">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:</p>
<ul>
<li data-start="1200" data-end="1236">Add CPAN scaffolding</li>
<li data-start="1200" data-end="1236">Update the classes to use Feature::Compat::Class</li>
<li data-start="1200" data-end="1236">Replace DirHandle</li>
<li data-start="1200" data-end="1236">Add WebServer::DirIndex::File</li>
<li data-start="1200" data-end="1236">Move <code data-start="1625" data-end="1635">render()</code> method</li>
<li data-start="1200" data-end="1236">Use <code data-start="1651" data-end="1660">:reader</code> attribute where useful</li>
<li data-start="1200" data-end="1236">Remove dependency on Plack</li>
</ul>
<p data-start="1718" data-end="1764">Each one was a focused, bounded piece of work. Each one had clear expectations.</p>
<p data-start="1800" data-end="1886">The key is this: Copilot works best when you behave like a maintainer, not a magician.</p>
<p data-start="1888" data-end="2032">You describe the change precisely. You state constraints. You mention compatibility requirements. You indicate whether tests need to be updated.</p>
<p data-start="2034" data-end="2071">Then you assign the issue to Copilot.</p>
<p data-start="2073" data-end="2082">And wait.</p>
<hr />
<h2 data-start="2089" data-end="2116">The Pull Request Arrives</h2>
<p data-start="2118" data-end="2222">After a few minutes — sometimes ten, sometimes less — Copilot creates a branch and opens a pull request.</p>
<p data-start="2224" data-end="2240">The PR contains:</p>
<ul>
<li data-start="2224" data-end="2240">Code changes</li>
<li data-start="2224" data-end="2240">Updated or new tests</li>
<li data-start="2224" data-end="2240">A descriptive PR message</li>
</ul>
<p data-start="2314" data-end="2434">And because it’s a real PR, your CI runs automatically. The code is evaluated in the same way as any other contribution.</p>
<p data-start="2436" data-end="2558">This is already a major improvement over editor-based prompting. The work is isolated, reviewable, and properly versioned.</p>
<p data-start="2560" data-end="2628">But the most interesting part is what happens in the background.</p>
<hr />
<h2 data-start="2635" data-end="2660">Watching Copilot Think</h2>
<p data-start="2662" data-end="2761">If you visit the <strong data-start="2679" data-end="2689">Agents</strong> tab in the repository, you can see Copilot reasoning through the issue.</p>
<p data-start="2763" data-end="2821">It reads like a junior developer narrating their approach:</p>
<ul>
<li data-start="2763" data-end="2821">Interpreting the problem</li>
<li data-start="2763" data-end="2821">Identifying the relevant files</li>
<li data-start="2763" data-end="2821">Planning changes</li>
<li data-start="2763" data-end="2821">Considering test updates</li>
<li data-start="2763" data-end="2821">Running validation steps</li>
</ul>
<p data-start="2957" data-end="2982">And you can interrupt it.</p>
<p data-start="2984" data-end="3088">If it starts drifting toward unnecessary abstraction or broad refactoring, you can comment and steer it:</p>
<ul>
<li data-start="3092" data-end="3127">Please don’t change the public API.</li>
<li data-start="3092" data-end="3127">Avoid experimental Perl features.</li>
<li data-start="3092" data-end="3127">This must remain compatible with Perl 5.40.</li>
</ul>
<p data-start="3213" data-end="3244">It responds. It adjusts course.</p>
<p data-start="3246" data-end="3403">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.</p>
<hr />
<h2 data-start="3410" data-end="3448">Teaching Copilot About Your Project</h2>
<p data-start="3450" data-end="3562">Out of the box, Copilot doesn’t really know how your repository works. It sees code, but it doesn’t know policy.</p>
<p data-start="3564" data-end="3626">That’s where repository-level configuration becomes useful.</p>
<h3 data-start="3628" data-end="3665">1. Custom Repository Instructions</h3>
<p data-start="3667" data-end="3824">GitHub allows you to provide a <code data-start="3698" data-end="3731">.github/copilot-instructions.md</code> file that gives Copilot repository-specific guidance. The documentation for this lives here:</p>
<ul>
<li data-start="3826" data-end="3929">
<p id="title-h1" class="border-bottom-0"><a href="https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions">Adding repository custom instructions for GitHub Copilot</a></p>
</li>
</ul>
<p data-start="3931" data-end="3989">When GitHub offers to generate this file for you, say yes.</p>
<p data-start="3991" data-end="4018">Then customise it properly.</p>
<p data-start="4020" data-end="4056">In a CPAN module, I tend to include:</p>
<ul>
<li data-start="4020" data-end="4056">Minimum supported Perl version</li>
<li data-start="4020" data-end="4056">Whether Feature::Compat::Class is preferred</li>
<li data-start="4020" data-end="4056">Whether experimental features are forbidden</li>
<li data-start="4020" data-end="4056">CPAN layout expectations (<code data-start="4213" data-end="4219">lib/</code>, <code data-start="4221" data-end="4225">t/</code>, etc.)</li>
<li data-start="4020" data-end="4056">Test conventions (Test::More, no stray diagnostics)</li>
<li data-start="4020" data-end="4056">A strong preference for not breaking the public API</li>
</ul>
<p data-start="4342" data-end="4377">Without this file, Copilot guesses.</p>
<p data-start="4379" data-end="4439">With this file, Copilot aligns itself with your house style.</p>
<p data-start="4441" data-end="4469">That difference is impressive.</p>
<h3 data-start="4476" data-end="4530">2. Customising the Copilot Development Environment</h3>
<p data-start="4532" data-end="4647">There’s another piece that many people miss: Copilot can run a special workflow event called <code data-start="4625" data-end="4646">copilot_agent_setup</code>.</p>
<p data-start="4649" data-end="4750">You can define a workflow that prepares the environment Copilot works in. GitHub documents this here:</p>
<ul>
<li data-start="4649" data-end="4750">
<p id="title-h1" class="border-bottom-0"><a href="ttps://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment">Customizing the development environment for GitHub Copilot coding agent</a></p>
</li>
</ul>
<p data-start="4863" data-end="4910">In my Perl projects, I use this standard setup:</p>
<div class="w-full my-4">
<div class="">
<div class="relative">
<div class="h-full min-h-0 min-w-0">
<div class="h-full min-h-0 min-w-0">
<div class="border corner-superellipse/1.1 border-token-border-light bg-token-bg-elevated-secondary rounded-3xl">
<div class="pointer-events-none absolute inset-x-4 top-12 bottom-4">
<div class="pointer-events-none sticky z-40 shrink-0 z-1!">
<div class="sticky bg-token-border-light">
<pre class="urvanov-syntax-highlighter-plain-tag">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 .</pre><br />
(Obviously, that was originally written for me by Copilot!)</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="">
<div class="">This does two important things.</div>
</div>
</div>
</div>
</div>
<p data-start="5403" data-end="5470">Firstly, it ensures Copilot is working with the correct Perl version.</p>
<p data-start="5472" data-end="5621">Secondly, it installs the distribution dependencies, meaning Copilot can reason in a context that actually resembles my real development environment.</p>
<p data-start="5623" data-end="5690">Without this workflow, Copilot operates in a kind of generic space.</p>
<p data-start="5692" data-end="5791">With it, Copilot behaves like a contributor who has actually checked out your code and run <code data-start="5783" data-end="5790">cpanm</code>.</p>
<p data-start="5793" data-end="5824">That’s a useful difference.</p>
<hr />
<h2 data-start="5831" data-end="5852">Reviewing the Work</h2>
<p data-start="5854" data-end="5915">This is the part where it’s important not to get starry-eyed.</p>
<p data-start="5917" data-end="5951">I still review the PR carefully.</p>
<p data-start="5953" data-end="5969">I still check:</p>
<ul>
<li data-start="5953" data-end="5969">Has it changed behaviour unintentionally?</li>
<li data-start="5953" data-end="5969">Has it introduced unnecessary abstraction?</li>
<li data-start="5953" data-end="5969">Are the tests meaningful?</li>
<li data-start="5953" data-end="5969">Has it expanded scope beyond the issue?</li>
</ul>
<p>I check out the branch and run the tests. Exactly as I would with a PR from a human co-worker.</p>
<p data-start="6131" data-end="6213">You can request changes and reassign the PR to Copilot. It will revise its branch.</p>
<p data-start="6215" data-end="6282">The loop is fast. Faster than traditional asynchronous code review.</p>
<p data-start="6284" data-end="6320">But the responsibility is unchanged. You are still the maintainer.</p>
<hr />
<h2 data-start="6358" data-end="6385">Why This Feels Different</h2>
<p data-start="6387" data-end="6438">What’s happening here isn’t just “AI writing code”. It’s AI integrated into the contribution workflow:</p>
<ul>
<li data-start="6387" data-end="6438">Issues</li>
<li data-start="6387" data-end="6438">Structured reasoning</li>
<li data-start="6387" data-end="6438">Pull requests</li>
<li data-start="6387" data-end="6438">CI</li>
<li data-start="6387" data-end="6438">Review cycles</li>
</ul>
<p data-start="6572" data-end="6598">That architecture matters.</p>
<p data-start="6600" data-end="6660">It means you can use Copilot in a controlled, auditable way.</p>
<p data-start="6662" data-end="6746">In my experience with WebServer::DirIndex, this model works particularly well for:</p>
<ul>
<li data-start="6662" data-end="6746">Mechanical refactors</li>
<li data-start="6662" data-end="6746">Adding attributes (e.g. <code data-start="6799" data-end="6808">:reader</code> where appropriate)</li>
<li data-start="6662" data-end="6746">Removing dependencies</li>
<li data-start="6662" data-end="6746">Moving methods cleanly</li>
<li data-start="6662" data-end="6746">Adding new internal classes</li>
</ul>
<p data-start="6916" data-end="7037">It is less strong when the issue itself is vague or architectural. Copilot cannot infer the intent you didn’t articulate.</p>
<p data-start="7039" data-end="7177">But given a clear issue, it’s remarkably capable — even with modern Perl using tools like Feature::Compat::Class.</p>
<hr />
<h2 data-start="7184" data-end="7237">A Small but Important Point for the Perl Community</h2>
<p data-start="7239" data-end="7302">I&#8217;ve seen people saying that AI tools don’t handle Perl well. That has not been my experience.</p>
<p data-start="7338" data-end="7466">With a properly described issue, repository instructions, and a defined development environment, Copilot works competently with:</p>
<ul>
<li data-start="7338" data-end="7466">Modern Perl syntax</li>
<li data-start="7338" data-end="7466">CPAN distribution layouts</li>
<li data-start="7338" data-end="7466">Test suites</li>
<li data-start="7338" data-end="7466">Feature::Compat::Class (or whatever OO framework I&#8217;m using on a particular project)</li>
</ul>
<p data-start="7571" data-end="7605">The constraint isn’t the language. It’s how clearly you explain the task.</p>
<hr />
<h2 data-start="7652" data-end="7669">The Real Shift</h2>
<p data-start="7671" data-end="7734">The most interesting thing here isn’t that Copilot writes Perl. It’s that GitHub allows you to treat AI as a contributor.</p>
<ul>
<li data-start="7795" data-end="7813">You file an issue.</li>
<li data-start="7815" data-end="7829">You assign it.</li>
<li data-start="7831" data-end="7859">You supervise its reasoning.</li>
<li data-start="7861" data-end="7879">You review its PR.</li>
</ul>
<p data-start="7881" data-end="7903">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.</p>
<p data-start="7881" data-end="7903">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?</p>
<hr />
<h2 data-start="7881" data-end="7903">Links</h2>
<p>If you want to have a closer look at the issues and PRs I&#8217;m talking about, here are some links?</p>
<ul>
<li><a href="https://github.com/davorg-cpan/webserver-dirindex">WebServer::DirIndex repo</a></li>
<li><a href="https://github.com/davorg-cpan/webserver-dirindex/issues?q=is%3Aissue%20state%3Aclosed">Closed issues</a></li>
<li><a href="https://github.com/davorg-cpan/webserver-dirindex/pulls?q=is%3Apr+is%3Aclosed">Closed PRs</a></li>
</ul><p>The post <a href="https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/">Treating GitHub Copilot as a Contributor</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2026/02/treating-github-copilot-as-a-contributor/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2404</post-id>	</item>
		<item>
		<title>App::HTTPThis: the tiny web server I keep reaching for</title>
		<link>https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/</link>
					<comments>https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 04 Jan 2026 13:46:13 +0000</pubDate>
				<category><![CDATA[Web]]></category>
		<category><![CDATA[http_this]]></category>
		<category><![CDATA[perl]]></category>
		<category><![CDATA[static sites]]></category>
		<category><![CDATA[webdev]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2393</guid>

					<description><![CDATA[<p>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. For that [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/">App::HTTPThis: the tiny web server I keep reaching for</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="144" data-end="472">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.</p>
<p data-start="474" data-end="532">For that job, I’ve been using <a href="https://metacpan.org/dist/App-HTTPThis/view/bin/http_this"><strong data-start="504" data-end="521">App::HTTPThis</strong></a> for years.</p>
<p data-start="534" data-end="768">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: <em data-start="727" data-end="767">run a command and you’ve got a website</em>.</p>
<h2 data-start="770" data-end="799">Why I’ve used it for years</h2>
<p data-start="801" data-end="865">Static sites are deceptively simple… right up until they aren’t.</p>
<ul data-start="867" data-end="1163">
<li data-start="867" data-end="940">
<p data-start="869" data-end="940">You want to check that relative links behave the way you think they do.</p>
</li>
<li data-start="941" data-end="1021">
<p data-start="943" data-end="1021">You want to confirm your CSS and images are loading with the paths you expect.</p>
</li>
<li data-start="1022" data-end="1163">
<p data-start="1024" data-end="1163">You want to reproduce “real HTTP” behaviour (caching headers, MIME types, directory handling) rather than viewing files directly from disk.</p>
</li>
</ul>
<p data-start="1165" data-end="1371">Sure, you <em data-start="1175" data-end="1180">can</em> open <code data-start="1186" data-end="1210">file:///.../index.html</code> 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.</p>
<p data-start="1373" data-end="1417">With <code data-start="1378" data-end="1389">http_this</code>, the workflow is basically:</p>
<ul data-start="1419" data-end="1510">
<li data-start="1419" data-end="1450">
<p data-start="1421" data-end="1450"><code data-start="1421" data-end="1425">cd</code> into your site directory</p>
</li>
<li data-start="1451" data-end="1473">
<p data-start="1453" data-end="1473">run a single command</p>
</li>
<li data-start="1474" data-end="1486">
<p data-start="1476" data-end="1486">open a URL</p>
</li>
<li data-start="1487" data-end="1510">
<p data-start="1489" data-end="1510">get on with your life</p>
</li>
</ul>
<p data-start="1512" data-end="1565">It’s the “tiny screwdriver” that’s always on my desk.</p>
<h2 data-start="1567" data-end="1588">Why I took it over</h2>
<p data-start="1590" data-end="1776">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.</p>
<p data-start="1880" data-end="2162">But I was using App::HTTPThis regularly, and I had one small-but-annoying itch: when you visited a directory URL, it would <em data-start="541" data-end="549">always</em> show a directory listing &#8211; even if that directory contained an <code data-start="613" data-end="625">index.html</code>. So instead of behaving like a typical web server (serve <code data-start="683" data-end="695">index.html</code> by default), it treated <code data-start="720" data-end="732">index.html</code> as just another file you had to click.</p>
<p data-start="1880" data-end="2162">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.</p>
<p data-start="1880" data-end="2162">(If you want to read more on this story, I wrote a couple of <a href="https://dev.to/davorg/the-story-behind-a-new-module-2gkp">blog</a> <a href="https://perlhacks.com/2023/05/mission-almost-accomplished/">posts</a>.)</p>
<h2 data-start="2164" data-end="2202">What I’ve done since taking it over</h2>
<p data-start="2204" data-end="2336">Most of the changes are about making the “serve a directory” experience smoother, without turning it into a kitchen-sink web server.</p>
<h3 data-start="2338" data-end="2368">1) Serve index pages by default (autoindex)</h3>
<p data-start="245" data-end="630">The first change was to make directory URLs behave like you’d expect: <strong data-start="1124" data-end="1174">if <code data-start="1129" data-end="1141">index.html</code> exists, serve it automatically</strong>. If it doesn’t, you still get a directory listing.</p>
<h3 data-start="2635" data-end="2662">2) Prettier index pages</h3>
<p data-start="2664" data-end="2731">Once autoindex was in place, I then turned my attention to the <em data-start="1106" data-end="1116">fallback</em> directory listing page. If there isn’t an <code data-start="1159" data-end="1171">index.html</code>, 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 <em data-start="1337" data-end="1341">do</em> end up browsing raw directories.</p>
<h3 data-start="2963" data-end="2983">3) A config file</h3>
<p data-start="2985" data-end="3086">Once you’ve used a tool for a while, you start to realise you run it <em data-start="3054" data-end="3068">the same way</em> most of the time.</p>
<p data-start="3088" data-end="3261">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.</p>
<h3 data-start="3263" data-end="3285">4) <code data-start="3270" data-end="3278">--host</code> option</h3>
<p data-start="3287" data-end="3367">The ability to control the host binding sounds like an edge case until it isn’t.</p>
<p data-start="3369" data-end="3388">Sometimes you want:</p>
<ul data-start="3390" data-end="3546">
<li data-start="3390" data-end="3427">
<p data-start="3392" data-end="3427">only <code data-start="3397" data-end="3408">localhost</code> access for safety;</p>
</li>
<li data-start="3428" data-end="3495">
<p data-start="3430" data-end="3495">access from other devices on your network (phone/tablet testing);</p>
</li>
<li data-start="3496" data-end="3546">
<p data-start="3498" data-end="3546">behaviour that matches a particular environment.</p>
</li>
</ul>
<p data-start="3548" data-end="3635">A <code data-start="3550" data-end="3558">--host</code> option gives you that control without adding complexity to the default case.</p>
<h2 data-start="3637" data-end="3679">The Bonjour feature (and what it’s for)</h2>
<p data-start="270" data-end="529">This is the part I only really appreciated recently: App::HTTPThis can advertise itself on your local network using <strong data-start="386" data-end="403">mDNS / DNS-SD</strong> &#8211; commonly called <em data-start="422" data-end="431">Bonjour</em> on Apple platforms, <em data-start="452" data-end="459">Avahi</em> on Linux, and various other names depending on who you’re talking to.</p>
<p data-start="531" data-end="592"><span data-start="546" data-end="591">I</span>t’s switched on with the <code data-start="574" data-end="582">--name</code> option.</p>
<div class="contain-inline-size rounded-2xl corner-superellipse/1.1 relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-bash">http_this --name MyService</code></div>
</div>
<p data-start="631" data-end="966">When you do that, <code data-start="649" data-end="660">http_this</code> publishes an <code data-start="674" data-end="686">_http._tcp</code> service on your local network with the instance name you chose (<code data-start="751" data-end="759">MyService</code> 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 <code data-start="937" data-end="964">http://192.168.1.23:7007/</code>”.</p>
<p data-start="968" data-end="1389">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: <strong data-start="1303" data-end="1322">not Apple magic</strong>, just <strong data-start="1329" data-end="1364">local-network service discovery</strong> with a branding problem.</p>
<p data-start="1391" data-end="1708">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 <a href="https://github.com/davorg-cpan/app-httpthis/blob/master/BONJOUR.md"><code data-start="1554" data-end="1566">BONJOUR.md</code></a> 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.</p>
<p data-start="1710" data-end="1782">(If you’re curious, look for <code data-start="1739" data-end="1751">_http._tcp</code> and your chosen service name.)</p>
<p data-start="1710" data-end="1782">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.</p>
<h2 data-start="4801" data-end="4836">Related tools in the same family</h2>
<p data-start="4838" data-end="5017">App::HTTPThis is part of a little ecosystem of “run a thing <em data-start="4898" data-end="4904">here</em> quickly” command-line apps. If you like the shape of <code data-start="4958" data-end="4969">http_this</code>, you might also want to look at these siblings:</p>
<ul data-start="5019" data-end="5445">
<li data-start="5019" data-end="5172">
<p data-start="5021" data-end="5172"><a href="https://metacpan.org/pod/https_this"><strong data-start="5021" data-end="5035">https_this</strong></a> : like <code data-start="5043" data-end="5054">http_this</code>, but served over HTTPS (useful when you need to test secure contexts, service workers, APIs that require HTTPS, etc.)</p>
</li>
<li data-start="5173" data-end="5260">
<p data-start="5175" data-end="5260"><a href="https://metacpan.org/pod/cgi_this"><strong data-start="5175" data-end="5187">cgi_this</strong></a> : for quick CGI-style testing without setting up a full web server stack</p>
</li>
<li data-start="5261" data-end="5361">
<p data-start="5263" data-end="5361"><a href="https://metacpan.org/pod/dav_this"><strong data-start="5263" data-end="5275">dav_this</strong></a> : serves content over WebDAV (handy for testing clients or workflows that expect DAV)</p>
</li>
<li data-start="5362" data-end="5445">
<p data-start="5364" data-end="5445"><a href="https://metacpan.org/pod/ftp_this"><strong data-start="5364" data-end="5376">ftp_this</strong></a> : quick FTP server for those rare-but-real moments when you need one</p>
</li>
</ul>
<p data-start="5447" data-end="5586">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”.</p>
<h2 data-start="5588" data-end="5602">Wrapping up</h2>
<p data-start="5604" data-end="5789">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.</p>
<p data-start="5791" data-end="5939">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.</p>
<p data-start="5941" data-end="6072">And if you’ve got ideas, bug reports, or platform notes (especially around Bonjour/Avahi weirdness), I’m always <a href="https://github.com/davorg-cpan/app-httpthis/issues">happy to hear them</a>.</p><p>The post <a href="https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/">App::HTTPThis: the tiny web server I keep reaching for</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2026/01/apphttpthis-the-tiny-web-server-i-keep-reaching-for/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2393</post-id>	</item>
		<item>
		<title>Behind the scenes at Perl School Publishing</title>
		<link>https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/</link>
					<comments>https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 14 Dec 2025 17:46:53 +0000</pubDate>
				<category><![CDATA[Books]]></category>
		<category><![CDATA[epub]]></category>
		<category><![CDATA[linter]]></category>
		<category><![CDATA[pandoc]]></category>
		<category><![CDATA[perlschool]]></category>
		<category><![CDATA[wkhtmltopdf]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2376</guid>

					<description><![CDATA[<p>We’ve just published a new Perl School book: Design Patterns in Modern Perl by Mohammad Sajid Anwar. 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 .mobi any more, tools have changed, and my old “it mostly works [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/">Behind the scenes at Perl School Publishing</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="150" data-end="255">We’ve just published a new <a href="https://perlschool.com/">Perl School</a> book: <a href="https://perlschool.com/books/design-patterns/"><em data-start="195" data-end="227">Design Patterns in Modern Perl</em></a> by Mohammad Sajid Anwar.</p>
<p data-start="257" data-end="501">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 <code data-start="383" data-end="390">.mobi</code> any more, tools have changed, and my old “it mostly works if you squint” build pipeline was starting to creak.</p>
<p data-start="503" data-end="803">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.</p>
<p data-start="805" data-end="1023">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.</p>
<hr data-start="1025" data-end="1028" />
<h2 data-start="1030" data-end="1077">The old world, and why it wasn’t good enough</h2>
<p data-start="1079" data-end="1148">The original Perl School pipeline dates back to a very different era:</p>
<ul data-start="1150" data-end="1287">
<li data-start="1150" data-end="1180">
<p data-start="1152" data-end="1180">Amazon wanted <code data-start="1166" data-end="1173">.mobi</code> files.</p>
</li>
<li data-start="1181" data-end="1207">
<p data-start="1183" data-end="1207">EPUB support was patchy.</p>
</li>
<li data-start="1208" data-end="1287">
<p data-start="1210" data-end="1287">I was happy to glue things together with shell scripts and hope for the best.</p>
</li>
</ul>
<p data-start="1289" data-end="1528">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”.</p>
<p data-start="1530" data-end="1733">Coming back to it for <em data-start="1552" data-end="1584">Design Patterns in Modern Perl</em> made that painfully obvious. The book itself is modern and well-structured; the pipeline that produced it shouldn’t feel like a relic.</p>
<hr data-start="1735" data-end="1738" />
<h2 data-start="1740" data-end="1806">Choosing tools: Pandoc and <code data-start="1770" data-end="1783">wkhtmltopdf</code> (and no LaTeX, thanks)</h2>
<p data-start="1808" data-end="1856">The new pipeline is built around two main tools:</p>
<ul data-start="1858" data-end="2088">
<li data-start="1858" data-end="1993">
<p data-start="1860" data-end="1993"><a href="https://pandoc.org/"><strong data-start="1860" data-end="1870">Pandoc</strong></a> – the Swiss Army knife of document conversion. It can take Markdown/Markua plus metadata and produce HTML, EPUB, and much, much more.</p>
</li>
<li data-start="1994" data-end="2088">
<p data-start="1996" data-end="2088"><a href="https://wkhtmltopdf.org/"><strong data-start="1996" data-end="2013"><code data-start="1998" data-end="2011">wkhtmltopdf</code></strong></a> – which turns HTML into a print-ready PDF using a headless browser engine.</p>
</li>
</ul>
<p data-start="2090" data-end="2340">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:</p>
<ul>
<li data-start="2344" data-end="2398">Markdown → HTML (via Pandoc) → PDF (via <code data-start="2384" data-end="2397">wkhtmltopdf</code>)</li>
</ul>
<p data-start="2400" data-end="2422">And the EPUB route is:</p>
<ul>
<li data-start="2426" data-end="2483">Markdown → EPUB (via Pandoc) → validated with <code data-start="2472" data-end="2483">epubcheck</code></li>
</ul>
<p data-start="2485" data-end="2699">The front matter (cover page, title page, copyright, etc.) is generated with Template Toolkit from a simple <code data-start="2593" data-end="2612">book-metadata.yml</code> file, and then stitched together with the chapters to produce a nice, consistent book.</p>
<p data-start="2701" data-end="2755">That got us a long way… but then a reader found a bug.</p>
<hr data-start="2757" data-end="2760" />
<h2 data-start="2762" data-end="2786">The iBooks bug report</h2>
<p data-start="2788" data-end="3027">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 <em data-start="2949" data-end="2981">Design Patterns in Modern Perl</em>, they were greeted with a big pink error box.</p>
<p data-start="3029" data-end="3066">Apple’s error message boiled down to:</p>
<blockquote data-start="3068" data-end="3122">
<p data-start="3070" data-end="3122">There’s something wrong with the XHTML in this EPUB.</p>
</blockquote>
<p data-start="3124" data-end="3198">That was slightly worrying. But, hey, every day is a learning opportunity. And, after a bit of digging, this is what I found out.</p>
<p data-start="3124" data-end="3198">EPUB 3 files are essentially a ZIP containing:</p>
<ul data-start="3200" data-end="3274">
<li data-start="3200" data-end="3223">
<p data-start="3202" data-end="3223">XHTML content files</p>
</li>
<li data-start="3224" data-end="3249">
<p data-start="3226" data-end="3249">a bit of XML metadata</p>
</li>
<li data-start="3250" data-end="3274">
<p data-start="3252" data-end="3274">CSS, images, and so on</p>
</li>
</ul>
<p data-start="3276" data-end="3390">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&#8217;ve got used to over the last decade or so.</p>
<p data-start="3392" data-end="3487">The first job was to see if we could reproduce the error and work out where it was coming from.</p>
<hr data-start="3489" data-end="3492" />
<h2 data-start="3494" data-end="3520">Discovering <code data-start="3509" data-end="3520">epubcheck</code></h2>
<p data-start="3522" data-end="3540">Enter <code data-start="3528" data-end="3539">epubcheck</code>.</p>
<p data-start="3542" data-end="3735"><code data-start="3542" data-end="3553">epubcheck</code> is the reference validator for EPUB files. Point it at an <code data-start="3612" data-end="3619">.epub</code> and it will unpack it, parse all the XML/XHTML, check the metadata and manifest, and tell you exactly what’s wrong.</p>
<p data-start="3737" data-end="3786">Running it on the book immediately produced this:</p>
<blockquote data-start="3788" data-end="3895">
<p data-start="3790" data-end="3895">Fatal Error while parsing file: The element type <code data-start="3839" data-end="3843">br</code> must be terminated by the matching end-tag <code data-start="3887" data-end="3894">&lt;/br&gt;</code>.</p>
</blockquote>
<p data-start="3897" data-end="3935">That’s the XML parser’s way of saying:</p>
<ul data-start="3937" data-end="4043">
<li data-start="3937" data-end="3963">
<p data-start="3939" data-end="3963">In HTML, <code data-start="3948" data-end="3954">&lt;br&gt;</code> is fine.</p>
</li>
<li data-start="3964" data-end="4043">
<p data-start="3966" data-end="4043">In XHTML (which is XML), you must use <code data-start="4004" data-end="4012">&lt;br /&gt;</code> (self-closing) or <code data-start="4031" data-end="4042">&lt;br&gt;&lt;/br&gt;</code>.</p>
</li>
</ul>
<p data-start="4045" data-end="4109">And there were a number of these scattered across a few chapters.</p>
<p data-start="4111" data-end="4309">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 Pandoc&#8217;s EPUB creation explicitly says that it won&#8217;t touch HTML fragments it finds in a Markdown file when converting it to EPUB. It&#8217;s down to the author to ensure they&#8217;re using valid XHTML</p>
<hr data-start="4311" data-end="4314" />
<h2 data-start="4316" data-end="4349">A quick (but not scalable) fix</h2>
<p data-start="4351" data-end="4418">Under time pressure, the quickest way to confirm the diagnosis was:</p>
<ol data-start="4420" data-end="4615">
<li data-start="4420" data-end="4448">
<p data-start="4423" data-end="4448">Unzip the generated EPUB.</p>
</li>
<li data-start="4449" data-end="4482">
<p data-start="4452" data-end="4482">Open the offending XHTML file.</p>
</li>
<li data-start="4483" data-end="4543">
<p data-start="4486" data-end="4543">Manually turn <code data-start="4500" data-end="4506">&lt;br&gt;</code> into <code data-start="4512" data-end="4520">&lt;br /&gt;</code> in a couple of places.</p>
</li>
<li data-start="4544" data-end="4563">
<p data-start="4547" data-end="4563">Re-zip the EPUB.</p>
</li>
<li data-start="4564" data-end="4589">
<p data-start="4567" data-end="4589">Run <code data-start="4571" data-end="4582">epubcheck</code> again.</p>
</li>
<li data-start="4590" data-end="4615">
<p data-start="4593" data-end="4615">Try it in Apple Books.</p>
</li>
</ol>
<p data-start="4617" data-end="4741">That worked. The errors vanished, <code data-start="4651" data-end="4662">epubcheck</code> was happy, and the reader confirmed that the fixed file opened fine in iBooks.</p>
<p data-start="4743" data-end="4755">But clearly:</p>
<blockquote data-start="4757" data-end="4821">
<p data-start="4759" data-end="4821"><em data-start="4759" data-end="4819">Open the EPUB in a text editor and fix the XHTML by hand</em></p>
</blockquote>
<p data-start="4823" data-end="4864">is not a sustainable publishing strategy.</p>
<p data-start="4866" data-end="4973">So the next step was to move from “hacky manual fix” to “the pipeline prevents this from happening again”.</p>
<hr data-start="4975" data-end="4978" />
<h2 data-start="4980" data-end="5020">HTML vs XHTML, and why linters matter</h2>
<p data-start="5022" data-end="5083">The underlying issue is straightforward once you remember it:</p>
<ul data-start="5085" data-end="5366">
<li data-start="5085" data-end="5167">
<p data-start="5087" data-end="5167">HTML is very forgiving. Browsers will happily fix up all kinds of broken markup.</p>
</li>
<li data-start="5168" data-end="5366">
<p data-start="5170" data-end="5210">XHTML is XML, so it’s not forgiving:</p>
<ul data-start="5213" data-end="5366">
<li data-start="5213" data-end="5288">
<p data-start="5215" data-end="5288">empty elements must be self-closed (<code data-start="5251" data-end="5259">&lt;br /&gt;</code>, <code data-start="5261" data-end="5270">&lt;img /&gt;</code>, <code data-start="5272" data-end="5280">&lt;hr /&gt;</code>, etc.),</p>
</li>
<li data-start="5291" data-end="5335">
<p data-start="5293" data-end="5335">tags must be properly nested and balanced,</p>
</li>
<li data-start="5338" data-end="5366">
<p data-start="5340" data-end="5366">attributes must be quoted.</p>
</li>
</ul>
</li>
</ul>
<p data-start="5368" data-end="5499">EPUB 3 content files are XHTML. If you feed them sloppy HTML, some readers (like Apple Books) will just refuse to load the chapter.</p>
<p data-start="5501" data-end="5603">So I added a manuscript HTML linter to the toolchain, before we ever get to Pandoc or <code data-start="5591" data-end="5602">epubcheck</code>.</p>
<p data-start="5605" data-end="5625">Roughly, the linter:</p>
<ul data-start="5627" data-end="5915">
<li data-start="5627" data-end="5730">
<p data-start="5629" data-end="5730">Reads the manuscript (ignoring fenced code blocks so it doesn’t complain about <code data-start="5708" data-end="5711">&lt;</code> in Perl examples).</p>
</li>
<li data-start="5731" data-end="5762">
<p data-start="5733" data-end="5762">Extracts any raw HTML chunks.</p>
</li>
<li data-start="5763" data-end="5812">
<p data-start="5765" data-end="5812">Wraps those chunks in a temporary root element.</p>
</li>
<li data-start="5813" data-end="5867">
<p data-start="5815" data-end="5867">Uses <code data-start="5820" data-end="5833">XML::LibXML</code> to check they’re well-formed XML.</p>
</li>
<li data-start="5868" data-end="5915">
<p data-start="5870" data-end="5915">Reports any errors with file and line number.</p>
</li>
</ul>
<p data-start="5917" data-end="6043">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?”</p>
<p data-start="6045" data-end="6124">That would have caught the <code data-start="6072" data-end="6078">&lt;br&gt;</code> problem before the book ever left my machine.</p>
<hr data-start="6126" data-end="6129" />
<h2 data-start="6131" data-end="6181">Hardening the pipeline: <code data-start="6158" data-end="6169">epubcheck</code> in the loop</h2>
<p data-start="6183" data-end="6310">The linter catches the obvious issues in the manuscript; <code data-start="6244" data-end="6255">epubcheck</code> is still the final authority on the finished EPUB.</p>
<p data-start="6312" data-end="6348">So the pipeline now looks like this:</p>
<ol data-start="6350" data-end="6818">
<li data-start="6350" data-end="6433">
<p data-start="6353" data-end="6433"><strong data-start="6353" data-end="6381">Lint the manuscript HTML</strong><br data-start="6381" data-end="6384" />Catch broken raw HTML/XHTML before conversion.</p>
</li>
<li data-start="6434" data-end="6670">
<p data-start="6437" data-end="6475"><strong data-start="6437" data-end="6473">Build PDF + EPUB via <code data-start="6460" data-end="6471">make_book</code></strong></p>
<ul data-start="6479" data-end="6670">
<li data-start="6479" data-end="6549">
<p data-start="6481" data-end="6549">Generate front matter from metadata (cover, title pages, copyright).</p>
</li>
<li data-start="6553" data-end="6594">
<p data-start="6555" data-end="6594">Turn Markdown + front matter into HTML.</p>
</li>
<li data-start="6598" data-end="6640">
<p data-start="6600" data-end="6640">Use <code data-start="6604" data-end="6617">wkhtmltopdf</code> for a print-ready PDF.</p>
</li>
<li data-start="6644" data-end="6670">
<p data-start="6646" data-end="6670">Use Pandoc for the EPUB.</p>
</li>
</ul>
</li>
<li data-start="6671" data-end="6756">
<p data-start="6674" data-end="6756"><strong data-start="6674" data-end="6705">Run <code data-start="6680" data-end="6691">epubcheck</code> on the EPUB</strong><br data-start="6705" data-end="6708" />Ensure the final file is standards-compliant.</p>
</li>
<li data-start="6757" data-end="6818">
<p data-start="6760" data-end="6818">Only then do we upload it to Leanpub and Amazon, making it available to eager readers.</p>
</li>
</ol>
<p data-start="6820" data-end="7033">The nice side-effect of this is that <em data-start="6857" data-end="6862">any</em> 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.</p>
<hr data-start="7035" data-end="7038" />
<h2 data-start="7040" data-end="7092">Docker and GitHub Actions: making it reproducible</h2>
<p data-start="7094" data-end="7209">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:</p>
<ul data-start="7211" data-end="7317">
<li data-start="7211" data-end="7267">
<p data-start="7213" data-end="7267">other authors might want to build their own drafts, or</p>
</li>
<li data-start="7268" data-end="7317">
<p data-start="7270" data-end="7317">I want the build to happen automatically in CI.</p>
</li>
</ul>
<p data-start="7319" data-end="7414">So the next step was to package everything into a Docker image and wire it into GitHub Actions.</p>
<p data-start="7416" data-end="7472">The Docker image is based on a slim Ubuntu and includes:</p>
<ul data-start="7474" data-end="7666">
<li data-start="7474" data-end="7536">
<p data-start="7476" data-end="7536">Perl + <code data-start="7483" data-end="7490">cpanm</code> + all CPAN modules from the repo’s <code data-start="7526" data-end="7536">cpanfile</code></p>
</li>
<li data-start="7537" data-end="7547">
<p data-start="7539" data-end="7547"><code data-start="7539" data-end="7547">pandoc</code></p>
</li>
<li data-start="7548" data-end="7563">
<p data-start="7550" data-end="7563"><code data-start="7550" data-end="7563">wkhtmltopdf</code></p>
</li>
<li data-start="7564" data-end="7584">
<p data-start="7566" data-end="7584">Java + <code data-start="7573" data-end="7584">epubcheck</code></p>
</li>
<li data-start="7585" data-end="7666">
<p data-start="7587" data-end="7666">The Perl School utility scripts themselves (<code data-start="7631" data-end="7642">make_book</code>, <code data-start="7644" data-end="7659">check_ms_html</code>, etc.)</p>
</li>
</ul>
<p data-start="7668" data-end="7706">The workflow in a book repo is simple:</p>
<ul data-start="7708" data-end="7920">
<li data-start="7708" data-end="7749">
<p data-start="7710" data-end="7749">Mount the book’s Git repo into <code data-start="7741" data-end="7748">/work</code>.</p>
</li>
<li data-start="7750" data-end="7795">
<p data-start="7752" data-end="7795">Run <code data-start="7756" data-end="7771">check_ms_html</code> to lint the manuscript.</p>
</li>
<li data-start="7796" data-end="7856">
<p data-start="7798" data-end="7856">Run <code data-start="7802" data-end="7813">make_book</code> to build <code data-start="7823" data-end="7836">built/*.pdf</code> and <code data-start="7841" data-end="7855">built/*.epub</code>.</p>
</li>
<li data-start="7857" data-end="7887">
<p data-start="7859" data-end="7887">Run <code data-start="7863" data-end="7874">epubcheck</code> on the EPUB.</p>
</li>
<li data-start="7888" data-end="7920">
<p data-start="7890" data-end="7920">Upload the <code data-start="7901" data-end="7909">built/</code> artefacts.</p>
</li>
</ul>
<p data-start="7922" data-end="8174">GitHub Actions then uses that same image as a <code data-start="7968" data-end="7980">container</code> 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, <code>wkhtmltopdf</code>, Java, and a large chunk of CPAN locally.</p>
<hr data-start="8176" data-end="8179" />
<h2 data-start="8181" data-end="8210">Why I’m making this public</h2>
<p data-start="8212" data-end="8246">At this point, the pipeline feels:</p>
<ul data-start="8248" data-end="8397">
<li data-start="8248" data-end="8291">
<p data-start="8250" data-end="8291">modern (Pandoc, HTML/CSS layout, EPUB 3),</p>
</li>
<li data-start="8292" data-end="8322">
<p data-start="8294" data-end="8322">robust (lint + <code data-start="8309" data-end="8320">epubcheck</code>),</p>
</li>
<li data-start="8323" data-end="8357">
<p data-start="8325" data-end="8357">reproducible (Docker + Actions),</p>
</li>
<li data-start="8358" data-end="8397">
<p data-start="8360" data-end="8397">and not tied to Perl in any deep way.</p>
</li>
</ul>
<p data-start="8399" data-end="8618">Yes, <em data-start="8404" data-end="8436">Design Patterns in Modern Perl</em> 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.</p>
<p data-start="8620" data-end="8764">So I’ve made the utilities available in a public repository (the <a href="https://github.com/davorg/perlschool-util"><code data-start="8685" data-end="8702">perlschool-util</code></a> repo on GitHub). There you’ll find:</p>
<ul data-start="8766" data-end="8907">
<li data-start="8766" data-end="8786">
<p data-start="8768" data-end="8786">the build scripts,</p>
</li>
<li data-start="8787" data-end="8822">
<p data-start="8789" data-end="8822">the Dockerfile and helper script,</p>
</li>
<li data-start="8823" data-end="8862">
<p data-start="8825" data-end="8862">example GitHub Actions configuration,</p>
</li>
<li data-start="8863" data-end="8907">
<p data-start="8865" data-end="8907">and notes on how to structure a book repo.</p>
</li>
</ul>
<p data-start="8909" data-end="8932">If you’ve ever thought:</p>
<blockquote data-start="8934" data-end="9055">
<p data-start="8936" data-end="9055">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…</p>
</blockquote>
<p data-start="9057" data-end="9104">then you’re very much the person I had in mind.</p>
<p data-start="9106" data-end="9251">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.</p>
<p data-start="9253" data-end="9447">And, of course, if you’d like to write a book <em data-start="9299" data-end="9304">for</em> Perl School, I’m still <a href="https://perlschool.com/write/">very interested in talking to potential authors</a> &#8211; especially if you’re doing interesting modern Perl in the real world.</p><p>The post <a href="https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/">Behind the scenes at Perl School Publishing</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/12/behind-the-scenes-at-perl-school-publishing/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2376</post-id>	</item>
		<item>
		<title>Dotcom Survivor Syndrome – How Perl’s Early Success Created the Seeds of Its Downfall</title>
		<link>https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/</link>
					<comments>https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 30 Nov 2025 15:50:59 +0000</pubDate>
				<category><![CDATA[Perl]]></category>
		<category><![CDATA[developer bias]]></category>
		<category><![CDATA[dotcom]]></category>
		<category><![CDATA[survivor syndrome]]></category>
		<category><![CDATA[web development]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2368</guid>

					<description><![CDATA[<p>If you were building web applications during the first dot-com boom, chances are you wrote Perl. And if you&#8217;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. This reflexive aversion isn’t just a preference. It’s what I call Dotcom Survivor Syndrome: [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/">Dotcom Survivor Syndrome – How Perl’s Early Success Created the Seeds of Its Downfall</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="403" data-end="642">If you were building web applications during the first dot-com boom, chances are you wrote Perl. And if you&#8217;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.</p>
<p data-start="644" data-end="894">This reflexive aversion isn’t just a preference. It’s what I call <strong data-start="710" data-end="743">Dotcom Survivor Syndrome</strong>: 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.</p>
<p data-start="896" data-end="1111">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.</p>
<hr data-start="1113" data-end="1116" />
<h2 data-start="1118" data-end="1177"><strong data-start="1121" data-end="1175">The Glory Days: Perl at the Heart of the Early Web</strong></h2>
<p data-start="1179" data-end="1237">In the mid- to late-1990s, Perl was the web’s duct tape.</p>
<ul data-start="1238" data-end="1435">
<li data-start="1238" data-end="1283">
<p data-start="1240" data-end="1283">It powered CGI scripts on Apache servers.</p>
</li>
<li data-start="1284" data-end="1338">
<p data-start="1286" data-end="1338">It automated deployments before DevOps had a name.</p>
</li>
<li data-start="1339" data-end="1435">
<p data-start="1341" data-end="1435">It parsed logs, scraped data, processed form input, and glued together whatever needed glueing.</p>
</li>
</ul>
<p data-start="1437" data-end="1614"><strong data-start="1437" data-end="1447">Perl 5</strong>, released in 1994, introduced real structure: references, modules, and the birth of <strong data-start="1532" data-end="1540">CPAN</strong>, which became one of the most effective software ecosystems in the world.</p>
<p data-start="1616" data-end="1694">Perl wasn’t just part of the early web—it was <strong data-start="1662" data-end="1693">instrumental in creating it</strong>.</p>
<hr data-start="1696" data-end="1699" />
<h2 data-start="1701" data-end="1764"><strong data-start="1704" data-end="1762">The Dotcom Boom: Shipping Fast and Breaking Everything</strong></h2>
<p data-start="1766" data-end="1876">To understand the long shadow Perl casts, you have to understand the speed and pressure of the dot-com boom.</p>
<p data-start="1878" data-end="1961">We weren’t just building websites.<br data-start="1912" data-end="1915" />We were inventing <strong data-start="1933" data-end="1940">how</strong> to build websites.</p>
<p data-start="1963" data-end="2090">Best practices? Mostly unwritten.<br data-start="1996" data-end="1999" />Frameworks? Few existed.<br data-start="2023" data-end="2026" />Code reviews? Uncommon.<br data-start="2049" data-end="2052" />Continuous integration? Still a dream.</p>
<p data-start="2092" data-end="2228">The pace was frantic. You built something overnight, demoed it in the morning, and deployed it that afternoon. And Perl let you do that.</p>
<p data-start="2230" data-end="2403">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:</p>
<ul data-start="2404" data-end="2603">
<li data-start="2404" data-end="2454">
<p data-start="2406" data-end="2454">Thousands of lines of unstructured CGI scripts</p>
</li>
<li data-start="2455" data-end="2480">
<p data-start="2457" data-end="2480">Minimal documentation</p>
</li>
<li data-start="2481" data-end="2512">
<p data-start="2483" data-end="2512">Global variables everywhere</p>
</li>
<li data-start="2513" data-end="2554">
<p data-start="2515" data-end="2554">Inline HTML mixed with business logic</p>
</li>
<li data-start="2555" data-end="2603">
<p data-start="2557" data-end="2603">Security holes you could drive a truck through</p>
</li>
</ul>
<p data-start="2605" data-end="2845">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.</p>
<hr data-start="2847" data-end="2850" />
<h2 data-start="2852" data-end="2913"><strong data-start="2855" data-end="2911">Dotcom Survivor Syndrome: Bias with a Backstory</strong></h2>
<p data-start="2915" data-end="3000">Many senior engineers today carry these memories with them. They associate Perl with:</p>
<ul data-start="3001" data-end="3103">
<li data-start="3001" data-end="3025">
<p data-start="3003" data-end="3025">Fragile legacy systems</p>
</li>
<li data-start="3026" data-end="3059">
<p data-start="3028" data-end="3059">Inconsistent, “write-only” code</p>
</li>
<li data-start="3060" data-end="3103">
<p data-start="3062" data-end="3103">The bad old days of early web development</p>
</li>
</ul>
<p data-start="3105" data-end="3258">And that’s understandable. But it also creates a bias—often unconscious—that prevents Perl from getting a fair hearing in modern development discussions.</p>
<hr data-start="3260" data-end="3263" />
<h2 data-start="3265" data-end="3317"><strong data-start="3268" data-end="3315">Version Number Paralysis: The Perl 6 Effect</strong></h2>
<p data-start="3319" data-end="3437">If Dotcom Boom Survivor Syndrome created the emotional case against Perl, then Perl 6 created the optical one.</p>
<p data-start="3439" data-end="3617">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.</p>
<p data-start="3619" data-end="3635">In the meantime:</p>
<ul data-start="3636" data-end="3879">
<li data-start="3636" data-end="3749">
<p data-start="3638" data-end="3749"><strong data-start="3638" data-end="3676">Perl 5 continued to evolve quietly, </strong>but with the <em data-start="3690" data-end="3711">implied expectation</em> that it would eventually be replaced.</p>
</li>
<li data-start="3750" data-end="3879">
<p data-start="3752" data-end="3879"><strong data-start="3752" data-end="3781">Years turned into decades</strong>, and confusion set in. Was Perl 5 deprecated? Was Perl 6 compatible? What was the future of Perl?</p>
</li>
</ul>
<p data-start="3881" data-end="4132">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&#8230; but never 6. Perl 6 finally emerged in 2015, but as an entirely different language, not a successor.</p>
<p data-start="4134" data-end="4250">Eventually, the community admitted what everyone already knew: Perl 6 wasn’t Perl. In 2019, it was renamed Raku.</p>
<p data-start="4252" data-end="4442">But the damage was done. For nearly two decades, the version number “6” hung over Perl 5 like a storm cloud &#8211; a constant reminder that its future was uncertain, even when that wasn’t true.</p>
<p data-start="4444" data-end="4495">This is what I call <strong data-start="4464" data-end="4492">Version Number Paralysis</strong>:</p>
<ul data-start="4496" data-end="4714">
<li data-start="4496" data-end="4561">
<p data-start="4498" data-end="4561">A stalled major version that made the language look obsolete.</p>
</li>
<li data-start="4562" data-end="4631">
<p data-start="4564" data-end="4631">A missed opportunity to signal continued relevance and evolution.</p>
</li>
<li data-start="4632" data-end="4714">
<p data-start="4634" data-end="4714">A marketing failure that deepened the sense that Perl was a thing of the past.</p>
</li>
</ul>
<p data-start="4716" data-end="4869">Even today, many developers believe Perl is “stuck at version 5,” unaware that modern Perl is actively maintained, well-supported, and quite capable.</p>
<p data-start="4716" data-end="4869">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.</p>
<hr data-start="4871" data-end="4874" />
<h2 data-start="4876" data-end="4920"><strong data-start="4879" data-end="4918">What They Missed While Looking Away</strong></h2>
<p data-start="4922" data-end="4989">While the world was confused or looking elsewhere, Perl 5 gained:</p>
<ul data-start="4990" data-end="5224">
<li data-start="4990" data-end="5028">
<p data-start="4992" data-end="5028">Modern object systems (Moo, Moose)</p>
</li>
<li data-start="5029" data-end="5077">
<p data-start="5031" data-end="5077">A mature testing culture (Test::More, Test2)</p>
</li>
<li data-start="5078" data-end="5145">
<p data-start="5080" data-end="5145">Widespread use of best practices (Perl::Critic, perltidy, etc.)</p>
</li>
<li data-start="5146" data-end="5189">
<p data-start="5148" data-end="5189">Core team stability and annual releases</p>
</li>
<li data-start="5190" data-end="5224">
<p data-start="5192" data-end="5224">Huge CPAN growth and refinements</p>
</li>
</ul>
<p data-start="5226" data-end="5378">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.</p>
<hr data-start="5380" data-end="5383" />
<h2 data-start="5385" data-end="5409"><strong data-start="5388" data-end="5407">Can We Move On?</strong></h2>
<p data-start="5411" data-end="5576">Dotcom Survivor Syndrome is real. So is Version Number Paralysis. Together, they’ve unfairly buried a language that remains fast, expressive, and battle-tested.</p>
<p data-start="5578" data-end="5615">We can’t change the past. But we can:</p>
<ul data-start="5616" data-end="5844">
<li data-start="5616" data-end="5668">
<p data-start="5618" data-end="5668">Acknowledge the emotional and historical baggage</p>
</li>
<li data-start="5669" data-end="5731">
<p data-start="5671" data-end="5731">Celebrate the role Perl played in inventing the modern web</p>
</li>
<li data-start="5732" data-end="5786">
<p data-start="5734" data-end="5786">Educate developers about what Perl really is today</p>
</li>
<li data-start="5787" data-end="5844">
<p data-start="5789" data-end="5844">Push back against the assumption that old == obsolete</p>
</li>
</ul>
<hr data-start="5846" data-end="5849" />
<h2 data-start="5851" data-end="5870"><strong data-start="5854" data-end="5868">Conclusion</strong></h2>
<p data-start="5872" data-end="6127">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.</p>
<p data-start="6129" data-end="6285">But the truth is that modern Perl is thriving quietly in the margins &#8211; maintained by a loyal community, used in production, and capable of great things.</p>
<p data-start="6287" data-end="6451">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.</p>
<p data-start="6453" data-end="6487">Maybe it’s time we looked again.</p><p>The post <a href="https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/">Dotcom Survivor Syndrome – How Perl’s Early Success Created the Seeds of Its Downfall</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/11/dotcom-survivor-syndrome-how-perls-early-success-created-the-seeds-of-its-downfall/feed/</wfw:commentRss>
			<slash:comments>5</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2368</post-id>	</item>
		<item>
		<title>Elderly Camels in the Cloud</title>
		<link>https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/</link>
					<comments>https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Sun, 23 Nov 2025 16:12:23 +0000</pubDate>
				<category><![CDATA[Web]]></category>
		<category><![CDATA[cloud]]></category>
		<category><![CDATA[deployment]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[web]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2364</guid>

					<description><![CDATA[<p>In last week&#8217;s post I showed how to run a modern Dancer2 app on Google Cloud Run. That’s lovely if your codebase already speaks PSGI and lives in a nice, testable, framework-shaped box. But that’s not where a lot of Perl lives. Plenty of useful Perl on the internet is still stuck in old-school CGI [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/">Elderly Camels in the Cloud</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="513" data-end="707">In <a class="decorated-link" href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/" rel="noopener" data-start="516" data-end="537">last week&#8217;s post</a> I showed how to run a <strong data-start="560" data-end="582">modern Dancer2 app</strong> on Google Cloud Run. That’s lovely if your codebase already speaks PSGI and lives in a nice, testable, framework-shaped box.</p>
<p data-start="709" data-end="750">But that’s not where a lot of Perl lives.</p>
<p data-start="752" data-end="939">Plenty of useful Perl on the internet is still stuck in <strong data-start="808" data-end="826">old-school CGI</strong> – the kind of thing you’d drop into <code data-start="863" data-end="872">cgi-bin</code> on a shared host in 2003 and then try not to think about too much.</p>
<p data-start="941" data-end="977">So in this post, I want to show that:</p>
<blockquote data-start="979" data-end="1093">
<p data-start="981" data-end="1093">If you can run a Dancer2 app on Cloud Run, you can also run <strong data-start="1041" data-end="1056">ancient CGI</strong> on Cloud Run – without rewriting it.</p>
</blockquote>
<p data-start="1095" data-end="1248">To keep things on the right side of history, we’ll use <strong data-start="1150" data-end="1166">nms FormMail</strong> rather than Matt Wright’s original script, but the principle is exactly the same.</p>
<hr />
<h2 data-start="1255" data-end="1299">Prerequisites: Google Cloud and Cloud Run</h2>
<p data-start="1301" data-end="1447">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”.</p>
<p data-start="1449" data-end="1485">If not, here’s the minimum you need.</p>
<ol data-start="1487" data-end="2467">
<li data-start="1487" data-end="1618">
<p data-start="1490" data-end="1520"><strong data-start="1490" data-end="1520">Google account and project</strong></p>
<ul data-start="1525" data-end="1618">
<li data-start="1525" data-end="1558">
<p data-start="1527" data-end="1558">Go to the Google Cloud Console.</p>
</li>
<li data-start="1562" data-end="1618">
<p data-start="1564" data-end="1618">Create a new project (e.g. &#8220;perl-cgi-cloud-run-demo&#8221;).</p>
</li>
</ul>
</li>
<li data-start="1620" data-end="1760">
<p data-start="1623" data-end="1641"><strong data-start="1623" data-end="1641">Enable billing</strong></p>
<ul data-start="1646" data-end="1760">
<li data-start="1646" data-end="1760">
<p data-start="1648" data-end="1760">Cloud Run is pay-as-you-go with a generous free tier, but you <strong data-start="1710" data-end="1718">must</strong> attach a billing account to your project.</p>
</li>
</ul>
</li>
<li data-start="1762" data-end="2035">
<p data-start="1765" data-end="1793"><strong data-start="1765" data-end="1793">Install the <code data-start="1779" data-end="1787">gcloud</code> CLI</strong></p>
<ul data-start="1798" data-end="2035">
<li data-start="1798" data-end="1847">
<p data-start="1800" data-end="1847">Install the Google Cloud SDK for your platform.</p>
</li>
<li data-start="1851" data-end="2035">
<p data-start="1853" data-end="1857">Run:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-sh">gcloud init<br />
</code></div>
</div>
<p data-start="1902" data-end="1928">and follow the prompts to:</p>
<ul data-start="1935" data-end="2035">
<li data-start="1935" data-end="1943">
<p data-start="1937" data-end="1943">log in</p>
</li>
<li data-start="1949" data-end="1970">
<p data-start="1951" data-end="1970">select your project</p>
</li>
<li data-start="1976" data-end="2035">
<p data-start="1978" data-end="2035">pick a default region (I’ll assume &#8220;europe-west1&#8221; below).</p>
</li>
</ul>
</li>
</ul>
</li>
<li data-start="2037" data-end="2226">
<p data-start="2040" data-end="2064"><strong data-start="2040" data-end="2064">Enable required APIs</strong></p>
<p data-start="2069" data-end="2085">In your project:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-sh">gcloud services <span class="hljs-built_in">enable</span> \<br />
run.googleapis.com \<br />
artifactregistry.googleapis.com \<br />
c  loudbuild.googleapis.com<br />
</code></div>
</div>
</li>
<li data-start="2228" data-end="2467">
<p data-start="2231" data-end="2282"><strong data-start="2231" data-end="2282">Create a Docker repository in Artifact Registry</strong></p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="sticky top-9"></div>
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-sh">gcloud artifacts repositories create formmail-repo \<br />
--repository-format=docker \<br />
--location=europe-west1 \<br />
--description=<span class="hljs-string">"Docker repo for CGI demos"</span><br />
</code></div>
</div>
</li>
</ol>
<p data-start="2469" data-end="2528">That’s all the GCP groundwork. Now we can worry about Perl.</p>
<hr />
<h2 data-start="2535" data-end="2577">The starting point: an old CGI FormMail</h2>
<p data-start="2579" data-end="2603">Our starting assumption:</p>
<ul data-start="2605" data-end="2795">
<li data-start="2605" data-end="2660">
<p data-start="2607" data-end="2660">You already have a CGI script like <strong data-start="2642" data-end="2658">nms FormMail</strong></p>
</li>
<li data-start="2661" data-end="2726">
<p data-start="2663" data-end="2726">It’s a single &#8220;.pl&#8221; file, intended to be dropped into &#8220;cgi-bin&#8221;</p>
</li>
<li data-start="2727" data-end="2795">
<p data-start="2729" data-end="2795">It expects to be called via the CGI interface and send mail using:</p>
</li>
</ul>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-perl"><span class="hljs-keyword">open</span> <span class="hljs-keyword">my</span> $mail, <span class="hljs-string">'|-'</span>, <span class="hljs-string">'/usr/sbin/sendmail -t'</span><br />
<span class="hljs-keyword">or</span> <span class="hljs-keyword">die</span> <span class="hljs-string">"Can't open sendmail: $!"</span>;<br />
</code></div>
</div>
<p data-start="2893" data-end="2942">On a traditional host, Apache (or similar) would:</p>
<ul data-start="2944" data-end="3109">
<li data-start="2944" data-end="2968">
<p data-start="2946" data-end="2968">parse the HTTP request</p>
</li>
<li data-start="2969" data-end="3041">
<p data-start="2971" data-end="3041">set CGI environment variables (<code data-start="3002" data-end="3018">REQUEST_METHOD</code>, <code data-start="3020" data-end="3034">QUERY_STRING</code>, etc.)</p>
</li>
<li data-start="3042" data-end="3074">
<p data-start="3044" data-end="3074">run <code data-start="3048" data-end="3061">formmail.pl</code> as a process</p>
</li>
<li data-start="3075" data-end="3109">
<p data-start="3077" data-end="3109">let it call <code data-start="3089" data-end="3109">/usr/sbin/sendmail</code></p>
</li>
</ul>
<p data-start="3111" data-end="3156">Cloud Run gives us none of that. It gives us:</p>
<ul data-start="3158" data-end="3231">
<li data-start="3158" data-end="3175">
<p data-start="3160" data-end="3175">a HTTP endpoint</p>
</li>
<li data-start="3176" data-end="3199">
<p data-start="3178" data-end="3199">backed by a container</p>
</li>
<li data-start="3200" data-end="3231">
<p data-start="3202" data-end="3231">listening on a port (<code data-start="3223" data-end="3230">$PORT</code>)</p>
</li>
</ul>
<p data-start="3233" data-end="3313">Our job is to recreate <em data-start="3256" data-end="3269">just enough</em> of that old environment inside a container.</p>
<p data-start="3315" data-end="3349">We’ll do that in two small pieces:</p>
<ol data-start="3351" data-end="3455">
<li data-start="3351" data-end="3391">
<p data-start="3354" data-end="3391">A <strong data-start="3356" data-end="3372">PSGI wrapper</strong> that emulates CGI.</p>
</li>
<li data-start="3392" data-end="3455">
<p data-start="3395" data-end="3455">A <strong data-start="3397" data-end="3414">sendmail shim</strong> so the script can still “talk” sendmail.</p>
</li>
</ol>
<hr />
<h2 data-start="3462" data-end="3494">Architecture in one paragraph</h2>
<p data-start="3496" data-end="3528">Inside the container we’ll have:</p>
<ul data-start="3530" data-end="3836">
<li data-start="3530" data-end="3593">
<p data-start="3532" data-end="3593"><strong data-start="3532" data-end="3548">nms FormMail</strong> – unchanged CGI script at <code data-start="3575" data-end="3593">/app/formmail.pl</code></p>
</li>
<li data-start="3594" data-end="3673">
<p data-start="3596" data-end="3673"><strong data-start="3596" data-end="3612">PSGI wrapper</strong> (<code data-start="3614" data-end="3624">app.psgi</code>) – using <code data-start="3634" data-end="3648">CGI::Compile</code> and <code data-start="3653" data-end="3673">CGI::Emulate::PSGI</code></p>
</li>
<li data-start="3674" data-end="3747">
<p data-start="3676" data-end="3747"><strong data-start="3676" data-end="3693">Plack/Starlet</strong> – a simple HTTP server exposing <code data-start="3726" data-end="3736">app.psgi</code> on <code data-start="3740" data-end="3747">$PORT</code></p>
</li>
<li data-start="3748" data-end="3836">
<p data-start="3750" data-end="3836"><strong data-start="3750" data-end="3763">msmtp-mta</strong> – providing <code data-start="3776" data-end="3796">/usr/sbin/sendmail</code> and relaying mail to a real SMTP server</p>
</li>
</ul>
<p data-start="3838" data-end="3959">Cloud Run just sees “HTTP service running in a container”. Our CGI script still thinks it’s on a early-2000s shared host.</p>
<hr />
<h2 data-start="3966" data-end="4007">Step 1 – Wrapping nms FormMail in PSGI</h2>
<p data-start="4009" data-end="4079">First we write a tiny PSGI wrapper. This is the only new Perl we need:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag"># 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-&gt;compile($cgi_script);

# Wrap it in a PSGI-compatible app
my $app = CGI::Emulate::PSGI-&gt;handler($cgi_app);

# Return PSGI app
$app;</pre><br />
That’s it.</p>
</div>
</div>
<ul data-start="4453" data-end="4681">
<li data-start="4453" data-end="4535">
<p data-start="4455" data-end="4535"><code data-start="4455" data-end="4469">CGI::Compile</code> loads the CGI script and turns its <code data-start="4505" data-end="4511">main</code> package into a coderef.</p>
</li>
<li data-start="4536" data-end="4602">
<p data-start="4538" data-end="4602"><code data-start="4538" data-end="4558">CGI::Emulate::PSGI</code> fakes the CGI environment for each request.</p>
</li>
<li data-start="4603" data-end="4681">
<p data-start="4605" data-end="4681">The CGI script doesn’t know or care that it’s no longer being run by Apache.</p>
</li>
</ul>
<p data-start="4683" data-end="4710">Later, we’ll run this with:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-sh">plackup -s Starlet -p <span class="hljs-variable">${PORT:-8080}</span> app.psgi</code></div>
<div dir="ltr">
<hr />
</div>
</div>
<div dir="ltr">
<h2 data-start="4773" data-end="4807">Step 2 – Adding a sendmail shim</h2>
<p data-start="4809" data-end="4882">Next problem: <strong data-start="4823" data-end="4881">Cloud Run doesn’t give you a local mail transfer agent</strong>.</p>
<p data-start="4884" data-end="4995">There is no real <code data-start="4901" data-end="4921">/usr/sbin/sendmail</code>, and you wouldn’t want to run a full MTA in a stateless container anyway.</p>
<p data-start="4997" data-end="5238">Instead, we’ll install <strong data-start="5020" data-end="5033">msmtp-mta</strong>, a light-weight SMTP client that includes a <strong data-start="5078" data-end="5109">sendmail-compatible wrapper</strong>. It gives you a <code data-start="5126" data-end="5146">/usr/sbin/sendmail</code> binary that forwards mail to a remote SMTP server (Mailgun, SES, your mail provider, etc.).</p>
<p data-start="5240" data-end="5293">From the CGI script’s point of view, nothing changes:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">open my $mail, '|-', '/usr/sbin/sendmail -t'
  or die "Can't open sendmail: $!";
# ... write headers and body ...
close $mail;</pre><br />
Under the hood, msmtp ships it off to your configured SMTP server.</p>
</div>
</div>
<p data-start="5505" data-end="5638">We’ll configure msmtp from environment variables at <strong data-start="5557" data-end="5579">container start-up</strong>, so Cloud Run’s <code data-start="5596" data-end="5612">--set-env-vars</code> values are actually used.</p>
<h2 data-start="5645" data-end="5715">Step 3 – Dockerfile (+ entrypoint) for Perl, PSGI and sendmail shim</h2>
<p data-start="5717" data-end="5771">Here’s a complete Dockerfile that pulls this together.</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">FROM perl:5.40

# Install msmtp-mta as a sendmail-compatible shim
RUN apt-get update &amp;&amp; \
    apt-get install -y --no-install-recommends msmtp-mta ca-certificates &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"]</pre><br />
And here’s the <code data-start="6534" data-end="6556">docker-entrypoint.sh</code> script:</p>
</div>
</div>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">#!/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." &gt;&amp;2
fi

cat &gt; /etc/msmtprc &lt;&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</pre><br />
Key points you might want to note:</p>
</div>
</div>
<ul data-start="7389" data-end="7656">
<li data-start="7389" data-end="7459">
<p data-start="7391" data-end="7459">We <strong data-start="7394" data-end="7423">never touch <code data-start="7408" data-end="7421">formmail.pl</code></strong>. It goes into <code data-start="7438" data-end="7444">/app</code> and that’s it.</p>
</li>
<li data-start="7460" data-end="7549">
<p data-start="7462" data-end="7549">msmtp gives us <code data-start="7477" data-end="7497">/usr/sbin/sendmail</code>, so the CGI script stays in its 1990s comfort zone.</p>
</li>
<li data-start="7550" data-end="7656">
<p data-start="7552" data-end="7656">The entrypoint writes <code data-start="7574" data-end="7588">/etc/msmtprc</code> at runtime, so Cloud Run’s environment variables are actually used.</p>
</li>
</ul>
<hr />
<h2 data-start="7663" data-end="7705">Step 4 – Building and pushing the image</h2>
<p data-start="7707" data-end="7817">With the Dockerfile and <code data-start="7731" data-end="7753">docker-entrypoint.sh</code> in place, we can build and push the image to Artifact Registry.</p>
<p data-start="7819" data-end="7831">I’ll assume:</p>
<ul data-start="7833" data-end="7943">
<li data-start="7833" data-end="7859">
<p data-start="7835" data-end="7859">Project ID: <code data-start="7847" data-end="7859">PROJECT_ID</code></p>
</li>
<li data-start="7860" data-end="7884">
<p data-start="7862" data-end="7884">Region: <code data-start="7870" data-end="7884">europe-west1</code></p>
</li>
<li data-start="7885" data-end="7914">
<p data-start="7887" data-end="7914">Repository: <code data-start="7899" data-end="7914">formmail-repo</code></p>
</li>
<li data-start="7915" data-end="7943">
<p data-start="7917" data-end="7943">Image name: <code data-start="7929" data-end="7943">nms-formmail</code></p>
</li>
</ul>
<p data-start="7945" data-end="7980">First, build the image <strong data-start="7968" data-end="7979">locally</strong>:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">docker build -t europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest .</pre><br />
Then configure Docker to authenticate against Artifact Registry:</p>
</div>
</div>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">gcloud auth configure-docker europe-west1-docker.pkg.dev</pre><br />
Now push the image:</p>
</div>
</div>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">docker push europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest</pre><br />
If you’d rather not install Docker locally, you can let Google Cloud Build do this for you:</p>
</div>
</div>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">gcloud builds submit \
  --tag europe-west1-docker.pkg.dev/PROJECT_ID/formmail-repo/nms-formmail:latest</pre><br />
Use whichever workflow your team is happier with; Cloud Run doesn’t care how the image got there.</p>
<hr />
<h2 data-start="8647" data-end="8681">Step 5 – Deploying to Cloud Run</h2>
<p data-start="8683" data-end="8737">Now we can create a Cloud Run service from that image.</p>
<p data-start="8739" data-end="8873">You’ll need SMTP settings from somewhere (Mailgun, SES, your mail provider). I’ll use “Mailgun-ish” examples here; adjust as required.</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">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</pre><br />
Cloud Run will give you a HTTPS URL, something like:</p>
</div>
</div>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-text">https://nms-formmail-abcdefgh-uk.a.run.app<br />
</code></div>
</div>
<p data-start="9430" data-end="9501">Your HTML form (on whatever website you like) can now post to that URL.</p>
<p data-start="9503" data-end="9515">For example:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">&lt;form action="https://nms-formmail-abcdefgh-uk.a.run.app/formmail.pl" method="post"&gt;
  &lt;input type="hidden" name="recipient" value="contact@example.com"&gt;
  &lt;input type="email" name="email" required&gt;
  &lt;textarea name="comments" required&gt;&lt;/textarea&gt;
  &lt;button type="submit"&gt;Send&lt;/button&gt;
&lt;/form&gt;</pre><br />
Depending on how you wire the routes, you may also just post to <code data-start="9888" data-end="9891">/</code> – the important point is that the request hits the PSGI app, which faithfully re-creates the CGI environment and hands control to <code data-start="10022" data-end="10035">formmail.pl</code>.</p>
<hr />
<h2 data-start="10043" data-end="10079">How much work did we actually do?</h2>
<p data-start="10081" data-end="10164">Compared to <a href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/">the Dancer2 example</a>, the interesting bit here is what we <strong data-start="10150" data-end="10160">didn’t</strong> do:</p>
<ul data-start="10166" data-end="10279">
<li data-start="10166" data-end="10209">
<p data-start="10168" data-end="10209">We didn’t convert the CGI script to PSGI.</p>
</li>
<li data-start="10210" data-end="10238">
<p data-start="10212" data-end="10238">We didn’t add a framework.</p>
</li>
<li data-start="10239" data-end="10279">
<p data-start="10241" data-end="10279">We didn’t touch its mail-sending code.</p>
</li>
</ul>
<p data-start="10281" data-end="10289">We just:</p>
<ol data-start="10291" data-end="10464">
<li data-start="10291" data-end="10331">
<p data-start="10294" data-end="10331">Wrapped it with <code data-start="10310" data-end="10330">CGI::Emulate::PSGI</code>.</p>
</li>
<li data-start="10332" data-end="10391">
<p data-start="10335" data-end="10391">Dropped a sendmail shim in front of a real SMTP service.</p>
</li>
<li data-start="10392" data-end="10464">
<p data-start="10395" data-end="10464">Put it in a container and let Cloud Run handle the scaling and HTTPS.</p>
</li>
</ol>
<p data-start="10466" data-end="10562">If you’ve still got a cupboard full of old CGI scripts doing useful work, this is a nice way to:</p>
<ul data-start="10564" data-end="10742">
<li data-start="10564" data-end="10601">
<p data-start="10566" data-end="10601">get them off fragile shared hosting</p>
</li>
<li data-start="10602" data-end="10625">
<p data-start="10604" data-end="10625">put them behind HTTPS</p>
</li>
<li data-start="10626" data-end="10690">
<p data-start="10628" data-end="10690">run them in an environment you understand (Docker + Cloud Run)</p>
</li>
<li data-start="10691" data-end="10742">
<p data-start="10693" data-end="10742">without having to justify a full rewrite up front</p>
</li>
</ul>
<hr />
<h2 data-start="10749" data-end="10784">When should you rewrite instead?</h2>
<p data-start="10786" data-end="10835">This trick is handy, but it’s not a time machine.</p>
<p data-start="10837" data-end="10869">If you find yourself wanting to:</p>
<ul data-start="10871" data-end="11028">
<li data-start="10871" data-end="10884">
<p data-start="10873" data-end="10884">add tests</p>
</li>
<li data-start="10885" data-end="10925">
<p data-start="10887" data-end="10925">share logic between multiple scripts</p>
</li>
<li data-start="10926" data-end="10964">
<p data-start="10928" data-end="10964">integrate with a modern app or API</p>
</li>
<li data-start="10965" data-end="11028">
<p data-start="10967" data-end="11028">do anything more complex than “receive a form, send an email”</p>
</li>
</ul>
<p data-start="11030" data-end="11124">…then you probably <em data-start="11049" data-end="11053">do</em> want to migrate the logic into a Dancer2 (or other PSGI) app properly.</p>
<p data-start="11126" data-end="11260">But as a <strong data-start="11135" data-end="11149">first step</strong> – or as a way to de-risk moving away from legacy hosting – wrapping CGI for Cloud Run works surprisingly well.</p>
<hr />
<h2 data-start="68" data-end="108">FormMail is still probably a bad idea</h2>
<p data-start="110" data-end="269">All of this proves that you <em data-start="138" data-end="143">can</em> take a very old CGI script and run it happily on Cloud Run. It does <strong data-start="212" data-end="219">not</strong> magically turn FormMail into a good idea in 2025.</p>
<p data-start="271" data-end="301">The usual caveats still apply:</p>
<ul data-start="303" data-end="1066">
<li data-start="303" data-end="510">
<p data-start="305" data-end="510"><strong data-start="305" data-end="323">Spam and abuse</strong> – 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.</p>
</li>
<li data-start="511" data-end="791">
<p data-start="513" data-end="791"><strong data-start="513" data-end="544">Validation and sanitisation</strong> – 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.</p>
</li>
<li data-start="792" data-end="1066">
<p data-start="794" data-end="1066"><strong data-start="794" data-end="817">Better alternatives</strong> – 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.</p>
</li>
</ul>
<p data-start="1068" data-end="1163">So think of this pattern as a <strong data-start="1098" data-end="1108">bridge</strong> for legacy, not a template for greenfield development.</p>
<hr data-start="1165" data-end="1168" />
<h2 data-start="1170" data-end="1183">Conclusion</h2>
<p data-start="1185" data-end="1491">In the <a href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/">previous post</a> 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.</p>
<p data-start="1493" data-end="1743">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.</p>
<p data-start="1745" data-end="2141">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.</p>
</div>
</div>
</div>
</div>
</div><p>The post <a href="https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/">Elderly Camels in the Cloud</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/11/elderly-camels-in-the-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2364</post-id>	</item>
		<item>
		<title>Dancing in the Clouds: Moving Dancer2 Apps from a VPS to Cloud Run</title>
		<link>https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/</link>
					<comments>https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Thu, 13 Nov 2025 16:48:00 +0000</pubDate>
				<category><![CDATA[Web]]></category>
		<category><![CDATA[cloud]]></category>
		<category><![CDATA[deployment]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[web]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2358</guid>

					<description><![CDATA[<p>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. In fact, over the last eighteen months or so, I wrote a series of blog posts explaining how I developed a system [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/">Dancing in the Clouds: Moving Dancer2 Apps from a VPS to Cloud Run</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="283" data-end="451">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.</p>
<p data-start="283" data-end="451">In fact, over the last eighteen months or so, I wrote a series of blog posts explaining how I developed <a href="https://perlhacks.com/2024/05/deploying-dancer-apps/">a system for deploying</a> <a href="https://perlhacks.com/2024/08/deploying-dancer-apps-addendum/">Dancer2 apps</a> and, eventually, <a href="https://perlhacks.com/2025/05/deploying-dancer-apps-the-next-generation/">controlling them using systemd</a>. I&#8217;m slightly embarrassed by those posts now.</p>
<p data-start="453" data-end="652">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.</p>
<p data-start="453" data-end="652">Back in 2019, I started <span style="box-sizing: border-box; margin: 0px; padding: 0px;">writing a series of blog posts called <a href="https://dev.to/davorg/moving-into-the-cloud-4p4" target="_blank" rel="noopener">Into the Cloud</a> that would follow my progress as I moved all </span>my apps into Docker containers. But real life intruded and I never made much progress on the project.</p>
<p data-start="654" data-end="904">Recently, I returned to this idea (yes, I&#8217;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.</p>
<p data-start="906" data-end="1044">This post walks through how I made the jump &#8211; and how you can too &#8211; using <strong data-start="980" data-end="1043">Perl, Dancer2, Docker, GitHub Actions, and Google Cloud Run</strong>.</p>
<hr data-start="1046" data-end="1049" />
<h2 data-start="1051" data-end="1083"><strong data-start="1054" data-end="1083">Why move away from a VPS?</strong></h2>
<p data-start="1085" data-end="1247">Running everything on a single VPS used to make sense. You could <code data-start="1150" data-end="1155">ssh</code> in, restart services, and feel like you were in control. But over time, the drawbacks grow:</p>
<ul data-start="1249" data-end="1521">
<li data-start="1249" data-end="1303">
<p data-start="1251" data-end="1303">You have to maintain the OS and packages yourself.</p>
</li>
<li data-start="1304" data-end="1362">
<p data-start="1306" data-end="1362">One bad app or memory leak can affect everything else.</p>
</li>
<li data-start="1363" data-end="1437">
<p data-start="1365" data-end="1437">You’re paying for full-time CPU and RAM even when nothing’s happening.</p>
</li>
<li data-start="1438" data-end="1521">
<p data-start="1440" data-end="1521">Scaling means provisioning a new server — not something you do in a coffee break.</p>
</li>
</ul>
<p data-start="1523" data-end="1708">Cloud Run, on the other hand, runs each app as a container and <strong data-start="1586" data-end="1638">only charges you while requests are being served</strong>. When no-one’s using your app, it scales to zero and costs nothing.</p>
<p data-start="1710" data-end="1825">Even better: no servers to patch, no ports to open, no SSL certificates to renew — Google does all of that for you.</p>
<hr data-start="1827" data-end="1830" />
<h2 data-start="1832" data-end="1855"><strong data-start="1835" data-end="1855">What we’ll build</strong></h2>
<p data-start="1857" data-end="1914">Here’s the plan. We’ll take a simple <strong data-start="1894" data-end="1905">Dancer2</strong> app and:</p>
<ol data-start="1916" data-end="2178">
<li data-start="1916" data-end="1958">
<p data-start="1919" data-end="1958">Package it as a <strong data-start="1935" data-end="1955">Docker container</strong>.</p>
</li>
<li data-start="1959" data-end="2021">
<p data-start="1962" data-end="2021">Build that container automatically in <strong data-start="2000" data-end="2018">GitHub Actions</strong>.</p>
</li>
<li data-start="2022" data-end="2110">
<p data-start="2025" data-end="2110">Deploy it to <strong data-start="2038" data-end="2058">Google Cloud Run</strong>, where it runs securely and scales automatically.</p>
</li>
<li data-start="2111" data-end="2178">
<p data-start="2114" data-end="2178">Map a custom domain to it and forget about server admin forever.</p>
</li>
</ol>
<p data-start="2180" data-end="2284">If you’ve never touched Docker or Cloud Run before, don’t worry &#8211; I’ll explain what’s going on as we go.</p>
<hr data-start="2286" data-end="2289" />
<h2 data-start="2291" data-end="2339"><strong data-start="2294" data-end="2339">Why Cloud Run fits Perl surprisingly well</strong></h2>
<p data-start="2341" data-end="2566">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.</p>
<p data-start="2568" data-end="2719">Cloud Run runs those containers on demand. It’s effectively a managed <code data-start="2638" data-end="2647">starman</code> farm where Google handles the hard parts &#8211; scaling, routing, and HTTPS.</p>
<p data-start="2721" data-end="2853">You pay for CPU and memory per request, not per server. For small or moderate-traffic Perl apps, it’s often <strong data-start="2829" data-end="2852">well under £1/month</strong>.</p>
<hr data-start="2855" data-end="2858" />
<h2 data-start="2860" data-end="2900"><strong data-start="2863" data-end="2900">Step 1: Dockerising a Dancer2 app</strong></h2>
<p data-start="2902" data-end="3126">If you’re new to Docker, think of it as a way of bundling <em data-start="2960" data-end="2984">your whole environment</em> — Perl, modules, and configuration — into a portable image. It’s like freezing a working copy of your app so it can run identically anywhere.</p>
<p data-start="3128" data-end="3176">Here’s a minimal <code data-start="3145" data-end="3157">Dockerfile</code> for a Dancer2 app:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">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"]</pre><br />
Let’s break that down:</p>
</div>
</div>
<ul data-start="3526" data-end="3896">
<li data-start="3526" data-end="3598">
<p data-start="3528" data-end="3598"><code data-start="3528" data-end="3544">FROM perl:5.42</code> — starts from an official Perl image on Docker Hub.</p>
</li>
<li data-start="3599" data-end="3663">
<p data-start="3601" data-end="3663"><code data-start="3601" data-end="3609">Carton</code> keeps dependencies consistent between environments.</p>
</li>
<li data-start="3664" data-end="3785">
<p data-start="3666" data-end="3785">The app is copied into <code data-start="3689" data-end="3695">/app</code>, and <code data-start="3701" data-end="3730">carton install --deployment</code> installs exactly what’s in your <code data-start="3763" data-end="3782">cpanfile.snapshot</code>.</p>
</li>
<li data-start="3786" data-end="3844">
<p data-start="3788" data-end="3844">The container exposes port 8080 (Cloud Run’s default).</p>
</li>
<li data-start="3845" data-end="3896">
<p data-start="3847" data-end="3896">The <code data-start="3851" data-end="3856">CMD</code> runs Starman, serving your Dancer2 app.</p>
</li>
</ul>
<p data-start="3898" data-end="3917">To test it locally:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-bash">docker build -t myapp .<br />
docker run -p 8080:8080 myapp<br />
</code></div>
</div>
<p data-start="3986" data-end="4122">Then visit <a class="decorated-link cursor-pointer" target="_new" rel="noopener" data-start="3997" data-end="4043">http://localhost:8080</a>. If you see your Dancer2 homepage, you’ve successfully containerised your app.</p>
<hr data-start="4124" data-end="4127" />
<h2 data-start="4129" data-end="4180"><strong data-start="4132" data-end="4180">Step 2: Building the image in GitHub Actions</strong></h2>
<p data-start="4182" data-end="4346">Once it works locally, we can automate it. GitHub Actions will build and push our image to <strong data-start="4273" data-end="4301">Google Artifact Registry</strong> whenever we push to <code data-start="4322" data-end="4328">main</code> or tag a release.</p>
<p data-start="4348" data-end="4414">Here’s a simplified workflow file (<code data-start="4383" data-end="4412">.github/workflows/build.yml</code>):</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">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</pre></p>
<p data-start="197" data-end="532">You’ll notice a few secrets referenced in the workflow — things like your Google Cloud project ID and credentials. These are stored <strong data-start="329" data-end="359">securely in GitHub Actions</strong>. 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.</p>
<p data-start="534" data-end="658">You only set those secrets up once, and they’re encrypted and hidden from everyone else — even if your repository is public.</p>
</div>
</div>
<p data-start="5284" data-end="5357">Once that’s set up, every push builds a fresh, versioned container image.</p>
<hr data-start="5359" data-end="5362" />
<h2 data-start="5364" data-end="5401"><strong data-start="5367" data-end="5401">Step 3: Deploying to Cloud Run</strong></h2>
<p data-start="5403" data-end="5495">Now we’re ready to run it in the cloud. We&#8217;ll do that using Google&#8217;s command line program, <code class="whitespace-pre! language-bash">gcloud</code>. It’s available from <a class="decorated-link" href="https://cloud.google.com/sdk/docs/install" target="_new" rel="noopener" data-start="389" data-end="461">Google’s official downloads</a> or through most Linux package managers — for example:</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag"># Fedora, RedHat or similar
sudo dnf install google-cloud-cli
# or on Debian/Ubuntu:
sudo apt install google-cloud-cli</pre><p></p>
<p data-start="621" data-end="678">Once installed, authenticate it with your Google account:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">gcloud auth login
gcloud config set project your-project-id</pre><br />
That links the CLI to your Google Cloud project and lets it perform actions like deploying to Cloud Run.</p>
</div>
</div>
<p data-start="5403" data-end="5495">Once that&#8217;s done, you can deploy manually from the command line:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr"><code class="whitespace-pre! language-bash">gcloud run deploy myapp \<br />
--image=europe-west1-docker.pkg.dev/MY_PROJECT/containers/myapp:<span class="hljs-variable">$GITHUB_SHA</span> \<br />
--region=europe-west1 \<br />
--allow-unauthenticated \<br />
--port=8080<br />
</code></div>
</div>
<p data-start="5684" data-end="5776">This tells Cloud Run to start a new service called <code data-start="5735" data-end="5742">myapp</code>, using the image we just built.</p>
<p data-start="5778" data-end="5845">After a minute or two, Google will give you a live HTTPS URL, like:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<ul>
<li style="list-style-type: none;">
<ul>
<li class="overflow-y-auto p-4" dir="ltr">https://myapp-abcdef12345-ew.a.run.app</li>
</ul>
</li>
</ul>
</div>
<p data-start="5895" data-end="5995">Visit it — and if all went well, you’ll see your familiar Dancer2 app, running happily on Cloud Run.</p>
<p data-start="5997" data-end="6029">To connect your own domain, run:</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">gcloud run domain-mappings create \
--service=myapp \
--domain=myapp.example.com</pre><p>Then update your DNS records as instructed. Within an hour or so, Cloud Run will issue a free SSL certificate for you.</p>
<hr data-start="6249" data-end="6252" />
<h2 data-start="6254" data-end="6294"><strong data-start="6257" data-end="6294">Step 4: Automating the deployment</strong></h2>
<p data-start="6296" data-end="6349">Once the manual deployment works, we can automate it too.</p>
<p data-start="6351" data-end="6445">Here’s a second GitHub Actions workflow (<code data-start="6392" data-end="6404">deploy.yml</code>) that triggers after a successful build:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">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</pre><br />
Now every successful push to <code data-start="7252" data-end="7258">main</code> results in an automatic deployment to production.</p>
</div>
</div>
<p data-start="7312" data-end="7503">You can take it further by splitting environments — e.g. <code data-start="7369" data-end="7375">main</code> deploys to staging, tagged releases to production — but even this simple setup is a big step forward from <code data-start="7482" data-end="7487">ssh</code> and <code data-start="7492" data-end="7502">git pull</code>.</p>
<hr data-start="7505" data-end="7508" />
<h2 data-start="7510" data-end="7564"><strong data-start="7513" data-end="7564">Step 5: Environment variables and configuration</strong></h2>
<p data-start="7566" data-end="7675">Each Cloud Run service can have its own configuration and secrets. You can set these from the console or CLI:</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">gcloud run services update myapp \
  --set-env-vars="DANCER_ENV=production,DATABASE_URL=postgres://..."</pre><p></p>
<p data-start="7794" data-end="7845">In your Dancer2 app, you can then access them with:</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ENV{DATABASE_URL}</pre><p></p>
<p data-start="7879" data-end="7996">It’s a good idea to keep database credentials and API keys out of your code and inject them at deploy time like this.</p>
<hr data-start="7998" data-end="8001" />
<h2 data-start="8003" data-end="8037"><strong data-start="8006" data-end="8037">Step 6: Monitoring and logs</strong></h2>
<p data-start="8039" data-end="8103">Cloud Run integrates neatly with Google Cloud’s logging tools.</p>
<p data-start="8105" data-end="8138">To see recent logs from your app:</p>
<div class="contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary">
<div class="overflow-y-auto p-4" dir="ltr">
<pre class="urvanov-syntax-highlighter-plain-tag">gcloud logs read --project=$PROJECT_NAME --service=myapp</pre><br />
You’ll see your Dancer2 <code data-start="8234" data-end="8240">warn</code> and <code data-start="8245" data-end="8250">die</code> messages there too, because STDOUT and STDERR are automatically captured.</p>
</div>
</div>
<p data-start="8326" data-end="8424">If you prefer a UI, you can use the Cloud Console’s Log Explorer to filter by service or severity.</p>
<hr data-start="8426" data-end="8429" />
<h2 data-start="8431" data-end="8456"><strong data-start="8434" data-end="8456">Step 7: The payoff</strong></h2>
<p data-start="8458" data-end="8545">Once you’ve done one migration, the next becomes almost trivial. Each Dancer2 app gets:</p>
<ul data-start="8547" data-end="8664">
<li data-start="8547" data-end="8591">
<p data-start="8549" data-end="8591">Its own Dockerfile and GitHub workflows.</p>
</li>
<li data-start="8592" data-end="8633">
<p data-start="8594" data-end="8633">Its own Cloud Run service and domain.</p>
</li>
<li data-start="8634" data-end="8664">
<p data-start="8636" data-end="8664">Its own scaling and logging.</p>
</li>
</ul>
<p data-start="8666" data-end="8726">And none of them share a single byte of RAM with each other.</p>
<p data-start="8728" data-end="8763">Here’s how the experience compares:</p>
<div class="_tableContainer_1rjym_1">
<div class="group _tableWrapper_1rjym_13 flex w-fit flex-col-reverse" tabindex="-1">
<table class="w-fit min-w-(--thread-content-width)" data-start="8765" data-end="9110">
<thead data-start="8765" data-end="8797">
<tr data-start="8765" data-end="8797">
<th data-start="8765" data-end="8774" data-col-size="sm">Aspect</th>
<th data-start="8774" data-end="8784" data-col-size="sm">Old VPS</th>
<th data-start="8784" data-end="8797" data-col-size="sm">Cloud Run</th>
</tr>
</thead>
<tbody data-start="8832" data-end="9110">
<tr data-start="8832" data-end="8878">
<td data-start="8832" data-end="8849" data-col-size="sm">OS maintenance</td>
<td data-col-size="sm" data-start="8849" data-end="8867">Manual upgrades</td>
<td data-col-size="sm" data-start="8867" data-end="8878">Managed</td>
</tr>
<tr data-start="8879" data-end="8915">
<td data-start="8879" data-end="8889" data-col-size="sm">Scaling</td>
<td data-col-size="sm" data-start="8889" data-end="8902">Fixed size</td>
<td data-col-size="sm" data-start="8902" data-end="8915">Automatic</td>
</tr>
<tr data-start="8916" data-end="8960">
<td data-start="8916" data-end="8922" data-col-size="sm">SSL</td>
<td data-col-size="sm" data-start="8922" data-end="8947">Let’s Encrypt renewals</td>
<td data-col-size="sm" data-start="8947" data-end="8960">Automatic</td>
</tr>
<tr data-start="8961" data-end="9009">
<td data-start="8961" data-end="8974" data-col-size="sm">Deployment</td>
<td data-col-size="sm" data-start="8974" data-end="8991">SSH + git pull</td>
<td data-col-size="sm" data-start="8991" data-end="9009">Push to GitHub</td>
</tr>
<tr data-start="9010" data-end="9052">
<td data-start="9010" data-end="9017" data-col-size="sm">Cost</td>
<td data-start="9017" data-end="9033" data-col-size="sm">Fixed monthly</td>
<td data-col-size="sm" data-start="9033" data-end="9052">Pay-per-request</td>
</tr>
<tr data-start="9053" data-end="9110">
<td data-start="9053" data-end="9069" data-col-size="sm">Downtime risk</td>
<td data-col-size="sm" data-start="9069" data-end="9093">One app can crash all</td>
<td data-col-size="sm" data-start="9093" data-end="9110">Each isolated</td>
</tr>
</tbody>
</table>
</div>
</div>
<p data-start="9112" data-end="9243">For small apps with light traffic, Cloud Run often costs <strong data-start="9169" data-end="9190">pennies per month</strong> &#8211; less than the price of a coffee for peace of mind.</p>
<hr data-start="9245" data-end="9248" />
<h2 data-start="9250" data-end="9272"><strong data-start="9253" data-end="9272">Lessons learned</strong></h2>
<p data-start="9274" data-end="9321">After a few migrations, a few patterns emerged:</p>
<ul data-start="9323" data-end="9868">
<li data-start="9323" data-end="9426">
<p data-start="9325" data-end="9426"><strong data-start="9325" data-end="9354">Keep apps self-contained.</strong> Don’t share config or code across services; treat each app as a unit.</p>
</li>
<li data-start="9427" data-end="9538">
<p data-start="9429" data-end="9538"><strong data-start="9429" data-end="9458">Use digest-based deploys.</strong> Deploy by image digest (<code data-start="9483" data-end="9496">@sha256:...</code>) rather than tag for true immutability.</p>
</li>
<li data-start="9539" data-end="9636">
<p data-start="9541" data-end="9636"><strong data-start="9541" data-end="9566">Logs are your friend.</strong> Cloud Run’s logs are rich; you rarely need to <code data-start="9613" data-end="9618">ssh</code> anywhere again.</p>
</li>
<li data-start="9637" data-end="9779">
<p data-start="9639" data-end="9779"><strong data-start="9639" data-end="9679">Cold starts exist, but aren’t scary.</strong> If your app is infrequently used, expect the first request after a while to take a second longer.</p>
</li>
<li data-start="9780" data-end="9868">
<p data-start="9782" data-end="9868"><strong data-start="9782" data-end="9806">CI/CD is liberating.</strong> Once the pipeline’s in place, deployment becomes a non-event.</p>
</li>
</ul>
<hr data-start="9870" data-end="9873" />
<h2 data-start="9875" data-end="9906"><strong data-start="9878" data-end="9906">Costs and practicalities</strong></h2>
<p data-start="9908" data-end="10113">One of the most pleasant surprises was the cost. My smallest Dancer2 app, which only gets a handful of requests each day, usually costs <strong data-start="10040" data-end="10061">under £0.50/month</strong> on Cloud Run. Heavier ones rarely top a few pounds.</p>
<p data-start="10115" data-end="10284">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.</p>
<hr data-start="10286" data-end="10289" />
<h2 data-start="10291" data-end="10309"><strong data-start="10294" data-end="10309">What’s next</strong></h2>
<p data-start="10311" data-end="10424">This post covers the essentials: containerising a Dancer2 app and deploying it to Cloud Run via GitHub Actions.</p>
<p data-start="10426" data-end="10459">In future articles, I’ll look at:</p>
<ul data-start="10461" data-end="10685">
<li data-start="10461" data-end="10518">
<p data-start="10463" data-end="10518">Connecting to persistent databases.</p>
</li>
<li data-start="10519" data-end="10564">
<p data-start="10521" data-end="10564">Using caching.</p>
</li>
<li data-start="10565" data-end="10634">
<p data-start="10567" data-end="10634">Adding monitoring and dashboards.</p>
</li>
<li data-start="10635" data-end="10685">
<p data-start="10637" data-end="10685">Managing secrets with <strong data-start="10659" data-end="10684">Google Secret Manager</strong>.</p>
</li>
</ul>
<hr data-start="10687" data-end="10690" />
<h2 data-start="10692" data-end="10709"><strong data-start="10695" data-end="10709">Conclusion</strong></h2>
<p data-start="10711" data-end="10838">After two decades of running Perl web apps on traditional servers, Cloud Run feels like the future has finally caught up with me.</p>
<p data-start="10840" data-end="11034">You still get to write your code in Dancer2 &#8211; the framework that’s made Perl web development fun for years &#8211; but you deploy it in a way that’s modern, repeatable, and blissfully low-maintenance.</p>
<p data-start="11036" data-end="11130">No more patching kernels. No more 3 a.m. alerts. Just code, commit, and dance in the clouds.</p><p>The post <a href="https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/">Dancing in the Clouds: Moving Dancer2 Apps from a VPS to Cloud Run</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/11/dancing-in-the-clouds-moving-dancer2-apps-from-a-vps-to-cloud-run/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2358</post-id>	</item>
		<item>
		<title>Easy SEO for lazy programmers</title>
		<link>https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/</link>
					<comments>https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/#respond</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Wed, 24 Sep 2025 10:22:53 +0000</pubDate>
				<category><![CDATA[Web]]></category>
		<category><![CDATA[opengraph]]></category>
		<category><![CDATA[perl]]></category>
		<category><![CDATA[seo]]></category>
		<category><![CDATA[websites]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2352</guid>

					<description><![CDATA[<p>A few of my recent projects—like Cooking Vinyl Compilations and ReadABooker—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. As I lined up [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/">Easy SEO for lazy programmers</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>A few of my recent projects—like <a href="https://cookingvinyl.dave.org.uk/"><strong>Cooking Vinyl Compilations</strong></a> and <a href="https://readabooker.com/"><strong>ReadABooker</strong></a>—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.</p>
<p>As I lined up a couple more sites in the same vein, I noticed I was writing <em>very</em> similar code again and again: take an object with <code>title</code>, <code>url</code>, <code>image</code>, <code>description</code>, and spray out the right <code>&lt;meta&gt;</code> 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—<a href="https://metacpan.org/pod/MooX::Role::SEOTa"><strong><code>MooX::Role::SEOTags</code></strong></a>—that any page-ish class can consume and just emit the right tags.</p>
<h2>What are these tags and why should you care?</h2>
<p>When someone shares your page, platforms read a handful of standardised tags to decide what to show in the preview:</p>
<ul>
<li><strong>Open Graph (<code>og:*</code>)</strong> — The de-facto standard for title, description, URL, image, and type. Used by Facebook, WhatsApp, Slack, iMessage and others.</li>
<li><strong>Twitter Cards (<code>twitter:*</code>)</strong> — Similar idea for Twitter/X; the common pattern is <code>twitter:card=summary_large_image</code> plus title/description/image.</li>
<li><strong>Classic SEO tags</strong> — <code>&lt;title&gt;</code>, <code>&lt;meta name="description"&gt;</code>, and a canonical URL tell search engines what the page is <em>about</em> and which URL is the “official” one.</li>
</ul>
<p>MooX::Role::SEOTags gives you one method that renders all of that, consistently, from your object’s attributes.</p>
<p>For more information about OpenGraph, see <a href="https://ogp.me/">ogp.me</a>.</p>
<h2>What it does</h2>
<p><code>MooX::Role::SEOTags</code> 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.</p>
<ul>
<li>Open Graph tags (<code>og:title</code>, <code>og:type</code>, <code>og:url</code>, <code>og:image</code>, etc.)</li>
<li>Twitter Card tags (<code>twitter:card</code>, <code>twitter:title</code>, <code>twitter:description</code>, <code>twitter:image</code>)</li>
<li>Standard SEO: <code>&lt;title&gt;</code>, meta <code>description</code>, canonical <code>&lt;link rel="canonical"&gt;</code></li>
<li>A single method to render the whole block with one call</li>
<li>But also individual methods to give you more control over tag placement</li>
</ul>
<p>That’s the whole job: define attributes → get valid tags out.</p>
<h2>Quick start</h2>
<p>Install the role using your favourite CPAN module installation tool.</p><pre class="urvanov-syntax-highlighter-plain-tag">cpanm MooX::Role::SEOTags</pre><p>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 &#8211; <code>og_title</code>, <code>og_description</code>, <code>og_url</code> and <code>og_type</code> &#8211; and <code>og_image</code> is optional (but highly recommended).</p>
<p>So a simple class might look like this:</p><pre class="urvanov-syntax-highlighter-plain-tag">package MyPage;
use Moo;
with 'MooX::Role::SEOTags';

# minimal OG fields
has og_title      =&gt; (is =&gt; 'ro', required =&gt; 1);
has og_type       =&gt; (is =&gt; 'ro', required =&gt; 1);   # e.g. 'article'
has og_url         =&gt; (is =&gt; 'ro', required =&gt; 1);
has og_description =&gt; (is =&gt; 'ro');

# optional niceties
has og_image        =&gt; (is =&gt; 'ro');           # absolute URL
has twitter_card    =&gt; (is =&gt; 'ro', default =&gt; sub { 'summary_large_image' });

1;</pre><p>And then you create the object:</p><pre class="urvanov-syntax-highlighter-plain-tag">my $page = MyPage-&gt;new(
  og_title       =&gt; 'How to Title a Title',
  og_type        =&gt; 'article',
  og_url         =&gt; 'https://example.com/post/title',
  og_image       =&gt; 'https://example.com/img/hero.jpg',
  og_description =&gt; 'A short, human description of the page.',
);</pre><p>Then you can call the various <code>*_tag</code> and <code>*_tags</code> methods to get the correct HTML for the various tags.</p>
<p>The easiest option is to just produce all of the tags in one go:</p><pre class="urvanov-syntax-highlighter-plain-tag">say $page-&gt;tags;</pre><p>But, for more control, you can call individual methods:</p><pre class="urvanov-syntax-highlighter-plain-tag">say $page-&gt;title_tag;
say $page-&gt;canonical_tag;
say $page-&gt;og_tags;
# etc...</pre><p>Depending on which combination of method calls you use, the output will look something like this:</p><pre class="urvanov-syntax-highlighter-plain-tag">&lt;title&gt;How to Title a Title&lt;/title&gt;
&lt;meta name="description" content="A short, human description of the page."&gt;
&lt;link rel="canonical" href="https://example.com/post/title"&gt;

&lt;meta property="og:title" content="How to Title a Title"&gt;
&lt;meta property="og:type"  content="article"&gt;
&lt;meta property="og:url"   content="https://example.com/post/title"&gt;
&lt;meta property="og:image" content="https://example.com/img/hero.jpg"&gt;

&lt;meta name="twitter:card"        content="summary_large_image"&gt;
&lt;meta name="twitter:title"       content="How to Title a Title"&gt;
&lt;meta name="twitter:description" content="A short, human description of the page."&gt;
&lt;meta name="twitter:image"       content="https://example.com/img/hero.jpg"&gt;</pre><p>In many cases, you&#8217;ll be pulling the data from a database and displaying the output using a templating system like the Template Toolkit.</p><pre class="urvanov-syntax-highlighter-plain-tag">my $tt = Template-&gt;new;

my $object = $resultset-&gt;find({ slug =&gt; $some_slug });

$tt-&gt;process('page.tt', { object =&gt; $object }, "$some_slug/index.html");</pre><p>In this case, you&#8217;d just add a single call to the &lt;head&gt; of your page template.</p><pre class="urvanov-syntax-highlighter-plain-tag">&lt;head&gt;

  &lt;!-- lots of other HTML --&gt;

[% object.tags %]

&lt;/head&gt;</pre><p></p>
<h2 data-start="4554" data-end="4602">A note if you used my earlier Open Graph role</h2>
<p data-start="4604" data-end="4809">If you spotted <code data-start="4619" data-end="4642">MooX::Role::OpenGraph</code> arrive on MetaCPAN recently: <code data-start="4653" data-end="4662">SEOTags</code> is the “grown-up” superset. It does Open Graph <em data-start="4710" data-end="4715">and</em> Twitter <em data-start="4724" data-end="4729">and</em> standard tags, so you only need one role. The old module is scheduled for deletion from MetaCPAN.</p>
<h2 data-start="4604" data-end="4809">SEO tags and JSON-LD</h2>
<p>These tags are only one item in the SEO toolkit that you&#8217;d use to increase the visibility of your website. Another useful tool is <a href="https://json-ld.org/"> JSON-LD</a> &#8211; 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 <a href="https://metacpan.org/pod/MooX::Role::JSON_LD">MooX::Role::JSON_LD</a> which makes it easy to add that to your page too. I wrote <a href="https://perlhacks.com/2025/01/adding-structured-data-with-perl/">a blog post about using that</a> earlier this year.</p>
<h3 data-start="0" data-end="17">In conclusion</h3>
<p data-start="19" data-end="556">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 <code data-start="207" data-end="228">MooX::Role::SEOTags</code> spit out the right tags every time (and, if you fancy richer results, pair it with <code data-start="312" data-end="333">MooX::Role::JSON_LD</code>). 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.</p><p>The post <a href="https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/">Easy SEO for lazy programmers</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/09/easy-seo-for-lazy-programmers/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2352</post-id>	</item>
		<item>
		<title>Stop using your system Perl</title>
		<link>https://perlhacks.com/2025/06/stop-using-your-system-perl/</link>
					<comments>https://perlhacks.com/2025/06/stop-using-your-system-perl/#comments</comments>
		
		<dc:creator><![CDATA[Dave Cross]]></dc:creator>
		<pubDate>Fri, 27 Jun 2025 12:58:04 +0000</pubDate>
				<category><![CDATA[Programming]]></category>
		<category><![CDATA[carton]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[perlbrew]]></category>
		<category><![CDATA[system perl]]></category>
		<guid isPermaLink="false">https://perlhacks.com/?p=2336</guid>

					<description><![CDATA[<p>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 “whatever version is included with my OS.” If that’s you, this post [&#8230;]</p>
<p>The post <a href="https://perlhacks.com/2025/06/stop-using-your-system-perl/">Stop using your system Perl</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></description>
										<content:encoded><![CDATA[<p data-start="151" data-end="450">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 <em data-start="406" data-end="450">“whatever version is included with my OS.”</em></p>
<p data-start="452" data-end="664">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&#8217;re serious about writing and running Perl in 2025, it&#8217;s time to stop relying on the system Perl.</p>
<p data-start="666" data-end="683">Let’s unpack why.</p>
<hr />
<h2 data-start="690" data-end="715">What is “System Perl”?</h2>
<p data-start="717" data-end="1054">When we talk about the <em data-start="740" data-end="753">system Perl</em>, 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 <code data-start="1002" data-end="1017">/usr/bin/perl</code> and tied closely to system packages.</p>
<p data-start="1056" data-end="1178">It’s tempting to just use what’s already there. But that decision brings a lot of hidden baggage—and some very real risks.</p>
<h3 data-start="363" data-end="414">Which versions of Perl are officially supported?</h3>
<p data-start="416" data-end="670">The <a class="cursor-pointer" href="https://perldoc.perl.org/perlpolicy#MAINTENANCE-AND-SUPPORT" target="_new" rel="noopener" data-start="420" data-end="512">Perl Core Support Policy</a> states that only the <strong data-start="534" data-end="575">two most recent stable release series</strong> of Perl are supported by the Perl development team [<strong>Update:</strong> fixed text in previous sentence]. As of mid-2025, that means:</p>
<ul data-start="672" data-end="736">
<li data-start="672" data-end="703">
<p data-start="674" data-end="703">Perl 5.40 (released May 2024)</p>
</li>
<li data-start="704" data-end="736">
<p data-start="706" data-end="736">Perl 5.38 (released July 2023)</p>
</li>
</ul>
<p data-start="738" data-end="1014">If you&#8217;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 <code data-start="958" data-end="979">ExtUtils::MakeMaker</code>, <code data-start="981" data-end="996">Module::Build</code>, or <code data-start="1001" data-end="1013">Test::More</code>.</p>
<p data-start="1016" data-end="1157">Using an old system Perl often means you’re <strong data-start="1060" data-end="1087">several versions behind</strong>, and no one upstream is responsible for keeping that working anymore.</p>
<hr />
<h2 data-start="1185" data-end="1222">Why using System Perl is a problem</h2>
<h3 data-start="1224" data-end="1250">1. It’s often outdated</h3>
<p data-start="1252" data-end="1494">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 <strong data-start="1437" data-end="1446">years</strong> behind the latest stable Perl (currently 5.40).</p>
<p data-start="1496" data-end="1529">This means you&#8217;re missing out on:</p>
<ul data-start="1530" data-end="1678">
<li data-start="1530" data-end="1611">
<p data-start="1532" data-end="1611">New language features (<code data-start="1555" data-end="1562">builtin</code>, <code data-start="1571" data-end="1583">class/method/field</code>, <code data-start="1585" data-end="1597">signatures</code>, <code data-start="1599" data-end="1610">try/catch</code>)</p>
</li>
<li data-start="1612" data-end="1638">
<p data-start="1614" data-end="1638">Performance improvements</p>
</li>
<li data-start="1639" data-end="1650">
<p data-start="1641" data-end="1650">Bug fixes</p>
</li>
<li data-start="1651" data-end="1678">
<p data-start="1653" data-end="1678">Critical security patches</p>
</li>
<li data-start="1651" data-end="1678">Support: anything older than Perl 5.38 is no longer officially maintained by the core Perl team</li>
</ul>
<p data-start="1680" data-end="1814">If you’ve ever looked at modern Perl documentation and found your code mysteriously breaking, chances are your system Perl is too old.</p>
<h3 data-start="1816" data-end="1850">2. It’s not yours to mess with</h3>
<p data-start="1852" data-end="2183">System Perl isn&#8217;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 <code data-start="2097" data-end="2103">cpan</code> or <code data-start="2107" data-end="2114">cpanm</code> as root), you run the risk of breaking something your OS depends on.</p>
<p data-start="2185" data-end="2278">It’s a kind of dependency hell that’s completely avoidable—<strong data-start="2244" data-end="2277">if you stop using system Perl</strong>.</p>
<h3 data-start="2280" data-end="2336">3. It’s a barrier to portability and reproducibility</h3>
<p data-start="2338" data-end="2450">When you use system Perl, your environment is essentially defined by your distro. That’s fine until you want to:</p>
<ul data-start="2451" data-end="2575">
<li data-start="2451" data-end="2492">
<p data-start="2453" data-end="2492">Move your application to another system</p>
</li>
<li data-start="2493" data-end="2531">
<p data-start="2495" data-end="2531">Run CI tests on a different platform</p>
</li>
<li data-start="2532" data-end="2549">
<p data-start="2534" data-end="2549">Upgrade your OS</p>
</li>
<li data-start="2550" data-end="2575">
<p data-start="2552" data-end="2575">Onboard a new developer</p>
</li>
</ul>
<p data-start="2577" data-end="2733">You lose the ability to create predictable, portable environments. That’s not a luxury—<strong data-start="2664" data-end="2707">it’s a requirement for sane development</strong> in modern software teams.</p>
<hr />
<h2 data-start="2740" data-end="2775">What you should be doing instead</h2>
<h3 data-start="2777" data-end="2809">1. Use <code data-start="2788" data-end="2798">perlbrew</code> or <code data-start="2802" data-end="2809">plenv</code></h3>
<p data-start="2811" data-end="2995">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? <code data-start="2966" data-end="2976">perlbrew</code> makes it a breeze.</p>
<p data-start="2997" data-end="3005">You get:</p>
<ul data-start="3006" data-end="3121">
<li data-start="3006" data-end="3043">
<p data-start="3008" data-end="3043">A clean separation from system Perl</p>
</li>
<li data-start="3044" data-end="3089">
<p data-start="3046" data-end="3089">The freedom to upgrade or downgrade at will</p>
</li>
<li data-start="3090" data-end="3121">
<p data-start="3092" data-end="3121">Zero risk of breaking your OS</p>
</li>
</ul>
<p data-start="3123" data-end="3193">It takes minutes to set up and pays for itself tenfold in flexibility.</p>
<h3 data-start="3195" data-end="3230">2. Use <code data-start="3206" data-end="3218">local::lib</code> or <code data-start="3222" data-end="3230">Carton</code></h3>
<p data-start="3232" data-end="3303">Managing CPAN dependencies globally is a recipe for pain. Instead, use:</p>
<ul data-start="3304" data-end="3525">
<li data-start="3304" data-end="3396">
<p data-start="3306" data-end="3396"><a class="cursor-pointer" target="_new" rel="noopener" data-start="3306" data-end="3357"><code data-start="3307" data-end="3319">local::lib</code></a>: keeps modules in your home directory.</p>
</li>
<li data-start="3397" data-end="3525">
<p data-start="3399" data-end="3525"><a class="cursor-pointer" target="_new" rel="noopener" data-start="3399" data-end="3442"><code data-start="3400" data-end="3408">Carton</code></a>: locks your CPAN dependencies (like <code data-start="3479" data-end="3484">npm</code> or <code data-start="3488" data-end="3493">pip</code>) so deployments are repeatable.</p>
</li>
</ul>
<p data-start="3527" data-end="3661">Your production system should run with <em data-start="3566" data-end="3575">exactly</em> the same modules and versions as your dev environment. Carton helps you achieve that.</p>
<h3 data-start="3663" data-end="3704">3. Consider Docker</h3>
<p data-start="3706" data-end="3951">If you’re building larger apps or APIs, containerising your Perl environment ensures true consistency across dev, test, and production. You can even start <em data-start="3861" data-end="3867">from</em> a system Perl inside the container—as long as it’s isolated and under your control.</p>
<p data-start="3953" data-end="4149">You never want to be the person debugging a bug that <em data-start="4006" data-end="4034">only happens on production</em>, because prod is using the distro’s ancient Perl and no one can remember which CPAN modules got installed by hand.</p>
<hr />
<h2 data-start="4156" data-end="4197">The benefits of managing your own Perl</h2>
<p data-start="4199" data-end="4249">Once you step away from the system Perl, you gain:</p>
<ul data-start="4251" data-end="4841">
<li data-start="4251" data-end="4351">
<p data-start="4253" data-end="4351"><strong data-start="4253" data-end="4285">Access to the full language.</strong> Use the latest features without backports or compatibility hacks.</p>
</li>
<li data-start="4352" data-end="4442">
<p data-start="4354" data-end="4442"><strong data-start="4354" data-end="4376">Freedom from fear.</strong> Install CPAN modules freely without the risk of breaking your OS.</p>
</li>
<li data-start="4443" data-end="4524">
<p data-start="4445" data-end="4524"><strong data-start="4445" data-end="4461">Portability.</strong> Move projects between machines or teams with minimal friction.</p>
</li>
<li data-start="4525" data-end="4599">
<p data-start="4527" data-end="4599"><strong data-start="4527" data-end="4546">Better testing.</strong> Easily test your code across multiple Perl versions.</p>
</li>
<li data-start="4600" data-end="4692">
<p data-start="4602" data-end="4692"><strong data-start="4602" data-end="4615">Security.</strong> Stay up to date with patches and fixes on <em data-start="4658" data-end="4664">your</em> schedule, not the distro’s.</p>
</li>
<li data-start="4693" data-end="4841">
<p data-start="4695" data-end="4841"><strong data-start="4695" data-end="4716">Modern practices.</strong> Align your Perl workflow with the kinds of practices standard in other languages (think <code data-start="4805" data-end="4817">virtualenv</code>, <code data-start="4819" data-end="4826">rbenv</code>, <code data-start="4828" data-end="4833">nvm</code>, etc.).</p>
</li>
</ul>
<hr />
<h2 data-start="4848" data-end="4871">“But it just works…”</h2>
<p data-start="4873" data-end="5000">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?</p>
<p data-start="5002" data-end="5048">Because “it just works” only holds true until:</p>
<ul data-start="5049" data-end="5312">
<li data-start="5049" data-end="5098">
<p data-start="5051" data-end="5098">You upgrade your OS and Perl changes under you.</p>
</li>
<li data-start="5099" data-end="5147">
<p data-start="5101" data-end="5147">A script stops working and you don’t know why.</p>
</li>
<li data-start="5148" data-end="5232">
<p data-start="5150" data-end="5232">You want to install a module and suddenly <code data-start="5192" data-end="5197">apt</code> is yelling at you about conflicts.</p>
</li>
<li data-start="5233" data-end="5312">
<p data-start="5235" data-end="5312">You realise the module you need requires Perl 5.34, but your system has 5.16.</p>
</li>
</ul>
<p data-start="5314" data-end="5358">Don’t wait for it to break. Get ahead of it.</p>
<hr />
<h2 data-start="5365" data-end="5382">The first step</h2>
<p data-start="5384" data-end="5462">You don’t have to refactor your entire setup overnight. But you <em data-start="5448" data-end="5453">can</em> do this:</p>
<ul data-start="5463" data-end="5631">
<li data-start="5463" data-end="5499">
<p data-start="5465" data-end="5499">Install <code data-start="5473" data-end="5483">perlbrew</code> and try it out.</p>
</li>
<li data-start="5500" data-end="5557">
<p data-start="5502" data-end="5557">Start a new project with <code data-start="5527" data-end="5535">Carton</code> to lock dependencies.</p>
</li>
<li data-start="5558" data-end="5631">
<p data-start="5560" data-end="5631">Choose a current version of Perl and commit to using it moving forward.</p>
</li>
</ul>
<p data-start="5633" data-end="5744">Once you’ve seen how smooth things can be with a clean, controlled Perl environment, you won’t want to go back.</p>
<hr />
<h2 data-start="5751" data-end="5759">TL;DR</h2>
<p data-start="5761" data-end="5899">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.</p>
<p data-start="5901" data-end="5983">Take the first step. Your future self (and probably your ops team) will thank you.</p><p>The post <a href="https://perlhacks.com/2025/06/stop-using-your-system-perl/">Stop using your system Perl</a> first appeared on <a href="https://perlhacks.com">Perl Hacks</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://perlhacks.com/2025/06/stop-using-your-system-perl/feed/</wfw:commentRss>
			<slash:comments>5</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2336</post-id>	</item>
	</channel>
</rss>
