Build Your Own: Packaging Your App as AppImage, Snap, and Flatpak (part 6)

Posted by Andrew Denner on March 24, 2026 · 29 mins read

Build Your Own: Packaging Your App as AppImage, Snap, and Flatpak

The Complete Developer Guide — From Zero to Shipped

Andrew Denner · March 2026 · denner.co Part 6 of the Linux Universal Packages series


This is the post I wish existed when I first tried to package something. It covers the full workflow for all three formats — not just the happy path, but the real path: the gotchas, the debugging loops, the “why does this work on my machine but not on the build server” moments. The talk slides have the abbreviated versions; this is the extended cut.


Before You Start: Pick Your Format

Not everything needs to be packaged in all three formats. A quick decision guide:

Build an AppImage if:

  • Your app has no deep system integration (no D-Bus services, no systemd units)
  • Your users are on diverse distros or locked-down systems without package managers
  • You want maximum compatibility with minimum infrastructure
  • You just want a single file to hand people

Build a Snap if:

  • Your primary audience is Ubuntu users
  • Your app is a long-running service or daemon
  • You want Canonical’s review process and store infrastructure
  • You’re targeting Ubuntu Core / IoT

Build a Flatpak if:

  • It’s a desktop GUI application
  • You want to reach Fedora, Mint, Arch, and Ubuntu users with one package
  • You care about sandbox transparency (Flatseal support)
  • You want to be on Flathub — the de facto app store for non-Ubuntu Linux desktops

If your app is CLI-only: use your distro’s package manager first. None of these formats are designed for CLI tools and the UX will fight you.


Building AppImages

Prerequisites

# Install build tools
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage

# Optional: plugin for Qt apps
wget https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
chmod +x linuxdeploy-plugin-qt-x86_64.AppImage

# For Python apps
wget https://github.com/niess/linuxdeploy-plugin-python/releases/download/continuous/linuxdeploy-plugin-python-x86_64.AppImage
chmod +x linuxdeploy-plugin-python-x86_64.AppImage

# appimagetool (if you want manual control)
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage

The Manual Method (Understanding the Structure)

Start here before using linuxdeploy — it teaches you what linuxdeploy is doing:

# 1. Create the AppDir
mkdir -p MyApp.AppDir/usr/{bin,lib,share/myapp}

# 2. Copy your binary
cp build/myapp MyApp.AppDir/usr/bin/

# 3. Create the .desktop file (required)
cat > MyApp.AppDir/myapp.desktop << 'EOF'
[Desktop Entry]
Name=MyApp
Exec=myapp
Icon=myapp
Type=Application
Categories=Utility;
Comment=My awesome application
EOF

# 4. Add an icon (required — 256x256 PNG minimum)
cp assets/myapp.png MyApp.AppDir/myapp.png
cp assets/myapp.png MyApp.AppDir/usr/share/myapp/

# 5. Create AppRun (the entry point)
cat > MyApp.AppDir/AppRun << 'APPRUN'
#!/bin/bash
set -e
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
export PATH="${HERE}/usr/bin/:${PATH}"
export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}"
export XDG_DATA_DIRS="${HERE}/usr/share/:${XDG_DATA_DIRS}"
exec "${HERE}/usr/bin/myapp" "$@"
APPRUN
chmod +x MyApp.AppDir/AppRun

# 6. Bundle shared libraries (manual approach)
# Find what your binary needs:
ldd MyApp.AppDir/usr/bin/myapp

# Copy each .so that isn't a basic system library:
# Skip: libm, libc, libpthread, libdl, ld-linux (these are always present)
# Copy: everything else
cp /usr/lib/x86_64-linux-gnu/libssl.so.1.1 MyApp.AppDir/usr/lib/
cp /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 MyApp.AppDir/usr/lib/
# ... etc

# 7. Build the AppImage
./appimagetool-x86_64.AppImage MyApp.AppDir MyApp-1.0-x86_64.AppImage

The linuxdeploy Method (Production Workflow)

# For a compiled app (C/C++, Rust, Go binary):
./linuxdeploy-x86_64.AppImage \
  --appdir MyApp.AppDir \
  --executable build/myapp \
  --desktop-file myapp.desktop \
  --icon-file assets/myapp.png \
  --output appimage

# For a Qt app:
export QMAKE=/usr/lib/qt5/bin/qmake  # Point to your Qt installation
./linuxdeploy-x86_64.AppImage \
  --appdir MyApp.AppDir \
  --executable build/myapp \
  --desktop-file myapp.desktop \
  --icon-file assets/myapp.png \
  --plugin qt \
  --output appimage

