Modern software distribution has converged on a simple idea: ship a self-contained artifact. Whether that means a statically linked binary, a container image, or a snap/flatpak, the benefits are the same -- dependency management is solved at build time, platform differences are absorbed, and upgrades and rollbacks reduce to swapping a single file.
Perl's App::FatPacker applies the same principle to Perl scripts. It bundles every pure-Perl dependency into a single executable file. No cpanm, no local::lib, no Makefile on the target -- just copy the file and run it. The technique is well-established -- cpm (the CPAN installer we use in the build) is itself distributed as a fatpacked binary.
The distribution pipeline looks like this:
Code repo --> CI --> fatpack --> deploy --> laptops / jumpboxes / servers
|
single file,
no dependencies
This post walks through how we fatpacked an internal CLI we'll call mycli, a ~90-module Perl app, into a single file. The approach generalises to any App::Cmd-based tool.
A good practice for internal tools is to provide all three interfaces: a web frontend, an API, and a CLI. The web frontend is the easiest to discover; the API enables automation and integration; the CLI is the fastest path for engineers who live in a terminal. FatPacker makes the CLI trivially deployable.
mycli is a thin client -- it talks to an internal REST API over HTTPS and renders the response locally. There is no local state beyond a config file and environment variables. You could build an equivalent tool against a binary RPC protocol such as gRPC or Thrift -- the fatpacking approach is the same.
+--------------------+ +-------------------+
| Workstation | HTTPS | Server |
| | | |
| $ mycli resource |---------->| REST API ---+ |
| list ... |<----------| (JSON) DB | |
+--------------------+ +-------------------+
Despite being a thin client, mycli is not trivial. It includes:
- Pluggable output renderers (table, JSON, YAML, CSV, plain text)
- Colour output with
NO_COLORsupport - Automatic pager integration (
less -RFX) and pipe/TTY detection - Activity spinner
- Multi-format ID resolution (numeric, UUID prefix, name lookup)
- Command aliases (
ls/list,get/show) - Config file discovery chain (env var, XDG path, dotfile)
- Timezone-aware timestamp rendering
- Structured syslog logging with per-invocation correlation IDs
- StatsD metrics instrumentation
- HTTP debugging hooks
All of this fatpacks cleanly because each feature is backed by pure-Perl modules.
This makes it an ideal fatpack candidate: the only XS dependency is Net::SSLeay for TLS, which is typically already present on the target system. Everything else is pure Perl.
Why FatPacker over PAR::Packer?
The other well-known option for single-file Perl distribution is PAR::Packer. PAR bundles everything -- including XS modules and even the perl interpreter itself -- into a self-extracting archive. At runtime it unpacks to a temp directory and executes from there.
FatPacker takes a different approach: modules are inlined as strings inside the script and served via a custom @INC hook. There is no extraction step, no temp directory, and no architecture coupling. The trade-off is that FatPacker only handles pure Perl -- XS modules must already be on the target.
For a thin REST client where the only XS dependency is Net::SSLeay, FatPacker wins on simplicity: the output is a plain Perl script, it starts instantly, and it runs on any architecture with a compatible perl. PAR is the better choice when you need to bundle XS-heavy dependencies or ship a binary to machines without Perl at all.
What fatpacking does
FatPacker prepends a BEGIN block to your script containing every dependency as a string literal, keyed by module path. A custom @INC hook serves these strings to require instead of reading from disk. The original script is appended unchanged.
$ wc -l bin/mycli mycli-packed
13 bin/mycli
48721 mycli-packed
That ~49k line file runs identically to the original, on any machine with Perl 5.24+.
The problem with naive fatpacking
The standard FatPacker workflow is:
fatpack trace bin/mycli
fatpack packlists-for $(cat fatpacker.trace) > packlists
fatpack tree $(cat packlists)
fatpack file bin/mycli > mycli-packed
This breaks for non-trivial apps because fatpack trace uses compile-time analysis (B::minus_c). It misses anything loaded at runtime via require:
-
App::Cmddiscovers commands viaModule::Pluggableat runtime -
Text::ANSITableloads border styles and colour themes dynamically -
LWP::UserAgentloads protocol handlers on first request -
YAML::Anyprobes for available backends at runtime
If the trace misses a module, the packed binary dies with Can't locate Foo/Bar.pm in @INC at the worst possible moment.
The solution: a custom trace helper
Instead of relying on fatpack trace, we wrote a helper script that requires every module the app could ever load, then dumps %INC at exit. This captures the complete runtime dependency tree.
#!/usr/bin/env perl
# bin/trace-helper -- not shipped, build-time only
use strict;
use warnings;
use lib 'lib';
# Modules loaded lazily that fatpack misses
require Data::Unixish::Apply;
require Digest::SHA;
require HTTP::Request;
require LWP::UserAgent;
require String::RewritePrefix;
# Exercise objects to trigger deep runtime loads
{
require Text::ANSITable;
my $t = Text::ANSITable->new(use_color => 1, use_utf8 => 1);
$t->border_style('UTF8::SingleLineBold');
$t->color_theme('Text::ANSITable::Standard::NoGradation');
$t->columns(['a']);
$t->add_row(['1']);
$t->draw; # forces all rendering deps to load
}
# Every App::Cmd leaf command
require MyCLI::App;
require MyCLI::App::Command::device::list;
require MyCLI::App::Command::device::get;
# ... all 80+ command modules ...
END {
open my $fh, '>', 'fatpacker.trace' or die $!;
for my $inc (sort keys %INC) {
next unless defined $INC{$inc};
next if $inc =~ m{\AMyCLI/}; # our own modules come from lib/
print $fh "$inc\n";
}
}
Key points:
-
Don't call
->run--App::Cmdsubdispatch will die on duplicate command names across namespaces. Justrequireevery leaf. -
Exercise both code paths --
Text::ANSITableloads different modules for colour vs plain, UTF-8 vs ASCII. Instantiate both. -
Exclude your own namespace -- FatPacker embeds modules from
fatlib/; yourlib/modules are embedded separately. Including them in the trace causes duplicates.
Forcing pure-Perl backends
FatPacker can only bundle pure Perl. Many popular modules ship dual XS/pure-Perl backends and prefer XS at runtime. If XS is available during the trace, the pure-Perl fallback won't appear in %INC and won't get bundled.
Force pure-Perl mode during the build:
# In the fatpack build script
export B_HOOKS_ENDOFSCOPE_IMPLEMENTATION=PP
export LIST_MOREUTILS_PP=1
export MOO_XS_DISABLE=1
export PACKAGE_STASH_IMPLEMENTATION=PP
export PARAMS_VALIDATE_IMPLEMENTATION=PP
export PERL_JSON_BACKEND=JSON::PP
export PUREPERL_ONLY=1
PUREPERL_ONLY=1 is a convention respected by many dual XS/PP distributions at install time, preventing XS compilation entirely. The per-module variables above cover modules that don't check PUREPERL_ONLY.
Combine this with --pp at install time to avoid pulling in XS at all:
cpm install -L local --target-perl 5.24.0 --pp
Pinning the target Perl version
The --target-perl flag to cpm is critical and easy to overlook. Without it, cpm resolves dependency versions against your build machine's Perl. If you're building on 5.38 but deploying to a jumpbox running 5.24, you'll silently install module versions that use postfix dereferencing, subroutine signatures, or other features that don't exist on the target.
The packed binary will fail at runtime with a syntax error -- far from the build where you could catch it.
This tells cpm's resolver to only consider module versions whose metadata declares compatibility with 5.24.0. Combined with perl -c as a post-install sanity check, this catches version mismatches before the slow trace step.
The complete build script
Here is the full pipeline, wrapped in a shell script. It supports incremental builds (reuses local/ and trace cache) and --clean for full rebuilds.
#!/bin/sh
set -e
CLEAN=0
[ "$1" = "--clean" ] && CLEAN=1
# 0. Prerequisites
for cmd in cpm fatpack perl; do
command -v "$cmd" >/dev/null 2>&1 || {
echo "Error: '$cmd' is not installed." >&2; exit 1
}
done
export PERL_USE_UNSAFE_INC=1 # Perl 5.26+ removed . from @INC
# 1. Install deps (pure-perl only)
if [ "$CLEAN" = 1 ] || [ ! -d local/ ]; then
rm -rf local/
cpm install -L local --target-perl 5.24.0 --pp
fi
# 2. Set up paths
export PERL5LIB=$PWD/lib:$PWD/local/lib/perl5
export PATH=$PWD/local/bin:$PATH
# 3. Force pure-perl backends
export B_HOOKS_ENDOFSCOPE_IMPLEMENTATION=PP
export LIST_MOREUTILS_PP=1
export MOO_XS_DISABLE=1
export PACKAGE_STASH_IMPLEMENTATION=PP
export PARAMS_VALIDATE_IMPLEMENTATION=PP
export PERL_JSON_BACKEND=JSON::PP
export PUREPERL_ONLY=1
# 4. Verify compilation
perl -c bin/mycli || exit 1
# 5. Trace
if [ "$CLEAN" = 1 ] || [ ! -f fatpacker.trace ]; then
perl -Ilib bin/trace-helper
echo "Trace: $(wc -l < fatpacker.trace) modules"
fi
# 6. Pack
fatpack packlists-for $(cat fatpacker.trace) > packlists
fatpack tree $(cat packlists)
# Strip arch-specific dirs and non-essential files
rm -rf fatlib/$(perl -MConfig -e 'print $Config{archname}')
find fatlib -name '*.pod' -delete
find fatlib -name '*.pl' -delete
# Bundle
fatpack file bin/mycli > mycli-packed
chmod +x mycli-packed
echo "Built mycli-packed ($(wc -c < mycli-packed) bytes)"
Step by step: what happens
-
Prerequisites -- verify
cpm,fatpack, andperlare available -
Install --
cpminstalls all dependencies intolocal/as pure Perl, targeting 5.24.0 -
Paths and env -- set
PERL5LIB,PATH, and pure-Perl overrides -
Compile check --
perl -c bin/myclicatches syntax errors before the slow trace step -
Trace -- the helper script loads everything and writes the module
list to
fatpacker.trace -
Packlists and tree --
fatpack packlists-formaps module names to installed packlist files;fatpack treecopies the.pmfiles intofatlib/ -
Clean up -- remove
.pod,.pl, and arch-specific directories to reduce size -
Bundle --
fatpack fileinlines everything fromfatlib/into the script
Makefile integration
For teams that prefer make, add targets that delegate to the shell script:
# In Makefile.PL, inside MY::postamble
.PHONY: pack clean_fatpack
pack:
./fatpack
clean :: clean_fatpack
clean_fatpack:
rm -rf fatlib fatpacker.trace packlists mycli-packed local/
Then building is just:
perl Makefile.PL
make pack
Adding a new dependency
When someone adds use Some::New::Module to the codebase, the fatpacked binary will break with Can't locate Some/New/Module.pm in @INC unless the build picks it up. The workflow is:
- Add the module to
cpanfile - If the module is loaded at runtime (via
requireor a plugin mechanism), add arequire Some::New::Moduleline to the trace helper - Rebuild with
--clean
./fatpack --clean
The --clean flag is important. Without it, the build reuses the cached local/ directory and fatpacker.trace from the previous run. The new module won't appear in either, and the packed binary will silently ship without it.
A good safeguard is to run perl -c mycli-packed after every build -- this catches missing modules at build time rather than in production.
What about perlstrip?
Perl::Strip can reduce the packed file by ~30% by removing comments, POD, and whitespace from bundled modules. We deliberately left it off. For an internal tool, the size saving (~1.7 MB) is not worth the trade-off: stripped files are harder to debug with stack traces, and perlstrip has a known issue corrupting files that contain use utf8.
Gotchas and tips
XS modules cannot be fatpacked
Modules with C extensions (.so/.xs) cannot be inlined. They must already exist on the target system. If your app has many XS dependencies, consider PAR::Packer instead (see above).
PERL_USE_UNSAFE_INC
Perl 5.26 removed . from @INC. Some older CPAN modules assume it's there during install or test. Set PERL_USE_UNSAFE_INC=1 during the build to avoid spurious failures. This only affects the build environment, not the packed binary.
Pinto / private CPAN
If your organisation runs a private CPAN mirror (Pinto, OrePAN2, etc.), point cpm at it with --resolver:
cpm install -L local --resolver 02packages,$PINTO_REPO --pp
Docker builds
FatPacker and Docker are complementary. Use Docker for the build environment (consistent Perl version, cpm, fatpack installed), and ship either the container image or just the packed file:
COPY mycli-packed /usr/local/bin/mycli
RUN chmod +x /usr/local/bin/mycli
Summary
The core recipe is three pieces:
- A trace helper that loads every module your app could use at runtime,
capturing the full dependency tree via
%INC -
Pure-Perl enforcement via environment variables and
cpm --pp - The standard fatpack pipeline: packlists, tree, clean up, bundle
The result is a single file you can scp to any box with Perl 5.24+ and run immediately. No CPAN, no Makefile, no containers required.
References
- App::FatPacker on CPAN
-
FatPacking Perl applications -- talk
by Andrew Rodland covering the core technique, pure-Perl enforcement, and
cpm - arodland/swr fatpack script -- a clean, minimal reference implementation of the full pipeline
-
App::cpm -- fast CPAN installer
(itself shipped as a fatpacked binary);
--target-perland--ppflags are essential for fatpack builds
Top comments (0)