Manual Flatpak Manifest Configuration for Complex Dependency Chains

Package Formats8 min

Table of Contents

  1. The Manifest Writes /app Before Any Binary Exists
  2. List Modules Bottom-Up, the Way the Linker Will Walk Them
  3. One SONAME Per Path Inside the Prefix
  4. Write the Recipe the Tarball Never Shipped
  5. Install Into a User Repo and Read ldd There
  6. A Kitchen Clock, a Scene-Cutter, Three Private Libraries

The Manifest Writes /app Before Any Binary Exists

A tangled library chain lives or dies on module order, on how each shared object is installed and cleaned, and on a local flatpak-builder install before any store upload. I start every hand-written manifest from that verdict.

The file is YAML or JSON. It names the app-id, the runtime, the SDK, the command that launches the binary, the finish-args that punch holes in the sandbox, and the ordered modules that populate /app. Those fields are the build contract. Everything the user later runs is a consequence of that contract, including the privacy & security surface you expose through finish-args.

For an app the runtime already satisfies, most of those keys stay still. Complex trees move three of them. modules is the compile graph. build-options is where I pin env, prepend-path, prepend-pkg-config-path, and prepend-ld-library-path so a mid-layer can see the leaf I just dropped into the prefix. cleanup is the broom that keeps /app/lib from becoming a junk drawer of static archives and libtool leftovers.

The official Flatpak manifest format documents every key. I treat that page as the schema. This note covers the part the schema will not decide for you: how to sequence a chain the chosen runtime cannot satisfy, and how to keep the resulting package formats from fighting the host.

Contract Fields That Move

When a tree gets tangled, edit modules, build-options, and cleanup first. app-id, runtime, and command almost never repair a missing.pc or a shadowed SONAME.

List Modules Bottom-Up, the Way the Linker Will Walk Them

Each module is one isolated configure-build-install into the same /app prefix. flatpak-builder executes that list strictly in order. There is no implicit topological sort. If the CMake mid-layer needs a.pc file, that file has to exist in /app before that module starts.

I put the application module at the top of the first manifest I wrote for a chain like this. The build died on the first configure pass. Headers and.pc files from the leaf C library and the CMake mid-layer were still absent from the prefix. I flipped the list into a bottom-up compile graph and left it that way.

The Prefix Is the Only Edge Between Modules

The working order looks like this. Leaf C library first. CMake mid-layer second, consuming the leaf's.pc. Application module last, linking both. When the leaf refuses to emit a.pc, a post-install script in that same module drops a correct file into /app/lib/pkgconfig before the next module runs.

I keep the application module last even when I am impatient to see a binary. The prefix is the only communication channel between modules. Order is how you write to it.

Order is the ABI between modules. The prefix is the bus. Nothing else crosses that boundary.

Image showing module graph

Fields That Encode the Graph

The graph lives in a handful of keys. sources is an archive, a git tag, or a dir. buildsystem is simple, autotools, cmake-ninja, or meson. config-opts carries the flags the upstream build expects. build-commands takes over when the upstream script is nonstandard and the named buildsystem cannot express the install.

I write those fields as if a stranger will rebuild the tree around six months later. A dir source is fine on my laptop. An archive with a pinned checksum is what I commit. Git tags belong in the manifest only when I also pin the commit, because a moving tag rebuilds a different leaf under the same module name.

One SONAME Per Path Inside the Prefix

Two modules that ship the same SONAME produce a binary that starts and then dlopen-fails inside the sandbox. A bundled copy that shadows a runtime library does the same thing. The process comes up. The first plugin load walks the wrong.so. The sandbox has no host fallback that will save you.

Runtime Shadow Risk

A private lib that reuses a runtime SONAME will win the search path and then fail ABI checks at dlopen. Prefer the runtime copy of glibc-adjacent and GNOME/KDE stack libraries. Build a library as a module only when the runtime cannot satisfy the required ABI.

I prefer the runtime's copy of anything that sits next to glibc or belongs to the GNOME or KDE stacks that desktop environments already ship. Those libraries are why I pick a runtime in the first place during distribution reviews of package formats. A private rebuild of them inside /app is how you invent a collision.

Leave /app/lib Intentionally Short