# For a Python app:
./linuxdeploy-x86_64.AppImage \
  --appdir MyApp.AppDir \
  --executable /usr/bin/python3 \
  --desktop-file myapp.desktop \
  --icon-file assets/myapp.png \
  --plugin python \
  --output appimage

linuxdeploy automates:

  • Running ldd to find all dependencies
  • Copying .so files into usr/lib/
  • Patching ELF rpath so the bundled libs are found at runtime
  • Handling Qt plugins (.so files for image formats, SQL drivers, etc.)
  • Generating the squashfs and attaching the runtime

The Most Important Rule: Build on Old Linux

The glibc symbol version problem is real and will bite you:

# Check which glibc symbols your binary needs:
objdump -T build/myapp | grep GLIBC | sort -t@ -k2 | tail

# Output example:
# 0000...  GLIBC_2.33  memmove
# 0000...  GLIBC_2.34  pthread_create

If you build on Ubuntu 24.04 (glibc 2.38) and someone tries to run your AppImage on Ubuntu 20.04 (glibc 2.31), they get:

./MyApp.AppImage: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found

The fix: Build on the oldest distro you want to support, or use Docker to target a specific old distro:

# Build inside Ubuntu 20.04 container:
docker run --rm -v $(pwd):/build ubuntu:20.04 bash -c "
  apt-get update -qq
  apt-get install -y build-essential cmake ...
  cd /build
  cmake -B builddir && cmake --build builddir
"
# Now run linuxdeploy on the output

The AppImageKit wiki recommends building on CentOS 7 (glibc 2.17) for maximum compatibility. In practice, Ubuntu 20.04 is the most pragmatic baseline for 2025+.

Adding Update Support

To enable appimageupdate integration, add the update URL to the AppImage at build time:

# For GitHub releases:
UPDATE_INFO="gh-releases-zsync|YourOrg|YourRepo|latest|MyApp-*-x86_64.AppImage.zsync"

./linuxdeploy-x86_64.AppImage \
  --appdir MyApp.AppDir \
  --executable build/myapp \
  --desktop-file myapp.desktop \
  --icon-file assets/myapp.png \
  --updateinformation "$UPDATE_INFO" \
  --output appimage

This embeds the update URL in the AppImage. Users with AppImageLauncher get automatic update notifications. Users with appimageupdate CLI can run delta updates.

Adding GPG Signing

# Sign the AppImage with your GPG key:
./appimagetool-x86_64.AppImage \
  --sign \
  --sign-key YOUR_GPG_FINGERPRINT \
  MyApp.AppDir \
  MyApp-1.0-x86_64.AppImage

# This embeds the signature in an ELF section and creates MyApp-1.0-x86_64.AppImage.digest

Debugging AppImages

# What's inside?
./MyApp.AppImage --appimage-extract
ls squashfs-root/

# Mount it for inspection (no extraction):
./MyApp.AppImage --appimage-mount
# Prints the mountpoint path, Ctrl+C to unmount

# Run the extracted binary directly (bypass FUSE):
squashfs-root/AppRun

# Extract and debug with strace:
./MyApp.AppImage --appimage-extract
strace -e openat squashfs-root/AppRun 2>&1 | grep "No such file"
# Shows every file it's trying to open that doesn't exist

# Check offset (useful for debugging self-extraction):
./MyApp.AppImage --appimage-offset

# Run without FUSE (useful when FUSE isn't available):
APPIMAGE_EXTRACT_AND_RUN=1 ./MyApp.AppImage

GitHub Actions CI for AppImages

# .github/workflows/appimage.yml
name: Build AppImage

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-20.04  # Use older Ubuntu for glibc compatibility!
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: sudo apt-get install -y cmake build-essential libssl-dev

      - name: Build
        run: cmake -B build && cmake --build build

      - name: Download linuxdeploy
        run: |
          wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
          chmod +x linuxdeploy-x86_64.AppImage

      - name: Build AppImage
        run: |
          ./linuxdeploy-x86_64.AppImage \
            --appdir MyApp.AppDir \
            --executable build/myapp \
            --desktop-file myapp.desktop \
            --icon-file assets/myapp.png \
            --output appimage

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: appimage
          path: "*.AppImage"

Building Snaps

Prerequisites

# Install snapcraft
sudo snap install snapcraft --classic

# Initialize LXD (snapcraft uses it for clean builds)
sudo snap install lxd
sudo lxd init --minimal
sudo usermod -aG lxd $USER
newgrp lxd

The snapcraft.yaml

Snapcraft builds from a single snapcraft.yaml in your project root (or in snap/snapcraft.yaml). Here’s a complete example:

name: myapp
base: core22          # Ubuntu 22.04 LTS runtime
version: '1.0.0'
summary: My App — one line description
description: |
  Extended description of what your app does.
  Can be multiple paragraphs.

grade: stable         # or 'devel' for non-release
confinement: strict   # 'devmode' for development, 'classic' for full system access

apps:
  myapp:
    command: bin/myapp
    plugs:
      - home           # Access ~/
      - network        # Network access
      - wayland        # Wayland display
      - x11            # X11 fallback
      - desktop        # Desktop environment integration
      - audio-playback # PulseAudio/PipeWire (for apps with sound)

parts:
  myapp:
    plugin: cmake
    source: .
    cmake-parameters:
      - -DCMAKE_INSTALL_PREFIX=/
      - -DCMAKE_BUILD_TYPE=Release
    build-packages:
      - libssl-dev
      - pkg-config
    stage-packages:
      - libssl3         # Runtime libraries (from Ubuntu 22.04 repos)

Build Commands

# Build the snap (downloads base, builds in LXD VM):
snapcraft

# Output: myapp_1.0.0_amd64.snap

# Install locally for testing:
sudo snap install myapp_1.0.0_amd64.snap --dangerous

# Test it:
snap run myapp

# Check what's blocked (AppArmor denials):
snap logs myapp

# Rebuild just a specific part (faster during development):
snapcraft clean myapp
snapcraft build myapp

# Debug shell inside the build environment:
snapcraft prime --debug  # drops you to a shell before packaging

Confinement Debugging

The most common snap development loop:

# 1. Start with devmode (no confinement):
# In snapcraft.yaml: confinement: devmode

# 2. Install and test:
sudo snap install myapp_1.0.0_amd64.snap --dangerous --devmode

# 3. Watch AppArmor denials:
sudo journalctl -f | grep DENIED

# 4. Map denials to interfaces
# If you see: /run/user/1000/wayland-0 denied → need 'wayland' plug
# If you see: /home/user/.config/ denied → need 'home' plug
# If you see: network denied → need 'network' plug

# 5. Add plugs to snapcraft.yaml, rebuild:
snapcraft

# 6. Install in strict mode:
sudo snap install myapp_1.0.0_amd64.snap --dangerous
snap run myapp

# 7. Repeat until it works, then set confinement: strict

The Classic Confinement Option

For developer tools that genuinely need full filesystem access (editors, IDEs, compilers):

confinement: classic

Classic snaps bypass AppArmor confinement entirely — they can access the full filesystem. Publishing a classic snap to the Snap Store requires manual review and justification. Not a rubber stamp; Canonical actually reviews the request.

Examples of legitimate classic snaps: VS Code, JetBrains IDEs, the various language SDKs (Go, .NET, Node).

Multi-App Snaps (Daemons + CLI)

Snaps can bundle multiple apps — useful for a service with a management CLI:

apps:
  myapp-daemon:
    command: bin/myappd
    daemon: simple        # or 'forking', 'oneshot', 'notify'
    restart-condition: on-failure
    plugs: [network]

  myapp-cli:
    command: bin/myapp-cli
    plugs: [home, network]

The daemon app runs as a systemd service. snap start myapp.myapp-daemon and snap stop myapp.myapp-daemon control it.

Publishing to the Snap Store

# Register your app name (one-time):
snapcraft register myapp

# Login to the store:
snapcraft login

# Upload:
snapcraft upload myapp_1.0.0_amd64.snap --release stable

# Or release a specific revision to a channel:
snapcraft release myapp 1 stable      # revision 1 → stable
snapcraft release myapp 1 candidate   # same revision to candidate

# Check status:
snapcraft status myapp

Snap Store review process: Automated checks run on upload. Strict snaps with common interfaces auto-approve. Snaps using sensitive interfaces (browser-support, classic confinement, etc.) queue for manual review. Typically 1-5 business days.

snapcraft.yaml Patterns

Python application:

parts:
  myapp:
    plugin: python
    source: .
    python-requirements:
      - requirements.txt

Electron application:

parts:
  myapp:
    plugin: npm
    source: .
    npm-include-node: true
    npm-node-version: '20.0.0'

Go application:

parts:
  myapp:
    plugin: go
    source: .
    build-snaps: [go]

Building Flatpaks

Prerequisites

# Install flatpak and flatpak-builder
sudo apt install flatpak flatpak-builder    # Debian/Ubuntu
sudo dnf install flatpak flatpak-builder    # Fedora/RHEL

# Add Flathub (if not already added)
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo

