The Linux counterpart of winbuildenv: a container that builds applications which run on any reasonably current Linux, from AlmaLinux 9 to Arch, with the whole library set, Qt and the toolchain baked in and a deployment tool that lays the result out under /opt/<appname>.
  • Shell 39.9%
  • CMake 26.3%
  • Python 19.8%
  • Dockerfile 13.9%
  • C 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Michael J. Manley 56eccb3e55
Some checks failed
image / image (push) Has been cancelled
Fix wxWidgets underlinking, harden check-env, add the CI workflow
wxQt shipped underlinked. build/cmake/init.cmake turns wxUSE_DETECT_SM on
whenever `pkg-config sm` resolves - and it resolves here regardless, since
libSM-devel is part of the system stack Qt needs - so apptraits.cpp's
SmcOpenConnection was compiled into libwx_qtu_core while nothing linked -lSM:
SM_LIBRARIES is set in init.cmake and read by no target anywhere. wxGTK never
notices because find_package(X11) drags the client libraries in sideways; wxQt
takes that branch never, and every program that linked the result died on
`undefined reference to SmcOpenConnection'. The overlay port passes
wxUSE_DETECT_SM=OFF (port-version 2), which costs only the session-manager
fallback in GetDesktopEnvironment() - reached only when XDG_CURRENT_DESKTOP is
empty. Linking -lSM instead would put libSM.so.6 in the NEEDED list of every wx
program out of this image.

check-env.sh grows the check that would have caught it - a `nm -D` pass for
undefined Smc*/Ice* in any libwx_*.so - and two fixes where it was reporting
things that were not true:

- find_package leaks the package's own variables into the caller's scope, and
  wxWidgetsConfig.cmake ends on `string(SUBSTRING ${libname} 2 -1 name)`. With
  a loop variable called `name` the pass came back reporting a package nobody
  asked for as missing and saying nothing about wxWidgets. One package per
  function call now.
- `objdump -p | grep -q` under `set -o pipefail` reads a non-zero objdump exit
  - including the SIGPIPE grep -q can cause - as the NEEDED entry being absent,
  with stderr discarded. It reported a wx/Qt linkage problem that did not exist,
  on one build of two identical cached layers. Output and exit status are taken
  separately now, and a tool that could not run says so. Same for the Qt/OpenSSL
  check, where four different failures arrived as the rarest one's message.

setvars.sh walks tools/<port>[/bin] for directories holding an executable
rather than listing them: vcpkg's layout is not uniform, and that skips the
debug copies and ICU's config/ without naming either.

.forgejo/workflows/build-image.yml builds the image on the docker (DinD) runner
label, smoke-tests it and pushes :latest plus a commit tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XwW1XJbzrUwdiE95BfesF
2026-09-12 15:08:36 -07:00
.forgejo/workflows Fix wxWidgets underlinking, harden check-env, add the CI workflow 2026-09-12 15:08:36 -07:00
cmake First Commit 2026-08-31 22:13:10 -07:00
examples Add .NET 10 to the image 2026-08-31 22:29:28 -07:00
overlay-ports Fix wxWidgets underlinking, harden check-env, add the CI workflow 2026-09-12 15:08:36 -07:00
overlay-triplets First Commit 2026-08-31 22:13:10 -07:00
scripts Fix wxWidgets underlinking, harden check-env, add the CI workflow 2026-09-12 15:08:36 -07:00
vcpkg-qt Add gdb and the SDL2/OpenAL/wxWidgets/libslirp/libpcap set 2026-09-04 10:55:59 -07:00
.dockerignore First Commit 2026-08-31 22:13:10 -07:00
.gitignore First Commit 2026-08-31 22:13:10 -07:00
build-image.sh First Commit 2026-08-31 22:13:10 -07:00
Dockerfile Add gdb and the SDL2/OpenAL/wxWidgets/libslirp/libpcap set 2026-09-04 10:55:59 -07:00
README.md Fix wxWidgets underlinking, harden check-env, add the CI workflow 2026-09-12 15:08:36 -07:00
test-portability.sh First Commit 2026-08-31 22:13:10 -07:00
vcpkg.json Add gdb and the SDL2/OpenAL/wxWidgets/libslirp/libpcap set 2026-09-04 10:55:59 -07:00

linuxbuildenv — portable Linux build environment

The Linux counterpart of winbuildenv: a container that builds applications which run on any reasonably current Linux, from AlmaLinux 9 to Arch, with the whole library set, Qt and the toolchain baked in and a deployment tool that lays the result out under /opt/<appname>.

base AlmaLinux 9
ABI baseline glibc 2.34, GLIBCXX_3.4.29, CXXABI_1.3.13
compiler gcc-toolset-14 (gcc 14.2, C++23)
debugger gdb 16.3, natively
libraries /opt/wiselibs, shared and static, built from source
Qt /opt/qt, 6.11.2, shared, built from source
.NET /opt/dotnet, SDK 10.0.400 (runtime 10.0.11, LTS)
Node.js /opt/node, 24.20.0 LTS

Why AlmaLinux 9

A dynamically linked ELF runs on any system whose glibc is at least the one it was built against, and on no system older than that. So the base distribution is not a preference, it is the floor: building on AlmaLinux 9 is what makes one set of binaries run on AL9, RHEL and Rocky 9, Debian 12 and 13, Ubuntu 22.04 onwards, Fedora and Arch alike. Building on Arch and hoping produces binaries that run on Arch.

AL9's own compiler is gcc 11.5, which is not a C++23 compiler, so the build uses gcc-toolset-14. That works without raising the floor because the toolset ships no libstdc++.so of its own: symbols newer than the base system's libstdc++ are linked in statically from libstdc++_nonshared.a. A C++23 binary built here — std::format, std::jthread, ranges — still asks only for GLIBCXX_3.4.29, and still runs on a stock AL9 machine.

That is checked, not assumed. scripts/check-baseline.sh reads a binary's version references and compares them against what this container's own glibc and libstdc++ provide:

check-baseline.sh dist/            # a whole tree
check-baseline.sh --baseline       # just the numbers

The image's last build step compiles a C++23 program and runs it through this, so an image that could produce non-portable binaries fails to build. It is also run automatically at the end of every wisedeploy.

Proving it

check-baseline.sh is an argument about symbol versions. test-portability.sh is the experiment: it takes a deployed tree, drops it into a stock container of each target distribution — no build tools, no Qt, no OpenSSL 3.6, nothing this image put anywhere — and runs it.

./test-portability.sh /tmp/deploy-out/wisedemo-linux-x86_64.tar.gz wisedemo/bin/smoke

examples/smoke, built once here and deployed with wisedeploy, was verified to run unmodified on all of:

almalinux:9 rockylinux:9 debian:12 debian:trixie
ubuntu:22.04 ubuntu:24.04 fedora:41 archlinux:latest

reporting openssl 3.6.3, sqlite3 3.53.4, libsodium 1.0.22, lua 5.5.0, zxing-cpp 3.1.1 on every one of them — the pinned versions it was built against, not whatever the host distribution happens to ship.

One consequence worth knowing: the image deliberately does not set LD_LIBRARY_PATH to the toolset's lib64 the way /opt/rh/gcc-toolset-14/enable would. There is nothing there to find, and leaving it unset keeps the container honest — anything that runs in here runs on the baseline, because in here is the baseline.

Libraries

argon2, gtest, liblzma, libmariadb[iconv], libpcap, libpq[lz4,openssl,zlib], libslirp, libsodium, lua, lz4, openal-soft, openssl, pugixml, sdl2, sqlite3[omit-load-extension], wxwidgets, zlib, zxing-cpp — plus icu, which winbuildenv gets from Windows and this image has to build.

The same list as winbuildenv, from the same pinned vcpkg commit and the same version overrides, so "openssl 3.6.3 on both platforms" is a fact rather than a coincidence. Two triplets:

triplet
x64-linux-dynamic shared $LIBS_ROOT, the default
x64-linux-static static, -fPIC $LIBS_STATIC_ROOT

-fPIC on the static tree is the one Linux-specific decision in the set: a static archive built without it cannot be linked into a shared library, and every product here that ships a .so does exactly that. Without it the failure is a link error deep inside the consumer — "relocation R_X86_64_32 against .rodata can not be used when making a shared object" — which reads like a bug in the consumer and is not.

Two manifests, because of build order

vcpkg.json is not the whole set. wxWidgets is built with the wxQt backend against this image's own Qt (below), so it cannot be built until /opt/qt exists, and Qt in turn is built against openssl, icu, sqlite, libpq and libmariadb from the first pass. So the set is built in two passes and merged into the same trees:

vcpkg.json everything Qt is built against, and everything that does not care — the libs stage
vcpkg-qt/vcpkg.json wxWidgets alone — the qtlibs stage, after Qt

Both use the same overlay-ports/ and overlay-triplets/, and the second pass runs build-libs.sh with MERGE=1 so it adds to the triplet trees rather than replacing them. Everything downstream — $LIBS_ROOT, the toolchain file, find_package — sees one tree and never has to know.

Overlay ports

overlay-ports/ carries the two ports the Windows side carries, copied verbatim so the two environments stay in step, plus one that exists only here:

  • zxing-cpp 3.1.1 — not in vcpkg mainline, which renamed the port and left it at 2.3.0.
  • libmariadb 3.4.8 — the patch that matters on Windows, a missing STDCALL on mysql_load_plugin, is a no-op here since STDCALL expands to nothing off Windows; the port is carried anyway so the version and the patch set are decided in one place.
  • wxwidgets 3.3.3 — mainline's port with the toolkit changed from GTK to Qt and the GNOME half of its dependency list removed. Below.

The exception: the system stack

The graphics, input and audio libraries — libX11, libGL, libwayland, libxcb, libxkbcommon, dbus, systemd, fontconfig — are the distribution's, are linked against as system libraries, and are never bundled. Bundling one machine's libGL does not make a program more portable; it is how a program stops working on the next machine, whose driver stack differs. Everything else is self-compiled.

Keeping that true when a GUI toolkit enters the library set takes two decisions, because vcpkg's own answer would have put dbus, libsystemd and fontconfig in /opt/wiselibs — where the path rule bundles them: wxWidgets is built as wxQt rather than wxGTK, and SDL2 is built without the D-Bus feature.

Two entries in the set do come from the vcpkg side of that line and are meant to. glib arrives because libslirp links it, and is ordinary self-contained code with no daemon or driver behind it — it bundles as safely as PCRE2 does. alsa arrives because openal-soft's port wants its headers, but nothing links it: OpenAL Soft and SDL2 both dlopen libasound.so.2 by soname, so what a program actually uses is the target machine's, and wisedeploy's NEEDED closure never sees it.

wxWidgets is wxQt here

wxWidgets is the one library in the set that could not be taken from vcpkg as it comes, and the reason is this same rule.

wxWidgets on Linux is normally a GTK front end, and vcpkg's port depends on vcpkg's own gtk3 rather than the machine's. Building it that way pulls the whole GNOME stack into /opt/wiselibs — glib, gdk-pixbuf, pango, cairo, atk, libepoxy, libX11/libXi/libXrandr, and through at-spi2-core a second copy of dbus and libsystemd; cairo brings fontconfig, freetype and harfbuzz with it. Four of those are named in the table below as libraries that must come from the host — and wisedeploy bundles by path, with ldd resolving sonames against /etc/ld.so.conf.d. Once they are in /opt/wiselibs, every GUI deployment out of this image starts carrying them, Qt programs included, and nothing warns.

So wx is built against Qt instead. wxWidgets has had a Qt backend for years, build/cmake/toolkit.cmake takes wxBUILD_TOOLKIT=qt and asks for Qt 6's Core Widgets Gui OpenGL OpenGLWidgets Test PrintSupport — all of which this image already builds — and overlay-ports/wxwidgets is mainline's port with that switch flipped and gtk3, cairo and libsm removed from its dependencies. None of the GNOME stack is ever built. A wx program here links libQt6Widgets.so.6 out of /opt/qt, which wisedeploy already knows how to deploy, and the rule above keeps meaning what it says.

The dependencies drop out cleanly rather than being forced: wxUSE_CAIRO defaults off when the toolkit is not GTK, find_package(X11 REQUIRED) is guarded by the X11/GTK ports, and the fonts and media features' lookups of fontconfig/pangoft2 and gstreamer are guarded by AND WXGTK in wxWidgets' own build/cmake/init.cmake.

libsm is the one that does not, and the port passes wxUSE_DETECT_SM=OFF to finish the job. Removing the dependency is not enough, because init.cmake keeps that option on whenever pkg-config sm resolves — and it resolves here regardless, since libSM-devel is part of the system stack Qt needs. The option then compiles src/unix/apptraits.cpp's SmcOpenConnection call into libwx_qtu_core while nothing links -lSM: SM_LIBRARIES is set in init.cmake and read by no target anywhere in wxWidgets' CMake build. wxGTK never notices because find_package(X11) in build/cmake/toolkit.cmake — guarded by WXX11 OR WXGTK2 OR (WXGTK AND wxHAVE_GDK_X11) — drags the X11 client libraries in sideways. wxQt takes that branch never, so the library shipped underlinked and every program that linked it died on undefined reference to SmcOpenConnection'. Turning it off costs one fallback: wxGUIAppTraits::GetDesktopEnvironment() asks the session manager for its vendor string only when XDG_CURRENT_DESKTOP is empty, which no current desktop leaves empty. Linking -lSM instead would keep it, at the price of libSM.so.6 in the NEEDED list of every wx program out of this image — a system-path library wisedeploy does not bundle, so every target machine would then have to have it.

What it costs. Upstream calls wxQt experimental — docs/readme.txt lists GTK support unqualified and "Most Unix variants with Qt 5 or newer (experimental)" beside it. It is maintained (3.3.3 added private font support to wxQt) but it is the least complete of the wx ports, and a program that behaves under wxGTK can still find a gap here. That is a deliberate trade, and it is one-sided: Windows is unaffected, winbuildenv builds wxMSW, which is a first-class port and involves no GTK at all.

check-env.sh asserts it rather than trusting it: the image fails to build if libwx_qtu_* is missing, if wx is not linked against Qt 6, if any libwx_*.so has an undefined Smc*/Ice* symbol, or if libgtk-3, libdbus-1, libsystemd or libfontconfig has appeared in $LIBS_ROOT/lib.

SDL2 is built without the D-Bus feature

Same argument, one line of manifest. vcpkg's sdl2 enables dbus by default on Linux, and that feature depends on the dbus port, which pulls libsystemd and behind it libmount, libcap and libxcrypt — the last things left that would put libdbus-1.so.3 and libsystemd.so.0 on this image's loader path after wxWidgets stopped doing it. So the manifest asks for x11 and wayland only:

{ "name": "sdl2", "default-features": false, "features": [ "wayland", "x11" ] }

Neither of those features has a vcpkg dependency — SDL finds the distribution's X11 and Wayland headers at build time and dlopens the libraries at run time, which is why libSDL2.so's only NEEDED entries are libc and libm. The same is true of OpenAL Soft and its ALSA and PulseAudio backends.

What is given up is SDL's D-Bus screensaver inhibition and its ibus IME bridge. If a program needs them, put the feature back and accept that libdbus-1 and libsystemd become part of what wisedeploy can bundle. Windows is unaffected: every one of those features is platform: linux in the port, so winbuildenv/vcpkg.json can and does name sdl2 plainly.

Qt

Qt 6.11.2 from the official whole-source tarball, built natively into /opt/qt, shared libraries only so LGPL relinking stays possible.

One pass, not two. The Windows image builds Qt twice — a Linux host pass for the code generators and a cross pass for the target — because moc cannot run on the machine it is being built for. Here host and target are the same machine, so there is no -qt-host-path, no QT_HOST_PATH, no separate tools build, and none of the "which qmake did PATH give me" hazard that dominates the Windows script. qmake -query QMAKE_XSPEC says linux-g++ and there is no second answer it could give.

Skipped: qtwebengine (Chromium — hours of build time and a dependency set of its own; add it deliberately if a project needs it), qtdoc, qtactiveqt (Windows-only). Everything else in the tarball is built, plus qtopcua, qtcoap and qtmqtt, which ship separately.

SQL drivers: QSQLITE, QMYSQL, QPSQL and QODBC, against the pinned library set rather than the distribution's.

ICU comes from the library set, not from the distribution, and that distinction is the whole trick. Linked against the system ICU this is the worst dependency in the build: ICU's soname carries its major version — libicuuc.so.67 on AlmaLinux 9, .so.72 on Debian 12, .so.74 on Ubuntu 24.04, .so.76 on Arch — so libQt6Core would run on RHEL 9 and nowhere else. Forward compatibility does not help; the file is simply not there. This was caught by test-portability.sh, not by reading the configure summary.

Built as part of the library set instead, it is ICU 78.3 — newer than any distribution currently ships — and wisedeploy bundles it through the same path rule as OpenSSL and sqlite, with no special case anywhere. -DICU_ROOT is stated explicitly so configure cannot quietly find the system copy.

PCRE2, libpng, libjpeg and double-conversion are bundled into Qt itself for a related reason: a stable soname is not the same as the library being installed. Fedora and RHEL split PCRE2 so that the 16-bit build lives in pcre2-utf16, a package a machine can easily lack — and libQt6Core needs libpcre2-16.so.0, so on such a machine nothing runs at all.

The font stack — freetype, harfbuzz, fontconfig — is deliberately not bundled, and that is load-bearing. Qt's fontconfig feature requires system freetype: pass -qt-freetype and configure turns fontconfig off silently, leaving a Qt that builds, links, runs and cannot enumerate a single font the machine has. check-env.sh now asserts QT_FEATURE_fontconfig for that reason.

TLS: configured -openssl-linked, where the Windows build loads OpenSSL at runtime. Linking it makes the dependency an ordinary NEEDED entry, so wisedeploy finds libssl.so.3 by walking the ELF like everything else and no special case exists to be forgotten. It also removes the chance of Qt picking up the host's OpenSSL, which on an older distribution is a difficult failure to diagnose.

Known gaps

Two modules configure but come out less than complete, both because AlmaLinux 9 does not package what they want:

  • qtmultimedia builds with no media backend. Qt 6.11 wants FFmpeg, which RHEL 9 does not ship (it is an RPM Fusion package), and the GStreamer fallback needs more of the plugin set than CRB carries. Playback APIs will exist and do nothing. To fix it for a project that needs it, add gstreamer1-plugins-bad-free-devel — and an FFmpeg — to the toolchain stage and rebuild.
  • qtopenapi is skipped: its generator needs a JRE and Maven.

Neither affects anything else in the build. configure reports both, so they are visible in the image build log rather than discovered later.

.NET 10

The SDK is in /opt/dotnet, on PATH as dotnet, with both shared frameworks — Microsoft.NETCore.App and Microsoft.AspNetCore.App 10.0.11. It is here so that one image builds the C++ tree, publishes the managed half and lays both out with wisedeploy, rather than handing the second half to another container.

dotnet publish src/WiseLicenseManager/WiseLicenseManager.csproj \
    --configuration Release --runtime linux-x64 --self-contained true \
    --output out/linux-x64

win-x64 and win-x86 publish from here too — a self-contained .NET publish is a file-copy operation, not a compile, so cross-publishing needs nothing the Windows image has.

An exact SDK version, not --channel 10.0, and the same one winbuildenv pins — 10.0.400, checked against the same SHA512, installed to the same /opt/dotnet. That parity is the point: the manager publishes for linux-x64 here and for win-x64 there, and those should not be two different programs. The devcontainer in the licensing tree pins only the major version and takes whatever point release is current, which is the right call for a development box and the wrong one for an image whose entire argument is that two machines building the same source get the same binaries.

The published output holds to the image's own baseline — check-baseline.sh reads wiselicmgr as glibc 2.27, comfortably under the 2.34 floor, because Microsoft builds the native runtime against something older than AlmaLinux 9.

A self-contained .NET app is not self-contained about ICU

Worth knowing before it bites, because it is the same shape as the OpenSSL config problem below: the failure is quiet, late, and nothing in the ELF header predicts it.

--self-contained true bundles the runtime, the framework and every assembly — 80 MB for a hello-world — and still does not bundle ICU. .NET reaches globalization data by dlopening libicuuc.so.<version> at the first culture-aware call, so the dependency appears in no import table, ldd says nothing, and wisedeploy's ELF walk cannot see it either. On a machine without ICU the program starts, runs, prints, and then dies partway through:

runtime    .NET 10.0.11
os         linux-x64
invariant  False
Process terminated. Couldn't find a valid ICU package installed on the system.

That is the stock almalinux:9, debian:12, ubuntu:24.04 and fedora:41 images — none of them carry ICU in the base layer, and archlinux:latest happens to. Installing the distribution's own (libicu, libicu72, libicu74, …) fixes it on all of them; that package belongs in the Depends or Requires of anything built here that ships managed code, beside the graphics stack listed below.

DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 removes the dependency and is not a drop-in: since .NET 8 a new CultureInfo("de-DE") under invariant mode throws CultureNotFoundException unless the app also sets PredefinedCulturesOnly to false. It is a deliberate application decision, not a deployment switch. The third option, if the version matters as much as it does for the rest of the set, is app-local ICU through the Microsoft.ICU.ICU4C.Runtime package — which is the .NET spelling of exactly what this image does with ICU for Qt.

Inside the container ICU resolves to the library set's 78.3 rather than AlmaLinux's 67, because /opt/wiselibs/x64-linux-dynamic/lib is on the loader path and .NET probes high versions first. Either satisfies it; check-env.sh asserts that one of them is findable.

Deployment: wisedeploy

This is what the image is for. A program built here links OpenSSL 3.6.3, libsodium, ZXing and Qt 6.11 — none of which the target machine has — and needs them to travel with it, found without LD_LIBRARY_PATH, a wrapper script, or anything the user has to remember.

. setvars.sh
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build
wisedeploy --appname myapp --qmldir src --tarball build/myapp

produces

/opt/myapp/
  bin/myapp            rpath $ORIGIN/../lib
  bin/qt.conf          where the plugins, QML modules and translations are
  lib/*.so*            everything self-compiled, rpath $ORIGIN
  plugins/<type>/*.so  the Qt plugins the program can actually use
  qml/                 QML modules
  translations/*.qm

which is exactly the payload an AppImage, a Flatpak, a .deb or an .rpm wants, and works untouched as a plain tarball. --dir puts it somewhere other than /opt/<appname>; --tarball also writes <name>-linux-x86_64.tar.gz.

What gets bundled is a path rule: everything resolving under /opt/wiselibs, /opt/qt or /opt/plxlib is ours and is copied; everything else belongs to the target machine. --bundle-root adds a prefix.

The path is a proxy for the real question, which is worth stating, because "system library" covers two very different things. A library genuinely has to come from the host when it is coupled to something only that machine knows:

glibc — libc, libm, libresolv, ld-linux the dynamic loader is part of it; a bundled libc under the host's loader breaks NSS, dlopen and locales
libGL, libGLX, libEGL, libOpenGL, libgbm, libdrm, libvulkan libglvnd picks the vendor library at runtime; one machine's copy will not match the next machine's driver
libudev, libsystemd, libdbus-1 they talk to the daemons actually running on that box
fontconfig, and freetype/harfbuzz beside it fontconfig reads /etc/fonts and must agree with the machine's font configuration
libstdc++, libgcc_s bundleable in principle; unnecessary here because of the ABI baseline

Everything else — ICU, PCRE2, OpenSSL, zlib, sqlite, brotli, krb5, unixODBC — is ordinary self-contained code with none of that coupling, and is bundleable. So the rule is not "system libraries stay behind"; it is "libraries that depend on this machine stay behind". Anything else you want to control the version of belongs in /opt/wiselibs, where the path rule collects it automatically — which is exactly why ICU is built there rather than taken from the distribution.

Plugins are chosen by asking, not by a table. Each Qt plugin is inspected for the Qt modules it needs and deployed when the modules actually deployed satisfy it; then its own dependencies are resolved too. That is the same question windeployqt's hardcoded module→plugin table approximates, asked directly, and it is why the PostgreSQL driver arrives with libpq.so.5 behind it without anybody naming either.

QML modules are deployed before the Qt plugins, and their plugins go through the same dependency closure as everything else. Both halves of that matter. A QML module is mostly text — qmldir, .qml, .qmltypes — but most modules also carry a plugin .so, and that plugin links Qt modules the application never does. QtQuick.Controls is the case that bites: libqtquickcontrols2plugin.so needs libQt6QuickControls2Impl.so.6, a library that exists only to back the Controls implementation, so nothing in the application's own ELF closure ever mentions it. Copying the module tree verbatim produces a deployment that looks complete and dies on the first import QtQuick.Controls with

Cannot load library .../libqtquickcontrols2plugin.so:
libQt6QuickControls2Impl.so.6: cannot open shared object file

Running the closure over the QML plugins fixes it, and doing so before choosing Qt plugins means the modules those QML plugins drag in count towards which Qt plugins the program can use — a QML-only import of QtMultimedia is a reason to deploy the multimedia plugins, and nothing else would have said so. For examples/testapp this is the difference between 37 libraries and 79.

examples/deploy/build.sh runs the whole thing and then proves it: the deployed program is executed with env -i — empty environment, no LD_LIBRARY_PATH, no QT_PLUGIN_PATH, no PATH — from outside the build tree. If it still reports its Qt version and SQL drivers, the tree will behave the same way on a machine that never had this image. examples/testapp goes further and calls into every bundled library rather than printing its version.

What no tool of this kind can see: a dlopen() at runtime. Nothing in an ELF header records that.

A bundled OpenSSL reads the host's openssl.cnf

Worth knowing before it bites, because the symptom is quiet and security-relevant: TLS silently unavailable, QSslSocket::supportsSsl() false, no error anywhere. examples/qt-smoke prints the SSL line for exactly this reason.

OpenSSL's config file is found at $OPENSSLDIR/openssl.cnf, and this build uses the conventional OPENSSLDIR=/etc/ssl so that the system CA certificates in /etc/ssl/certs are found — which is what you want. The cost is that the bundled OpenSSL also reads the host distribution's openssl.cnf, a file written for that distribution's OpenSSL, which is usually patched.

On Fedora 41 this fails. /etc/ssl is a symlink to /etc/pki/tls, and Fedora's config sets

config_diagnostics = 1

which makes OpenSSL treat configuration problems as fatal rather than ignoring them. Something in the rest of that file — the crypto-policies include, the provider section — is not accepted verbatim by a vanilla OpenSSL 3.6.3, and with diagnostics on, initialisation fails outright. Setting config_diagnostics = 0 in a copy of the identical file makes it work, which is how this was pinned down; each individual section passes on its own.

Debian, Ubuntu, Arch, AlmaLinux 9 and Rocky 9 are unaffected — their configs do not turn diagnostics on.

The workaround is one environment variable, set wherever the application is launched from (an AppImage AppRun, a Flatpak manifest, a systemd unit, a .desktop file):

OPENSSL_CONF=/opt/myapp/lib/openssl.cnf   # or /dev/null to use built-in defaults

The permanent fix is to build OpenSSL with no-autoload-config, so the library never reads a config file while still finding certificates in /etc/ssl/certs. That needs an overlay port for openssl and a rebuild of everything linked against it, and has not been done here.

What the target machine still has to have

Everything self-compiled travels; the graphics, input and font stack does not, because it belongs to the machine. A console program needs nothing beyond glibc and libstdc++. A Qt GUI program needs the host to provide:

RHEL/Alma/Rocky/Fedora libglvnd-glx mesa-libGL libxkbcommon-x11 xcb-util-cursor xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil fontconfig dbus-libs
Debian/Ubuntu libgl1 libglx0 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 libxcb-shape0 libfontconfig1 libdbus-1-3
Arch libglvnd libxkbcommon-x11 xcb-util-cursor xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil fontconfig dbus

That is the list a .deb or .rpm built from the tree should declare as its dependencies, and it is what test-portability.sh --gui installs before running a GUI program — a real target is a desktop, not a 70 MB base image with no OpenGL in it at all.

A wxWidgets program needs exactly what a Qt one needs and nothing more: wx here is wxQt, so it deploys the same libQt6Widgets.so.6 a Qt program does and asks the host for the same GL, xcb and fontconfig it already asks for. host-requirements.sh answers for whichever tree was actually produced, which is why it asks the tree rather than a table.

A .NET program adds one more, whatever else it is: ICU — libicu on RHEL/Alma/Rocky/Fedora and Arch, libicu72/libicu74/… on Debian and Ubuntu. It belongs in the same Depends line, and it is needed even by a --self-contained publish, for the reason in .NET 10.

Build the image

./build-image.sh                                  # code.wisesourcery.com/docker/linuxbuildenv:latest
IMAGE=linuxbuildenv:local ./build-image.sh         # somewhere else
./build-image.sh --target libs                     # stop after the library set

First build compiles the whole library set twice over plus Qt, so budget a couple of hours on 16 cores; afterwards it is a cached layer. The vcpkg binary cache, the Qt tarball, the Qt sources, ccache and the Qt build tree are all BuildKit cache mounts, so an interrupted build resumes rather than restarts, and changing only the manifest rebuilds only what changed.

autoconf is built from source into /usr/local as part of the toolchain stage. AlmaLinux 9 ships 2.69 and has nothing newer anywhere in AppStream, CRB or EPEL, while a growing number of autotools projects now declare AC_PREREQ([2.70]) — and vcpkg re-runs autoreconf rather than trusting a shipped configure, so the autoconf in the image decides whether a port builds at all. 2.72 is what Debian trixie gives winbuildenv, which is the version the two images have to agree on. --build-arg AUTOCONF_VERSION= and AUTOCONF_SHA256= move it, together.

To pin a different Qt: --build-arg QT_VERSION=6.11.1 (the download URL is derived from it; override with --build-arg QT_URL=...). To move Node.js, bump NODE_VERSION and NODE_SHA256 together from https://nodejs.org/dist/v<version>/SHASUMS256.txt. To move .NET, bump DOTNET_SDK_VERSION and DOTNET_SDK_SHA512 together from the sdk entry in https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/10.0/releases.json.

On the build server

.forgejo/workflows/build-image.yml does all of the above on the docker runner label: it builds through this same build-image.sh, runs examples/smoke and examples/qt-smoke inside the result, and pushes :latest alongside a tag naming the commit. A push to dev that touches anything but Markdown triggers it; Run workflow takes a no_cache input for the periodic proof that the Dockerfile still builds against today's upstream.

That label is Docker-in-Docker — an agent whose jobs get a daemon of their own rather than the host's socket. It is a runner instance of its own, because the privileged it needs is a per-runner setting; dockerbuildenv/README.md in the buildenv tree has the image and the act_runner configuration.

Use it

docker run --rm -it \
    -v "$PWD":/work \
    -v ~/plxlib:/opt/plxlib:ro \
    code.wisesourcery.com/docker/linuxbuildenv:latest

The shell starts with setvars.sh sourced. There is one of these where the Windows image has two — a single native target needs no arch switch. It exports LIBS_ROOT, LIBS_STATIC_ROOT, WISELIBS_ROOT, QT_ROOT, PLXLIB_ROOT, CMAKE_PREFIX_PATH, CMAKE_TOOLCHAIN_FILE, PKG_CONFIG_PATH and PATH.

PATH covers three places programs actually live, because vcpkg and Qt both keep them out of bin:

$QT_ROOT/bin, $QT_ROOT/libexec qmake, and the code generators — moc, rcc, uic, qmlcachegen, qmlimportscanner. Qt hides libexec on purpose, since CMake finds those through the Qt6*Tools packages; they are on PATH here for hand-rolled builds and scripts.
$LIBS_ROOT/bin shared libraries and the few ports that install to bin.
$LIBS_ROOT/tools/<port>[/bin] everything else a port installs as a program: wxrc, wx-config, glib-compile-resources, gdbus-codegen, pcre2-config, curl-config, uconv.

That last row is why setvars.sh walks the tree instead of listing it. vcpkg's tools/ layout is not uniform — some ports drop executables straight into tools/<port>, others one level down in tools/<port>/bin — and any port with a debug build keeps a second copy in tools/<port>/debug that must not shadow the release one. Each candidate directory is taken only if it holds an executable, which skips the debug/ copies and ICU's config/ (makefile fragments) without either being named. Add a library and its tools appear on PATH with no edit here.

Building a CMake project

CMAKE_TOOLCHAIN_FILE is already exported, so:

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build

Against the static tree:

cmake -B build-static -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DVCPKG_TARGET_TRIPLET=x64-linux-static

The default toolchain (cmake/wisebuild-vcpkg.cmake) goes through vcpkg's own buildsystem integration, which matters: several vcpkg-generated package configs (libsodium's, for one) reference _VCPKG_INSTALLED_DIR and only resolve correctly that way. It also keeps the sibling triplet off CMAKE_PREFIX_PATH and PKG_CONFIG_PATH, so a "static" build cannot quietly find the shared set first.

It selects no compiler — the target is this machine and gcc-toolset-14 is what PATH resolves to. For a build that must not have vcpkg's integration in the way, use $WISEBUILD_PLAIN_TOOLCHAIN (cmake/toolchain-linux.cmake).

Debugging

gdb is in the image — AlmaLinux's own 16.3, which reads everything gcc 14 emits — along with gcore, gstack and pstack.

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build
gdb --args build/yourapp --whatever

It needs no setup, and that is the one thing worth saying about it: the library set and Qt are registered in /etc/ld.so.conf.d rather than exported through LD_LIBRARY_PATH, so a debuggee started from a shell that never sourced setvars.sh still finds libssl.so.3 and libQt6Core.so.6. The same is true of a program run from a wisedeploy staging directory, where the $ORIGIN rpaths do the work instead.

Debugging the deployed tree is the more interesting case, because that is the tree that will be on somebody else's machine, and it works unchanged:

wisedeploy --appname myapp build/myapp
gdb /opt/myapp/bin/myapp

Unlike the Windows environment there is no cross debugger and no gdbserver dance — the target is this machine.

Layout

/opt/vcpkg                        vcpkg, pinned to the commit in the Dockerfile
/opt/wiselibs/<triplet>/          the library set, one tree per triplet
/opt/qt                           Qt 6.11.2 (shared)
/opt/linuxbuildenv/cmake/         CMake toolchain files
/opt/linuxbuildenv/scripts/       setvars.sh, wisedeploy, check-*.sh, helpers
/opt/linuxbuildenv/vcpkg.json     the library manifest (the pre-Qt pass)
/opt/linuxbuildenv/vcpkg-qt/      the post-Qt manifest: wxWidgets
/opt/linuxbuildenv/overlay-ports/ zxing-cpp, libmariadb, wxwidgets
/opt/node                         Node.js LTS + npm (linked into /usr/local/bin)
/opt/dotnet                       .NET SDK 10 (linked into /usr/local/bin)
/opt/plxlib                       bind-mount point for the sanctuarylib SDK
/work                             your source tree

plxlib is not baked into the image (proprietary SDK). Mount the directory holding plxlib-linux64/ at /opt/plxlib, or point elsewhere with PLXLIB_HOME / PLXLIB_ROOT.

/opt/wiselibs/x64-linux-dynamic/lib and /opt/qt/lib are registered in /etc/ld.so.conf.d rather than exported through LD_LIBRARY_PATH, so anything built here runs under gdb, under a test runner and out of a staging directory — none of which inherit a sourced shell.

Examples

examples/smoke links a slice of the library set, built both ways, prints the versions
examples/qt-smoke Qt version, TLS backend and SQL drivers
examples/deploy build → wisedeploy → run with env -i → tarball
examples/testapp a whole application, deployed to /opt/test — below

The sample application

examples/testapp is the worked example of what this image is for: a Qt Quick program that links every library in the set, deployed as a self-contained tree at /opt/test.

examples/testapp/build.sh          # build, deploy to /opt/test, verify, tar it up
DEST=/opt/other examples/testapp/build.sh

It is a real application with a window, and it is also its own test. Run it with --selftest and it exercises each bundled library rather than printing its version — an EVP SHA-256, argon2id and a BLAKE2b, a zlib/lz4/lzma round trip, a Lua chunk, a pugixml parse, an insert-and-read-back through the QSQLITE driver plugin, and a QR code encoded by zxing-cpp and decoded again — then exits. That mode needs no display, which is what makes the tree verifiable inside a stock container:

./test-portability.sh --gui dist/testapp-linux-x86_64.tar.gz \
    env QT_QPA_PLATFORM=offscreen test/bin/testapp --selftest

Version banners are the wrong test for a deployment. A missing .so, a plugin left behind or an rpath still pointing at the build machine all produce a program that starts and prints its versions perfectly; what breaks is the code path that actually calls into the thing. So the sample calls into all of them.

The tree it produces is ~79 libraries, 55 plugins and 51 QML modules, and it runs unmodified on all eight distributions in test-portability.sh — plus, on a real desktop, under both the xcb and wayland platform plugins.

--qmldir is not optional for a Qt Quick program. It is the source tree qmlimportscanner reads to work out which modules under /opt/qt/qml the application imports. Without it the binary deploys, starts, and dies on the first import QtQuick with module QtQuick is not installed.

Adding a library

Add it to vcpkg.json — keeping it in step with winbuildenv/vcpkg.json, which is the point of both files being the same — then rebuild the image, or run build-libs.sh inside a container (optionally TRIPLETS="x64-linux-static" build-libs.sh).

If it needs Qt to build, it goes in vcpkg-qt/vcpkg.json instead, and is built by the same script with the manifest and merge mode named:

MANIFEST_ROOT=/opt/linuxbuildenv/vcpkg-qt MERGE=1 build-libs.sh

Overlay ports and triplets come from $OVERLAY_ROOT, which defaults to /opt/linuxbuildenv either way, so there is still one of each.

Differences from winbuildenv

winbuildenv linuxbuildenv
targets 4 (x86/x64 × static/dynamic) 2 (static/dynamic)
setvars setvars32.sh, setvars64.sh setvars.sh
Qt passes 2 (host + cross) 1
Qt 32-bit no n/a
Qt OpenSSL loaded at runtime linked
deploy tool windeployqt (a replacement) wisedeploy
running the output wine it just runs
debugger cross gdb + gdbserver.exe gdb, natively
wxWidgets wxMSW wxQt (not wxGTK — why)
library passes 1 2 (before and after Qt)
libpcap capture needs an Npcap/WinPcap SDK AF_PACKET, built in
ABI concern which CRT and exception model which glibc and libstdc++