How AppImages are built
This page explains the mechanics behind AppImage packaging for Python applications - both the
manual process and what appimage.ctl automates. Understanding the manual steps makes it
clear what the module does and why each piece exists.
What an AppImage is
An AppImage is a single executable file that bundles an application together with all its
dependencies into a SquashFS filesystem image. A small ELF runtime is prepended to the
image; when the file is executed, that runtime uses FUSE to mount the SquashFS and then
runs the AppRun entry point inside it. The application sees a consistent, self-contained
directory tree at $APPDIR, regardless of what is installed on the host system.
The AppDir layout
Before appimagetool packs everything into a .AppImage file, it expects a directory with
this structure:
AppDir/
├── AppRun ← executable entry point (bash script)
├── myapp.desktop ← Freedesktop metadata
├── myapp.png ← application icon
└── python/ ← complete Python distribution
├── bin/
│ └── python3
└── lib/
└── python3.x/
└── site-packages/
├── appimage/ ← the runtime module
└── myapp/ ← your application
Three files at the root are mandatory for AppImage: AppRun, a .desktop file, and an icon
matching the desktop file’s Icon= value. Everything else is up to you - for a Python
application that means shipping a complete Python distribution under python/.
python-build-standalone and uv
The Python distribution bundled in the AppImage comes from python-build-standalone, a project that produces pre-built, highly portable Python interpreters for Linux, macOS, and Windows.
Astral - the team behind uv - now maintains python-build-standalone
and uses it as the direct source for uv python install. This means the Python you get when
you run uv python install 3.11 and the Python bundled in your AppImage are built from
exactly the same release artifacts. Same compiler flags, same standard library, same
binary layout.
The practical consequence: if you develop and test with uv, there are no interpreter
surprises at packaging time. The AppImage ships the interpreter your code already ran against.
python-build-standalone provides several artifact variants. The one used for AppImages is
install_only_stripped - it contains the interpreter, standard library, and headers, but
omits test suites and debug symbols, keeping the download small. The archive extracts to a
python/ directory ready to be placed directly inside AppDir.
Releases and download URLs
Releases are published at
github.com/astral-sh/python-build-standalone/releases.
Each release is tagged with a date (e.g. 20260211). The download URL follows a fixed
pattern:
https://github.com/astral-sh/python-build-standalone/releases/download/{date}/
cpython-{python_version}+{date}-{arch}-unknown-linux-gnu-install_only_stripped.tar.gz
The {arch} token depends on the host machine:
|
URL arch token |
|---|---|
|
|
|
|
|
|
Note
uv uses the same python-build-standalone distributions when you run
uv python install. If uv is already available on the build machine, it can
download and cache the interpreter for you - but the tarball it uses internally is
the same install_only_stripped artifact described here.
Building manually
The following steps reproduce what appimage.ctl does, without using the module.
Each step can be run and verified independently. Requirements: curl, tar, a Linux host.
Set these variables in your shell before running the steps below. Adjust APP,
PYTHON_MINOR, and the entry point in the AppRun script to match your project.
APP="myapp"
PYTHON_MINOR="3.11"
ARCH="$(uname -m)"
Step 1 - Create the AppDir skeleton
The AppDir is the staging directory that appimagetool will later pack into a single file.
mkdir -p build/AppDir
Step 2 - Download Python
The python-build-standalone URL uses a different arch token than uname -m on armv7.
Apply the mapping first:
case "$ARCH" in
armv7l) PBS_ARCH="armv7" ;;
*) PBS_ARCH="$ARCH" ;;
esac
Then resolve the download URL and fetch the tarball:
RELEASE_DATE=$(curl -s \
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" \
| grep '"tag_name"' | cut -d'"' -f4)
PBS_URL=$(curl -s \
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/${RELEASE_DATE}" \
| grep '"browser_download_url"' \
| grep "cpython-${PYTHON_MINOR}\." \
| grep "${PBS_ARCH}-unknown-linux-gnu-install_only_stripped" \
| grep -v "freethreaded" \
| cut -d'"' -f4)
curl -L "$PBS_URL" -o build/python.tar.gz
For reproducible builds, replace the first curl with a hardcoded date:
RELEASE_DATE="20260211"
Step 3 - Extract Python into AppDir
The tarball extracts to a python/ directory. Placing it directly inside AppDir gives
the final path AppDir/python/bin/python3.
tar -xf build/python.tar.gz -C build/AppDir
After this step, verify the interpreter works:
build/AppDir/python/bin/python3 --version
Step 4 - Install the application
Use the bundled interpreter’s pip to install the application and the appimage runtime
module into the bundled site-packages. This keeps everything self-contained inside AppDir.
build/AppDir/python/bin/python3 -m pip install --no-compile appimage myapp
Replace myapp with . (the current directory) instead if you’re packaging a local
project that isn’t published to PyPI - which is the common case while developing:
build/AppDir/python/bin/python3 -m pip install --no-compile appimage .
The appimage package is the runtime component that handles entry point dispatch,
--python-interpreter, and virtual environment support at launch time.
--no-compile skips pip’s usual bytecode compilation, which invalidates each .pyc
against the install-time source mtime - a value that differs on every build even
when the installed content is identical. appimage.ctl compiles bytecode itself,
once, at the very end of AppDir assembly (after any hooks have run), using
compileall --invalidation-mode unchecked-hash so a .pyc’s validity is tied to a
hash of its source instead:
build/AppDir/python/bin/python3 -m compileall -qf \
--invalidation-mode unchecked-hash \
-s build/AppDir/python/lib/python3.x/site-packages \
build/AppDir/python/lib/python3.x/site-packages
-f (force) matters here: merely importing a module - not just pip compiling it -
can leave behind a .pyc already timestamp-invalidated by an earlier build step (a
build backend, a lifecycle hook). Without -f, compileall leaves an
existing-looking .pyc alone instead of regenerating it in hash-based mode,
reintroducing the same non-determinism through a side door.
-s <site-packages> strips that path prefix from every compiled code object’s
co_filename. Without it, each .pyc embeds the absolute build path (e.g.
/home/alice/project/build/AppDir/python/lib/python3.11/site-packages/...) - baking
the building machine’s directory layout, and typically the building user’s own name,
into every single compiled file. Confirmed by hand: packaging the identical AppDir
from two different absolute locations, with every other reproducibility measure on
this page already applied, still produced two different .AppImage files until this
flag was added - co_filename was the only thing left that varied.
Step 5 - Write the AppRun script
AppRun is the executable entry point that the Linux kernel calls when the AppImage is
run. It must be at the root of AppDir and must be executable.
Create a file named AppRun with the following content:
#!/bin/bash
set -e
if [ -n "$APPIMAGE" ]; then
appimage_path=$(dirname "$APPIMAGE")
if [ -d "$appimage_path/squashfs-root" ]; then
export APPDIR="$appimage_path/squashfs-root"
fi
fi
if [ -z "$APPDIR" ]; then
export APPDIR=$(dirname $(readlink -f "$0"))
fi
exec "$APPDIR/python/bin/python3" -P -m appimage --python-main myapp "$@"
Replace myapp in --python-main with the console script name your package defines in
[project.scripts]. The blank line after set -e is deliberate, not a typo - it’s
where appimage.ctl’s own template (templates/AppRun.sh)
substitutes extra export lines for [tool.appimage.env], and stays blank (not
collapsed) when there are none - keep it for a byte-identical match against the
generated version.
Then copy it into place and mark it executable:
cp AppRun build/AppDir/AppRun
chmod +x build/AppDir/AppRun
The script sets APPDIR to the root of the mounted filesystem, then hands off to the
appimage runtime module. The squashfs-root block at the top handles the case where
the AppImage was extracted manually (e.g. on systems without FUSE).
The -P flag passed to Python activates isolated mode:
PYTHONPATHfrom the host environment is ignoredThe user site-packages directory (
~/.local/lib/python3.x/site-packages) is not added tosys.pathPYTHONSTARTUPis not executed
Without -P, a package installed in the user’s home directory could shadow a package
bundled in the AppImage. The flag ensures the AppImage only ever loads what is inside
AppDir/python/.
Step 6 - Write the .desktop file
AppImage requires a Freedesktop .desktop file at the root of AppDir. The filename
must match the Icon= value and the AppImage filename prefix.
Create a file named myapp.desktop with the following content:
[Desktop Entry]
Type=Application
Name=myapp
Icon=myapp
Categories=Utility;
Terminal=true
Then copy it into place:
cp myapp.desktop build/AppDir/myapp.desktop
Step 7 - Add an icon
AppImage requires an icon file whose basename matches the Icon= value in the .desktop
file. PNG and SVG are both accepted.
cp myapp.png build/AppDir/${APP}.png
If no icon is available, appimage.ctl falls back to a bundled default - already sitting
inside AppDir after Step 4, no extra download needed:
cp build/AppDir/python/lib/python3.x/site-packages/appimage/assets/default_icon.svg \
build/AppDir/${APP}.svg
(Update the .desktop file’s Icon= line to match if you use the .svg extension.)
Alternatively, a plain placeholder can be created with ImageMagick (convert is
deprecated as of ImageMagick 7 - use magick):
magick -size 256x256 xc:gray -strip build/AppDir/${APP}.png
-strip matters for reproducibility: without it, ImageMagick embeds a png:tIME chunk
the real wall-clock moment the file was generated - into the PNG’s own bytes. Normalizing the file’s mtime later (see below) doesn’t touch that; two placeholder icons generated at different times are otherwise different files even though they’re visually identical gray squares. Confirmed by hand: this alone was enough to make an otherwise byte-identical AppDir differ.
Step 8 - Download appimagetool and the runtime stub
appimagetool is the official tool for packing an AppDir into a SquashFS-based AppImage.
It is distributed as a self-contained AppImage itself. appimage.ctl uses
AppImage/appimagetool - the maintained
successor to AppImage/AppImageKit’s classic appimagetool - because AppImageKit’s
bundled mksquashfs has a documented non-deterministic multi-threaded compression bug
(see AppImageKit #929) that made
byte-identical output impossible no matter how the AppDir itself was normalized;
AppImage/appimagetool bundles a fixed, modern squashfs-tools instead.
Both AppImage/appimagetool and AppImage/type2-runtime also publish a rolling
continuous release - the asset is replaced in place whenever a new build goes out,
same filename, same URL. Don’t download from it: a sha256 pinned against today’s
continuous asset can become permanently unfetchable the moment upstream cuts the next
one, since GitHub doesn’t keep the bytes it overwrote. appimage.ctl instead resolves
the newest genuine, versioned release - appimagetool uses semver (1.9.1),
type2-runtime a release date (20251108) - which is never reused for a later build.
Reproducing that here means downloading from a specific version tag, not continuous:
curl -L \
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH}.AppImage" \
-o build/appimagetool
chmod +x build/appimagetool
Newer appimagetool releases no longer embed the AppImage runtime ELF stub - they fetch it live, over the network, at packaging time. Pre-fetching it yourself avoids depending on that live fetch succeeding (some network environments, e.g. certain TLS-intercepting proxies, can’t complete it) and lets you verify what you got:
curl -L \
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH}" \
-o build/runtime
chmod +x build/runtime
Check each project’s releases page for the current version tag before using these -
1.9.1/20251108 above are what was current when this was written, not a moving
target you can query the way /releases/latest works for python-build-standalone
(/releases/latest returns whichever release was published most recently by date,
which is usually continuous itself for both of these repos - it doesn’t know to skip
rolling releases the way appimage.ctl’s own resolution does). GitHub publishes a
sha256 digest per asset for both repos via its Releases API, so a fresh download can be
verified against it at no extra cost regardless of which tag it came from. Set
appimagetool_sha256 and runtime_sha256 in [tool.appimage] (or run init to
write both automatically from whatever’s currently resolved) to pin and verify them;
without a pin, appimage.ctl still logs the sha256 of whichever binaries it used, so
they can be copied into config later.
Step 9 - Scrub build-machine paths and normalize the AppDir
Everything up to this point produces a working AppImage - run it, it works. It does
not yet produce a reproducible one: pip install and mksquashfs both bake incidental,
per-build-machine state into the result, invisibly, even though nothing about the
installed content actually changed.
Console-script shebangs. pip install . generates a launcher script for every
[project.scripts] entry point (build/AppDir/python/bin/myapp here), and its shebang
line embeds the absolute path to the bundled interpreter - build/AppDir/python/bin/ python3, resolved to a full path at install time. That path varies with wherever the
checkout happens to live, so two otherwise-identical builds from two different
locations (two developers, two CI runners) embed two different shebangs. Rewrite each
one to find its interpreter relative to its own location instead:
build/AppDir/python/bin/python3 - <<'PYEOF'
import pathlib
bindir = pathlib.Path("build/AppDir/python/bin")
appdir_bytes = str(bindir.parent.parent.resolve()).encode()
for script in bindir.iterdir():
if script.is_symlink() or not script.is_file():
continue
content = script.read_bytes()
if not content.startswith(b"#!" + appdir_bytes):
continue
first_line, _, rest = content.partition(b"\n")
python_bin_name = first_line[2:].rsplit(b"/", 1)[-1]
replacement = b'"$(dirname -- "$(realpath -- "$0")")/' + python_bin_name + b'"'
new_content = (
b"#!/bin/sh\n'''exec' " + replacement + b' "$0" "$@"\n' + b"' '''\n" + rest
)
script.write_bytes(new_content)
PYEOF
pip’s own RECORD file for that package (.../myapp-0.1.0.dist-info/RECORD) still lists
the original shebang’s hash and size for that script - stale the moment the script
above rewrites it. Nothing at runtime reads RECORD, but leaving it stale means the
RECORD file’s own bytes still vary with the original, path-dependent shebang, defeating
the whole point. appimage.ctl updates the affected row in place; doing the same by
hand for a small project is usually simplest with a short script - see
_scrub_record_row
for the exact logic if you want to replicate it faithfully.
direct_url.json. Every local install writes one per package (PEP 610), recording
the file:// source path it was installed from. Meaningless once the AppDir runs
somewhere else, and - for a package installed from a local path - it embeds that
path too:
find build/AppDir -name direct_url.json -delete
(If you deleted it, also drop its row from the corresponding RECORD, for the same reason as the shebang above.)
File timestamps. Every file pip, compileall, and the steps above touched now has
whatever mtime it happened to be installed, compiled, or rewritten at - which differs
build to build even when the content doesn’t. mksquashfs embeds each file’s mtime
into the packed image, so this alone is enough to make two builds differ:
find build/AppDir -exec touch -h -d @0 {} +
-h matters - it changes the symlink’s own timestamp instead of following it (some
Python installations include symlinks, e.g. python3 -> python3.11).
File permissions. mksquashfs also embeds each file’s permission bits. Depending on
how a given build host’s umask interacted with the permissions already stored in an
installed package’s own files, two content-identical AppDirs have been observed to
differ only in whether files ended up group/other-writable:
find build/AppDir -not -type l -exec chmod go-w {} +
Step 10 - Pack the AppImage
mkdir -p dist
SOURCE_DATE_EPOCH=0 LC_ALL=C TZ=UTC build/appimagetool --runtime-file build/runtime \
--mksquashfs-opt -no-xattrs \
--mksquashfs-opt -no-duplicates \
--mksquashfs-opt -processors --mksquashfs-opt 1 \
build/AppDir dist/${APP}-${ARCH}.AppImage
If update_info is set in [tool.appimage], add -u "<update_info>" before the
AppDir/output arguments too - it’s not just cosmetic. appimagetool embeds that
string into a resource section of the runtime ELF itself, so a build run with -u and
one run without it produce two different files even from an identical AppDir with
every other flag the same. Confirmed by hand: this exact, previously-unexplained
mismatch is what earlier made a hand-packaged AppImage differ from appimage.ctl’s own
output for a project that has update_info configured, despite every file inside the
AppDir already matching byte-for-byte.
appimagetool compresses the AppDir into a SquashFS image, prepends the AppImage
runtime (a small ELF binary that mounts and executes it - --runtime-file supplies the
copy from Step 8 instead of triggering another live download), and writes the result as
a single executable file.
The three --mksquashfs-opt flags and the environment matter as much as everything in
Step 9 - see “Why this is hard” for the full
reasoning behind each:
-no-xattrs- without it, a build host’s own filesystem xattrs (e.g. SELinux labels) leak into the image.-no-duplicates- mksquashfs’s duplicate-file pre-filter otherwise makes the packaged bytes sensitive to incidental per-build state, even from an unchanged AppDir.-processors 1- confirmed by hand: with the compression thread count left to auto-detect, otherwise-identical AppDirs produced different bytes depending on how the parallel deflator threads happened to finish and get written out - not on content, directory order, or machine, but on scheduling. Slower, but the only way to remove that variable entirely.SOURCE_DATE_EPOCH/LC_ALL/TZin appimagetool’s own environment - it touches a few paths of its own during packaging (e.g..DirIcon) that Step 9’s AppDir-side normalization can’t reach.
Verify it yourself, without appimage.ctl
Every step above was run by hand, twice, from two different absolute output
directories (/tmp/manual-build-a, /tmp/manual-build-b - deliberately not the same
location twice, the stronger version of the check: it also catches anything that
depends on the build’s own path, not just on run-to-run timing):
$ ./manual-build.sh /tmp/manual-build-a
[...]
54dde6716cbe1da104a2b73b4f1a67d356f56a677da51e408b877e1b900a4326 /tmp/manual-build-a/dist/myapp-x86_64.AppImage
$ ./manual-build.sh /tmp/manual-build-b
[...]
54dde6716cbe1da104a2b73b4f1a67d356f56a677da51e408b877e1b900a4326 /tmp/manual-build-b/dist/myapp-x86_64.AppImage
Identical to each other - and, checked directly against a same-configuration build run
through the real python -m appimage.ctl on the same machine, identical to that too:
54dde6716cbe1da104a2b73b4f1a67d356f56a677da51e408b877e1b900a4326, same hash, byte for
byte. Not merely “this recipe is internally consistent” but “this recipe is what
appimage.ctl does” - the strongest version of the claim on this page.
Before that match, both this and the cross-machine case below went through several
rounds of a real mismatch, each one a genuine gap in this page rather than a fluke -
confirmed by hand, in this order: missing -s on compileall, a stale RECORD row
after relocating the shebang, ImageMagick’s png:tIME chunk, an icon file that existed
but wasn’t the same file appimage.ctl itself falls back to, and (for a project with
update_info configured - not myapp, see ssh-mitm
below) a missing -u flag. manual-build.sh is
examples/myapp/manual-build.sh
in this repository - a runnable version of every step on this page, for anyone who
wants to check the current claim rather than trust this paragraph.
The same recipe, adapted for a real project’s own config (production extras,
pylock.toml-pinned dependencies, its own icon/desktop, update_info) was checked
against ssh-mitm - a much larger, real
application, not a toy example - with the same result: the fully manual build matches
python -m appimage.ctl’s own output byte for byte
(62066b4cb7cf61004d714b905ed15a0a7e42819256869a091baf7d9b6a40ea85).
What appimage.ctl automates
python -m appimage.ctl performs the same steps as described above, with these
additions:
Configuration resolution - app name, entry point, and Python version are read from
[project] in pyproject.toml. Running check shows exactly what was detected and from
where before anything is built.
GitHub API lookup - the module queries the python-build-standalone releases API to find
the correct install_only_stripped asset for the current architecture and the requested
Python minor version. Passing python_date in [tool.appimage] pins the exact
release tag. A freshly downloaded tarball is also verified against the sha256 digest
GitHub publishes for the asset (or against python_sha256, if set) - no extra network
request needed.
Hash-based bytecode - installed packages are compiled with
compileall --invalidation-mode unchecked-hash instead of relying on pip’s default
timestamp-based .pyc cache (see Step 4).
Timestamp normalization - every file and directory in the AppDir has its mtime set
to SOURCE_DATE_EPOCH (default: the Unix epoch) right before packaging, and the same
value is passed into appimagetool’s own process environment, since it touches a few
paths of its own (e.g. .DirIcon) that AppDir-side normalization can’t reach.
appimagetool and runtime verification - both resolve from an explicit path, then
the build cache, then a download; PATH is never searched for either (see Classic
appimagetool detected).
appimagetool_sha256/runtime_sha256 verify the resolved binary before use; a
mismatch aborts rather than silently packing with the wrong one (see Step 8).
appimagetool is also checked for known signs of the classic, non-deterministic build
and refused outright on a match, pin or not. The runtime file is always pre-fetched
and passed via --runtime-file, so appimagetool never triggers its own live
download. verify_downloads turns an unpinned resolution into a hard error instead
of a warning.
Dependency and build-backend verification - with pylock/build_pylock set,
third-party dependencies and the project’s own [build-system].requires backend are
installed hash-verified (pip install --require-hashes / --build-constraint)
instead of resolved live from whatever the index currently serves. Generated via
lock; see Reproducible builds for
the full mechanism.
Caching - the Python tarball, appimagetool binary, and runtime file are all
cached in build/ and reused on subsequent builds.
AppRun generation - the AppRun script is generated from a template that also handles
the squashfs-root fallback for environments without FUSE (containers, some CI systems),
and injects any extra environment variables defined under [tool.appimage.env].
Desktop file generation - the .desktop file is generated from pyproject.toml
metadata (name, description). A custom file can be provided via the desktop key in
[tool.appimage].
Lifecycle hooks - shell scripts can run after pip install (post_install) or after
all files are in place but before appimagetool runs (pre_package). The APPDIR
environment variable is set when the hook executes, so hooks can modify the AppDir directly.
Bytecode compilation (above) runs after pre_package, so a hook that edits an installed
package’s source is still reflected in the compiled .pyc.
Extra files - arbitrary files or directories are copied into the AppDir via
[tool.appimage.extra_files].
The runtime module
The appimage Python package (installed into AppDir/python/lib/.../site-packages/) is
the bridge between the static AppRun bash script and your application. It is invoked as
python3 -P -m appimage --python-main <entry_point> by every generated AppRun.
At startup it:
Reads
--python-mainto know the default entry pointChecks
VIRTUAL_ENVandARGV0to detect whether it was invoked through a virtual environment symlink - if so, it activates that environment by adjustingsys.path,sys.prefix, and the relevant environment variablesStrips all
--python-*arguments fromsys.argvbefore forwarding to the application, so your code never sees them
The --python-interpreter flag replaces the process with a fresh python3 -P invocation
(via os.execvp) passing any remaining arguments. This is how interactive use and
-m venv work: the module hands control back to the raw interpreter after the AppImage
environment is set up.
When creating a virtual environment via --python-interpreter -m venv, the module patches
the venv so that its python3 symlink points to the AppImage file itself rather than to
the interpreter binary inside AppDir. This makes the AppImage act as the Python
interpreter for the venv - all bundled packages are available, and pip install into the
venv adds packages on top without repackaging.