# Install the GNOME SDK (or whichever runtime you'll use):
flatpak install org.gnome.Sdk//47
flatpak install org.gnome.Platform//47

# For KDE apps:
flatpak install org.kde.Sdk//6.8
flatpak install org.kde.Platform//6.8

The Manifest

Flatpak manifests can be JSON or YAML. YAML is easier to read and maintain:

# com.example.MyApp.yaml
app-id: com.example.MyApp

# The runtime provides the base libraries (GTK, Qt, etc.)
runtime: org.gnome.Platform
runtime-version: '47'

# The SDK is the build-time equivalent of the runtime
sdk: org.gnome.Sdk

# Entry point after installation
command: myapp

# Sandbox permissions
finish-args:
  - --share=network          # Allow network access
  - --socket=wayland         # Wayland display protocol
  - --socket=fallback-x11    # X11 fallback for non-Wayland compositors
  - --device=dri             # GPU access (hardware rendering)
  - --share=ipc              # Shared memory (required for X11)
  - --filesystem=home        # Access home directory (use sparingly)

# Modules: your app and its non-runtime dependencies
modules:
  # Optional: build a library dep that isn't in the runtime
  - name: libfoo
    buildsystem: cmake-ninja
    sources:
      - type: git
        url: https://github.com/example/libfoo.git
        tag: v2.1.0
        commit: abc123def456...   # Always pin a commit, not just a tag

  # Your main application
  - name: myapp
    buildsystem: cmake-ninja
    sources:
      - type: git
        url: https://github.com/yourorg/myapp.git
        tag: v1.0.0
        commit: def456abc123...

Build and Test Cycle

# Build and install locally (--user = user installation, no root needed):
flatpak-builder --install --user --force-clean builddir com.example.MyApp.yaml

# Run it:
flatpak run com.example.MyApp

# Debug: drop into a shell inside the sandbox
flatpak run --command=bash com.example.MyApp

# Debug: inspect the sandbox from inside
cat /run/user/$(id -u)/flatpak/app/com.example.MyApp/.flatpak-info

# Rebuild just to check the manifest without installing:
flatpak-builder --force-clean builddir com.example.MyApp.yaml

# Check logs when something goes wrong:
journalctl -f --user  # In another terminal while running the app

Handling Dependencies Not in the Runtime

The tricky part: any library your app needs that isn’t in the GNOME Platform (or KDE Frameworks) must be included as a module in your manifest.

No network during build: flatpak-builder enforces this. All sources must be declared with checksums.

modules:
  - name: libsodium
    buildsystem: autotools
    sources:
      - type: archive
        url: https://download.libsodium.org/libsodium/releases/libsodium-1.0.18.tar.gz
        sha256: 6f504490b342a4f8a4c4a02fc9b866cbef8622d5df4e5452b46be121e46636c1

  - name: libzmq
    buildsystem: cmake-ninja
    sources:
      - type: archive
        url: https://github.com/zeromq/libzmq/releases/download/v4.3.4/zeromq-4.3.4.tar.gz
        sha256: c593001a89f5a85dd2ddf564805deb860e02471171b3f204944857336295c3e5

The flatpak-builder-tools helpers save enormous time for language ecosystems:

# For Python apps (pip dependencies):
git clone https://github.com/flatpak/flatpak-builder-tools
cd flatpak-builder-tools/pip

python3 flatpak-pip-generator requests pillow numpy \
  --output python-deps.json

# Add to manifest:
# modules:
#   - python-deps.json
#   - name: myapp
#     ...

Similar tools exist for npm, cargo (Rust), and Maven/Gradle (Java).

Choosing the Right Permissions

The hardest part of Flatpak packaging is getting permissions right. Start restrictive and add only what’s needed:

finish-args:
  # Start with these for a GUI app:
  - --socket=wayland
  - --socket=fallback-x11
  - --share=ipc
  - --device=dri

  # Add as needed — test before adding:
  - --share=network        # Does the app need the internet?
  - --filesystem=home      # Does it need to open arbitrary files?
  - --filesystem=xdg-documents  # Better: scope to specific dirs
  - --socket=pulseaudio    # Does it play audio?
  - --device=all           # Only for hardware-specific apps

  # D-Bus access (be specific):
  - --talk-name=org.freedesktop.Notifications  # System notifications
  - --talk-name=org.gnome.keyring.SystemPrompter  # Keyring access
  - --own-name=com.example.MyApp   # If the app exports a D-Bus service

