Part 2 of the Linux Universal Packages series
Part 1 of this series covers the overview, decision matrix, and talk highlights. This part goes further than any conference talk has time for. We’re going into the binary format, FUSE internals, the update spec, and the libfuse2/libfuse3 incident that broke half the AppImages on Ubuntu 22.04. Grab coffee.
The executive summary: an AppImage is a concatenated ELF binary and squashfs filesystem. That’s it. Understanding those two pieces explains almost everything about how they work and why they behave the way they do.
Every AppImage starts with a runtime — a small C binary typically called runtime.c from the AppImageKit project. When your kernel executes an AppImage, it executes this runtime just like any other ELF binary. The runtime’s job is to:
/proc/self/exe)The squashfs payload starts at a specific byte offset immediately after the runtime binary. The runtime finds this offset because it knows the size of itself — it’s compiled with its own size embedded as a constant. In AppImageKit, this is the sfs_offset variable. When the runtime binary is built, the build system appends this size at a well-known location so the runtime can find where the filesystem begins.
You can inspect this yourself:
# Find the squashfs magic bytes — 0x73717368 ("sqsh") or 0x68737173 ("hsqs")
xxd MyApp.AppImage | grep -m1 "7371 7368\|6873 7173"
The offset you find is where the squashfs filesystem starts. Everything before it is the runtime ELF.
# Verify the ELF header (first 4 bytes: 7f 45 4c 46 = ELF magic)
xxd -l 16 MyApp.AppImage
# Check the embedded section that tells file(1) this is an AppImage
file MyApp.AppImage
# Output: ELF 64-bit LSB executable ... (AppImage)
# The AppImage type is embedded in ELF section .note.ABI-tag
# Type 1 = AppImage format 1 (libarchive-based, older)
# Type 2 = AppImage format 2 (squashfs, current)
readelf -n MyApp.AppImage | grep -A2 AppImage
The .note.ABI-tag ELF section contains a note of type AI\x02\x00 (AppImage format 2). This is how tools like AppImageLauncher identify AppImages without relying on file extension — though the .AppImage extension is conventional, not required.
squashfs is a read-only compressed filesystem that’s been part of the Linux kernel since 2.6.29. It’s the same filesystem format used in live CDs, embedded systems (OpenWRT, among many others), and Snap/Flatpak runtimes. AppImage is not special in using squashfs — it’s just the right tool for a read-only bundled filesystem.
Key properties:
You can inspect the squashfs directly:
# Extract squashfs at the known offset (replace OFFSET with actual value)
dd if=MyApp.AppImage bs=1 skip=OFFSET of=/tmp/myapp.squashfs
# Or let unsquashfs figure it out
unsquashfs -l MyApp.AppImage | head -40
# Shows the directory tree inside the AppImage
Inside the squashfs, AppImages follow the AppDir spec. This is a semi-formal specification that defines what an application directory should contain to be portable.
A minimal AppDir:
MyApp.AppDir/
├── AppRun # Executable or symlink; entry point
├── myapp.desktop # Standard .desktop file
├── myapp.png # Icon (various sizes ideally)
├── usr/
│ ├── bin/
│ │ └── myapp # The actual binary
│ ├── lib/
│ │ ├── libssl.so.1.1 # Bundled libraries
│ │ └── libcrypto.so.1.1
│ └── share/
│ └── myapp/ # Data files, assets
└── .DirIcon # Optional: icon for file managers
The AppRun file is the entry point. For simple apps it might just be:
#!/bin/bash
exec "$APPDIR/usr/bin/myapp" "$@"
But more complex AppRuns set up LD_LIBRARY_PATH to point to bundled libraries, set XDG_DATA_DIRS to include bundled data, and handle architecture-specific paths.
The .desktop file must follow the Desktop Entry Specification. AppImageLauncher uses it to create system integration — the Name, Exec, Icon, and Categories fields become the application menu entry.
When you execute an AppImage, the runtime uses FUSE (Filesystem in Userspace) to mount the squashfs payload. Here’s the sequence:
fuse_main() (or equivalent) with the squashfs offset as the source/tmp/.mount_<appname><random>/exec()s $MOUNTPOINT/AppRunYou can observe this while an AppImage is running:
# In one terminal, run an AppImage
./MyApp.AppImage &
# In another terminal:
mount | grep AppImage
# Shows: /tmp/.mount_MyApp<hash> type fuse.squashfuse ...
ls /tmp/.mount_MyApp*/
# Shows the AppDir contents live
This FUSE mount is why AppImages don’t need root — all mounting happens in userspace. The kernel FUSE driver brokers access between the runtime’s FUSE filesystem server and any processes trying to read files through it.
Traditional mount requires CAP_SYS_ADMIN. FUSE gets around this because:
/dev/fuse/dev/fuse and register a FUSE filesystem server/dev/fuse file descriptorThe userspace FUSE server (squashfuse) reads compressed blocks from the AppImage file and hands them to the kernel. Every read operation goes: kernel → FUSE driver → squashfuse userspace process → kernel buffer → calling process.
This is why AppImage I/O has slightly higher overhead than directly reading a filesystem — there’s an extra userspace hop for every file access. In practice this is negligible for most apps.
This is the section of AppImage history that’s still causing production headaches in 2026.
AppImageKit’s FUSE runtime historically used libfuse2 (the 2.x API). This was the dominant FUSE library for years. Around 2021-2022, libfuse3 became the standard — it’s not backward-compatible at the API level, though the kernel protocol is the same.
Ubuntu 22.04 LTS (Jammy) shipped without libfuse2 installed by default. Only libfuse3 was included. The AppImage runtime links against libfuse2 at build time, so:
# On Ubuntu 22.04 with a pre-2022 AppImage:
./OldApp.AppImage
# Error: cannot open shared object file: libfuse.so.2: No such file or directory
# OR just: Segmentation fault
The fix:
sudo apt install libfuse2
That’s it. One package install. But it breaks the “AppImages just work without installing anything” story, and it surprised a lot of users who didn’t know what FUSE was.
By 2025-2026, most actively maintained AppImages have migrated to the FUSE 3 runtime or added a fuse3 fallback. The cases still requiring libfuse2 tend to be older or unmaintained AppImages, and some legacy scientific computing tools that haven’t been rebuilt since the transition. If you’re building new AppImages, use current AppImageKit tooling — it handles FUSE 3 correctly. If you encounter an older AppImage that still needs libfuse2, the install command is just sudo apt install libfuse2.
Some AppImages also include a --appimage-extract-and-run fallback that extracts the squashfs to a temporary directory and runs without FUSE at all (slower, but works on systems without any FUSE support).
You can use this workaround for any AppImage:
./MyApp.AppImage --appimage-extract-and-run
# Or set the environment variable permanently:
export APPIMAGE_EXTRACT_AND_RUN=1
The AppImage spec includes an optional update mechanism based on zsync — a partial file download algorithm that applies binary diffs over HTTP. This is how AppImages can self-update without downloading the entire file each time.
zsync is essentially rsync-over-HTTP. The basic idea:
.zsync file at a known URL describes the new file as a series of blocks with checksumsFor large AppImages (200MB+), this can reduce update downloads to a few megabytes for minor version bumps that only changed code, not bundled libraries.
AppImages that support updating embed an update_information field in their .desktop file or in the ELF’s custom section. It looks like:
gh-releases-zsync|owner|repo|latest|MyApp-*.AppImage.zsync
This tells AppImageUpdate:
gh-releases-zsync = GitHub releases with zsync)# Use AppImageUpdate to check for updates
appimageupdate --check MyApp.AppImage
# Output: [INFO] update available, new version: 1.2.3
# Apply the update (downloads diff, creates new version)
appimageupdate MyApp.AppImage
Most AppImages don’t include this. The AppImageUpdate tool is optional and rarely used. The official AppImageHub catalog tracks update info for listed apps. AppImageLauncher integrates with this to show update notifications in the tray — when you double-click an AppImage in AppImageLauncher, it registers it and periodically polls for updates.
Understanding how AppImages are built gives you the full picture of what you’re running.
# Minimal AppImage build process:
# 1. Create your AppDir structure
mkdir -p MyApp.AppDir/usr/bin
cp myapp MyApp.AppDir/usr/bin/
cat > MyApp.AppDir/myapp.desktop << EOF
[Desktop Entry]
Name=MyApp
Exec=myapp
Icon=myapp
Type=Application
Categories=Utility;
EOF
cp myapp.png MyApp.AppDir/
# 2. Create AppRun
cat > MyApp.AppDir/AppRun << 'EOF'
#!/bin/bash
exec "$APPDIR/usr/bin/myapp" "$@"
EOF
chmod +x MyApp.AppDir/AppRun
# 3. Bundle dependencies (the painful part)
# linuxdeploy handles this automatically:
linuxdeploy --appdir MyApp.AppDir \
--executable usr/bin/myapp \
--desktop-file MyApp.AppDir/myapp.desktop \
--icon-file myapp.png \
--output appimage
# This produces: MyApp-x86_64.AppImage
linuxdeploy traces the app’s dynamic library dependencies (ldd), copies them into the AppDir, and patches the ELF rpath so the bundled libraries are found. It also handles Qt and GTK plugin deployment via separate plugins.
This is where the “bundle everything” tradeoff becomes concrete: linuxdeploy might find 40+ shared libraries to bundle, adding 50-100MB to the AppImage before compression.
Let’s be honest about what the AppImage security model is and isn’t.
~/.ssh, ~/.gnupg, your entire home directory — all accessible.When you download an AppImage, you are trusting:
This is roughly equivalent to downloading and running a binary from a developer’s website — which is what a lot of users did before universal packages existed. It’s not inherently worse than that baseline, but it’s significantly weaker than a distro package or Flatpak where there’s review infrastructure.
Practical guidance:
appimagetool --sign)--appimage-* CLI FlagsEvery AppImage built with AppImageKit supports these built-in flags:
# Extract the squashfs to a directory (no FUSE needed)
./MyApp.AppImage --appimage-extract
# Creates: squashfs-root/
# Mount the squashfs and show mount path (for debugging)
./MyApp.AppImage --appimage-mount
# Prints: /tmp/.mount_MyApp<hash>
# Ctrl+C to unmount
# Show embedded update info
./MyApp.AppImage --appimage-updateinfo
# Show version of AppImageKit runtime
./MyApp.AppImage --appimage-version
# Run without FUSE (extract to temp dir and exec)
./MyApp.AppImage --appimage-extract-and-run
# Show offset where squashfs starts
./MyApp.AppImage --appimage-offset
These are invaluable for debugging why an AppImage isn’t working. If --appimage-mount fails, you have a FUSE problem. If it succeeds, the issue is in AppRun. If --appimage-extract succeeds but running the extracted binary fails, you have a missing dependency.
AppImageLauncher is what makes AppImages usable as a daily driver. Without it, you’re managing raw files. With it, you get:
Automatic integration: Double-click an AppImage → dialog asks if you want to integrate it into the system. Integrating copies it to ~/Applications/ and creates a .desktop file in ~/.local/share/applications/.
Update checking: Integrated apps are periodically checked for updates using the embedded update URL. The tray icon notifies you.
Removal: Right-click → Remove from the app menu unintegrates the app and removes the file.
binfmt_misc registration: AppImageLauncher registers an interpreter via /proc/sys/fs/binfmt_misc so AppImages execute directly without needing chmod +x. The kernel recognizes the AppImage ELF magic and routes execution through AppImageLauncher.
The binfmt_misc trick is the clever part:
# AppImageLauncher registers something like:
# :AppImage:M:0:\x7fELF...\x41\x49::/usr/lib/AppImageLauncher/runtime:
# This tells the kernel: if ELF file contains AI magic bytes, exec it via the runtime
cat /proc/sys/fs/binfmt_misc/appimage-type2
This is why AppImages “just work” without chmod +x when AppImageLauncher is installed — the binfmt_misc interpreter handles execution before the execute permission check.
A few numbers worth knowing:
Cold start overhead: 200-800ms additional latency vs a native binary, depending on squashfs compression and how many files need to be accessed. This is FUSE overhead + decompression. After first launch, the OS page cache stores the decompressed blocks and subsequent launches are faster.
Warm start: Near-native performance once the squashfs blocks are cached in RAM.
Memory overhead: The squashfuse process adds ~2-5MB RSS. Negligible.
Disk I/O: Higher than native binaries for file reads from the AppImage filesystem. For I/O-heavy operations, consider whether the app should be running from an AppImage at all.
Compression ratio: Typical AppImages achieve 2-4x compression on the squashfs payload. A 200MB installed app might be a 80-100MB AppImage.
apt install squashfs-tools — provides unsquashfsNext: Part 3 — Snap: Anatomy of Canonical’s Distribution System
Andrew Denner — denner.co — @adenner