cleanup and cleanup-commands strip the rest. I keep the list boring on purpose:

  • static archives (*.a)
  • libtool.la files
  • includedir headers the later modules no longer need
  • stray binaries the leaf's make install dropped into /app/bin

What remains in /app/lib should be a short, intentional list of.so files you actually link. prepend-ld-library-path and an rpath isolate those private objects from runtime resolution. That scopes the search path. It does not harden the sandbox against malicious code. finish-args still decide the privacy & security boundary; the library path only decides which file the dynamic linker opens.

Write the Recipe the Tarball Never Shipped

Plenty of libraries that show up in media tools exist only as a tarball or a git tag. No shared module on the runtime. No upstream recipe. You write the nested module by hand.

Pin the Tarball, Then Pick a Buildsystem

  1. Pin the source URL and the sha256. A floating tag will rebuild a different tree on a later build.
  2. Choose cmake-ninja, meson, or a simple build-commands block. I pick cmake-ninja when the project already has a CMakeLists that respects CMAKE_INSTALL_PREFIX. I pick meson when meson.build is the native file. I drop to build-commands when the inner script is Autotools hiding under a Meson wrapper, and I run that configure so make install targets /app.
  3. Pass prefix=/app. Every other prefix leaks into the SDK and disappears at runtime.

Path Variables Stay on the Consumer

A mid-layer that expects CMAKE_PREFIX_PATH or PKG_CONFIG_PATH needs those values on that module's build-options. Host paths do not inherit into the next module. I set prepend-pkg-config-path and the matching env keys on the consumer, then I stop hoping the SDK's /usr/lib/pkgconfig will answer for a private leaf.

Path Variables Stay on the Consumer

Own-Module Default

Vendoring a snapshot inside the app module works for a single consumer. Give the library its own module once two consumers need the same.so. The separate module installs once, writes one.pc, and keeps the cleanup list honest.

For nested Autotools under Meson, the inner configure runs from build-commands. That is the only reliable way I have found to force the install prefix when the outer buildsystem will not forward it. The same pattern shows up in a few web tools that wrap an old C library behind a newer meson.build; the wrapper lies about prefix unless you execute configure yourself.

Install Into a User Repo and Read ldd There

A leftover /app from an earlier build will lie to you. I run a clean local build with flatpak-builder --force-clean, a dedicated ostree repo, and --install into a user installation. That combination is the only test I trust before I commit the manifest.

Force a Clean Ostree, Then Open a Shell

Then I enter the sandbox.

flatpak run --command=sh drops me into the real tree. From there the inspection is mechanical:

  • ldd on the main binary
  • ls of /app/lib
  • pkg-config --exists for every private library I built

I want ldd to list /app paths and runtime paths. A line that still points at the SDK's /usr/lib is a link that will vanish for the user. A missing.so at this stage is a manifest bug: wrong module order, cleanup that ate the library, or a binary still linked against /usr/lib from the SDK. I open the YAML.

This is also where I catch the cleanup that was too proud of itself. Stripping includedir is correct for the shipped bundle. Stripping it before a later module runs its configure recreates the first failure mode in a new costume. I keep headers until the last consumer finishes, then let the top-level cleanup array take them.

A Kitchen Clock, a Scene-Cutter, Three Private Libraries

Friday, around 23:40. Mara stands in a quiet office kitchen with a spare laptop and a mug that went cold an hour ago. She needs a small scene-cutter: a patched libavfilter, a private color-science library, and a Qt front end. The host ffmpeg on that laptop is the wrong ABI. The three libraries have been fighting it all week.

The leaf module finishes. She waits on the mid-layer. The second module's.pc file appears under /app/lib/pkgconfig. The app module links. She steps into the sandbox and runs ldd. Every line is /app or the runtime. Host ffmpeg is gone from the map.

She installs one user Flatpak on the spare laptop. The three libraries sit in /app/lib and leave the host alone. She commits the manifest beside the source tag, closes the lid, and the kitchen lights stay on for another minute while the ostree repo settles.

Stay Updated

Be the first to know.

We respect your privacy. No spam.

Your Thoughts

Nothing here yet. Add your opinion.

Write a Comment

Your cookie choices