Common mistakes:

  • --filesystem=host — grants access to the entire filesystem. Acceptable for file managers, unacceptable for most apps. Flathub reviewers will ask you to justify this.
  • --socket=session-bus — grants access to the entire D-Bus session bus. Only use for apps that legitimately need broad D-Bus access.
  • --share=ipc — required for X11 apps but not for Wayland-only apps. Include it in the fallback case.

The App ID and Reverse Domain Convention

Flatpak uses reverse-domain app IDs. The rules:

com.YourDomain.AppName        → If you own yourdomain.com
org.YourOrg.AppName           → For non-profits/open-source orgs
io.github.youruser.AppName    → For GitHub-hosted projects without custom domain

Flathub verifies domain ownership for published apps. For io.github.* IDs, Flathub verifies your GitHub username via OAuth during submission.

Why it matters: the app ID becomes the Flatpak directory structure, the D-Bus service name (if any), and the identity in Flatseal’s permission UI. Changing it later requires users to reinstall.

Publishing to Flathub

# 1. Test your manifest builds cleanly with no network errors:
flatpak-builder --force-clean --sandbox builddir com.example.MyApp.yaml

# 2. Fork github.com/flathub/flathub

# 3. Create com.example.MyApp/com.example.MyApp.yaml in your fork
#    (or com.example.MyApp/com.example.MyApp.json for JSON)

# 4. The Flathub CI will run automatically on your PR:
#    - Linting your manifest
#    - Building the app
#    - Running basic smoke tests
#    - Checking for common issues (network access during build, etc.)

# 5. Human reviewers check:
#    - App ID is appropriate
#    - Permissions are reasonable
#    - Sources are verifiable (no bare HTTP, commit hashes required)
#    - No proprietary bundled binaries for open-source apps

# 6. After approval, you get a dedicated repo under github.com/flathub/
#    Future updates: push to your app's repo, Flathub CI builds and publishes

CI/CD for Flatpaks

Flathub handles CI for Flathub-published apps. For testing in your own repo:

# .github/workflows/flatpak.yml
name: Flatpak CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: bilelmoussaoui/flatpak-github-actions:gnome-47
      options: --privileged

    steps:
      - uses: actions/checkout@v4

      - name: Build Flatpak
        uses: flatpak/flatpak-github-actions/flatpak-builder@v6
        with:
          bundle: MyApp.flatpak
          manifest-path: com.example.MyApp.yaml
          cache-key: flatpak-builder-$

Updating Your App After Flathub Publication

After initial approval, you maintain a GitHub repo under github.com/flathub/com.example.MyApp:

# To update: edit the manifest in your Flathub repo
# Change the commit hash (and url/tag if applicable):
modules:
  - name: myapp
    sources:
      - type: git
        url: https://github.com/yourorg/myapp.git
        tag: v1.1.0
        commit: newcommit123...  # Update this

# Open a PR → CI runs → merges → Flathub builds and publishes
# No per-update manual review once you're established

Side-by-Side Comparison: The Developer Experience

Aspect AppImage Snap Flatpak
Setup time 30 min 1-2 hours 1-3 hours
Build tool linuxdeploy snapcraft flatpak-builder
Build isolation None (build on host) LXD/Multipass VM Sandbox (strict)
Dependency strategy Bundle everything Bundle + staged debs/rpms Runtime + modules
CI complexity Low Medium Medium-High
Publishing Self-hosted / GitHub releases Snap Store (Canonical) Flathub (community)
Review required No Yes (Snap Store) Yes (Flathub)
Store fee Free Free (open source) Free
Update mechanism Manual/zsync Auto (snapd) Auto/manual
glibc concerns Yes (build on old distro) No (base snap handles it) No (runtime handles it)

The Hardest Part of Each Format

AppImage: The glibc floor problem and bundling the right — but not too many — shared libraries. Under-bundling causes crashes on other distros. Over-bundling (bundling things like libGL that must match the host GPU driver) also causes crashes. Finding the right set is a process of test, fail, adjust.

Snap: Getting confinement right without either (a) opening everything up with classic, or (b) spending days mapping AppArmor denials to interface names. The interface reference documentation is good; reading it is mandatory. The other hard part: LXD setup is fiddly, and if your dev machine is itself a VM you’re running nested virtualization.

Flatpak: The no-network-during-build constraint is the real pain. When your app has 47 Python dependencies, 15 npm packages, and 3 compiled C libraries, generating and maintaining the source manifest for all of them is significant overhead. The flatpak-builder-tools scripts help but require maintenance. The payoff is reproducible, auditable builds — but you earn it.


Resources

AppImage:

Snap:

Flatpak:


This concludes the Linux Universal Packages series. Full series index at denner.co/blog. Talk materials at denner.co/talks.

Andrew Denner — denner.co — @adenner