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.
Not everything needs to be packaged in all three formats. A quick decision guide:
Build an AppImage if:
Build a Snap if:
Build a Flatpak if:
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.
# 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
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
# 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:
ldd to find all dependencies.so files into usr/lib/rpath so the bundled libs are found at runtime.so files for image formats, SQL drivers, etc.)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+.
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.
# 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
# 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/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"
# 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
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 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
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
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).
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.
# 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.
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]
# 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
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 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
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).
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.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.
# 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
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-$
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
| 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) |
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.
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