AppImage Deep Dive: One File, No Illusions (part 2)

Posted by Andrew Denner on March 21, 2026 · 21 mins read

AppImage Deep Dive: One File, No Illusions

The Complete Technical Picture

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.


What an AppImage Actually Is at the Byte Level

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.

The Runtime Binary

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:

  1. Find its own file path (/proc/self/exe)
  2. Mount the squashfs payload that follows it in the same file
  3. Find and execute the AppRun script inside that squashfs
  4. Wait for AppRun to exit, then unmount and clean up

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.

ELF Header Inspection

# 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.


The squashfs Filesystem

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:

  • Read-only: squashfs is always compressed and always read-only. No exceptions.
  • Block-compressed: data is compressed in blocks (typically 128KB). Random access to compressed data requires decompressing the block containing your data. This is why squashfs-based formats have slightly higher latency on first access.
  • Supported compression: gzip, lzma, lzo, xz, zstd. AppImages typically use gzip or xz. xz gives better compression at the cost of slower decompression.
  • Directory structure: standard POSIX filesystem with inodes, directory entries, symlinks, device nodes.

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

The AppDir Layout

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.


FUSE Mounting: What Actually Happens at Runtime

When you execute an AppImage, the runtime uses FUSE (Filesystem in Userspace) to mount the squashfs payload. Here’s the sequence:

  1. Runtime binary starts execution
  2. It calls fuse_main() (or equivalent) with the squashfs offset as the source
  3. The kernel FUSE driver creates a virtual mountpoint at /tmp/.mount_<appname><random>/
  4. The squashfs is accessible at that path
  5. Runtime exec()s $MOUNTPOINT/AppRun
  6. AppRun runs (the application executes)
  7. When AppRun exits, FUSE unmounts and cleans up

You 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.

Why AppImage Doesn’t Need Root (and What That Costs)

Traditional mount requires CAP_SYS_ADMIN. FUSE gets around this because:

  1. The FUSE kernel module exposes /dev/fuse
  2. Any user can open /dev/fuse and register a FUSE filesystem server
  3. The kernel routes filesystem operations to that server via the /dev/fuse file descriptor

The 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.


The libfuse2/libfuse3 Incident

This is the section of AppImage history that’s still causing production headaches in 2026.

Background

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.

The Longer-Term Fix

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

AppImage Update Protocol: zsync

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.

How zsync Works

zsync is essentially rsync-over-HTTP. The basic idea:

  1. The new version of the file is available at a URL
  2. A .zsync file at a known URL describes the new file as a series of blocks with checksums
  3. The client reads its existing file and computes which blocks are already correct
  4. Only the changed blocks are downloaded
  5. The client reassembles the new file from old blocks + downloaded new blocks

For large AppImages (200MB+), this can reduce update downloads to a few megabytes for minor version bumps that only changed code, not bundled libraries.

The Update URL Embedded in the AppImage

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:

  • The update mechanism (gh-releases-zsync = GitHub releases with zsync)
  • Where to find the latest release
  • Which file pattern matches the update
# 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.


Building AppImages: appimagetool

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.


Security: The Actual Risk Surface

Let’s be honest about what the AppImage security model is and isn’t.

What AppImage Does NOT Provide

  • No sandbox: AppImages run with your full user permissions. ~/.ssh, ~/.gnupg, your entire home directory — all accessible.
  • No code signing verification: The ELF + squashfs structure has no built-in signature verification. AppImageKit supports GPG signatures embedded in the image, but checking them is not enforced and most apps don’t implement it.
  • No update integrity checking: zsync downloads use checksums for file integrity, but there’s no signing of the release itself beyond whatever the hosting platform provides (GitHub releases, for example, doesn’t sign artifacts by default).
  • No AppArmor/seccomp: Unlike Snap, there’s no mandatory access control applied to AppImage processes.

What AppImage DOES Provide

  • Isolation by convention: The app runs from a read-only squashfs. It can’t modify its own code at runtime. This isn’t security — the app can still write anywhere in your home directory — but it does mean the app files themselves aren’t modified.
  • System isolation: The app’s files don’t mix with your system’s files. There’s no risk of an AppImage install breaking system packages.
  • Reproducibility: You know exactly what version you’re running and it will never change unless you download a new file. This is the opposite of Snap’s auto-update, and for certain security contexts it’s actually preferable.

The Trust Model

When you download an AppImage, you are trusting:

  1. The developer who built it
  2. The infrastructure that distributed it (GitHub releases page, developer’s website, etc.)
  3. Your connection to that infrastructure (HTTPS protects against in-transit modification)

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:

  • Download from the project’s official GitHub releases page or official website only
  • Verify SHA256/SHA512 checksums when provided (many projects publish them)
  • Prefer projects that embed GPG signatures in their AppImages (appimagetool --sign)
  • Check the AppImageHub catalog for community-vetted apps

The --appimage-* CLI Flags

Every 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: The Missing Piece

AppImageLauncher is what makes AppImages usable as a daily driver. Without it, you’re managing raw files. With it, you get:

  1. 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/.

  2. Update checking: Integrated apps are periodically checked for updates using the embedded update URL. The tray icon notifies you.

  3. Removal: Right-click → Remove from the app menu unintegrates the app and removes the file.

  4. 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.


Performance Characteristics

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.



Next: Part 3 — Snap: Anatomy of Canonical’s Distribution System

Andrew Denner — denner.co — @adenner