Nucleus
Packaging & distribution

Building for Linux

Configure a Nucleus app to build DEB, RPM, AppImage, and pacman packages and publish to the Snap and Flatpak stores.

In this tutorial, you'll configure a Nucleus app to produce every Linux package format — DEB, RPM, AppImage, and pacman for direct distribution, plus Snap and Flatpak for the stores — from a single linux { } block, then build and inspect the output.

Before you start

  • The Nucleus Gradle plugin is applied to your project. See Project setup.
  • You are on a Linux build host. DEB, RPM, AppImage, pacman, Snap, and Flatpak are Linux-only formats and do not cross-build from macOS or Windows.
  • Format-specific tooling is installed as noted in each step (for example libfuse2 for AppImage, snapcraft for Snap, flatpak-builder for Flatpak).

Configure the target formats

In build.gradle.kts, list the Linux formats you want in targetFormats(...) and describe the package in the linux { } block:

build.gradle.kts
nucleus {
    application {
        nativeDistributions {
            targetFormats(
                TargetFormat.Deb,
                TargetFormat.Rpm,
                TargetFormat.AppImage,
                TargetFormat.Pacman,
                TargetFormat.Snap,
                TargetFormat.Flatpak,
            )
            homepage = "https://myapp.example.com"

            linux {
                iconFile.set(project.file("icons/app.png"))
                packageName = "myapp"
                shortcut = true
                appCategory = "Utility"
                menuGroup = "Development"

                debMaintainer = "Your Name <dev@example.com>"
                debDepends = listOf("libfuse2", "libgtk-3-0", "libasound2")

                rpmLicenseType = "MIT"
                rpmRequires = listOf("gtk3", "libX11", "alsa-lib")

                pacmanDepends = listOf("gtk3", "libx11", "alsa-lib")
            }
        }
    }
}

homepage is required for DEB and RPM builds. The packaging task fails without it.

Build DEB and RPM packages

Each format has its own Gradle task named after the TargetFormat constant:

./gradlew packageDeb
./gradlew packageRpm

The plugin writes a .desktop entry into each package with the correct Categories=, MimeType=, StartupWMClass=, and Exec= lines. If your app's window class doesn't match what the window manager reports, set startupWMClass explicitly:

build.gradle.kts
linux {
    startupWMClass = "com-example-MyApp"
}

When startupWMClass is unset, Nucleus derives it from mainClass by replacing dots with hyphens.

Build an AppImage

An AppImage is a single portable executable that runs on most distributions without installation:

./gradlew packageAppImage

The build host needs libfuse2:

sudo apt-get install -y libfuse2

Configure the desktop metadata in the appImage { } sub-block:

build.gradle.kts
linux {
    appImage {
        category = AppImageCategory.Utility
        genericName = "My Application"
        synopsis = "A short description"
        desktopEntries = mapOf("Keywords" to "editor;text;")
    }
}

Output size and build time depend on nativeDistributions.compressionLevel:

CompressionLevelOutputBuild time
StoreLargestFastest
Normal (default)BalancedModerate
MaximumSmallSlow
UltraSmallestSlowest

Ultra additionally rebuilds the .deb at maximum xz effort (xz -9e), roughly 25% smaller than Maximum. RPM and pacman already use xz -9 and are unaffected.

Prefer Normal or Store for AppImage: Maximum / Ultra map to electron-builder maximum and make cold starts very slow (squashfs/FUSE decompression). From 2.3.1 you can override only the AppImage while leaving DEB/DMG on a higher global level:

build.gradle.kts
nativeDistributions {
    compressionLevel = CompressionLevel.Ultra
    linux {
        appImage {
            // Snappy FUSE cold start; DEB still uses Ultra above.
            compressionLevel = CompressionLevel.Normal
        }
    }
}

Package for Arch Linux

TargetFormat.Pacman produces a pacman archive for Arch Linux and derivatives:

./gradlew packagePacman

Set Arch-specific dependencies and, if needed, an independent version string:

build.gradle.kts
linux {
    pacmanDepends = listOf("gtk3", "libx11", "alsa-lib")
    pacmanPackageVersion = "2.0.0"
}

Add install and remove scripts

Deb, Rpm, and Pacman packages run maintainer scripts as root. Point linux { } at your own files; Nucleus concatenates the after-scripts after its own template (desktop integration, AppArmor, optional polkit silent-update helper) and passes the before-scripts raw to fpm. GraalVM native-image packages honor the same properties.

build.gradle.kts
linux {
    afterInstall.set(project.file("packaging/linux/after-install.sh"))
    afterRemove.set(project.file("packaging/linux/after-remove.sh"))
    beforeInstall.set(project.file("packaging/linux/before-install.sh"))
    beforeRemove.set(project.file("packaging/linux/before-remove.sh"))
    // Pacman only: the install/remove scripts do not run on an upgrade
    afterUpgrade.set(project.file("packaging/linux/after-upgrade.sh"))
    beforeUpgrade.set(project.file("packaging/linux/before-upgrade.sh"))
}
PropertyWhen it runsHow it is wired
afterInstallAfter the payload is unpackedConcatenated after Nucleus's template, then electron-builder
afterRemoveAfter the payload is deletedConcatenated after Nucleus's template, then electron-builder
beforeInstallBefore the payload is unpackedPassed raw to fpm (--before-install)
beforeRemoveBefore the payload is deletedPassed raw to fpm (--before-remove)
afterUpgradeAfter a Pacman upgrade swaps the payloadPassed raw to fpm (--after-upgrade)
beforeUpgradeBefore a Pacman upgrade swaps the payloadPassed raw to fpm (--before-upgrade)

Typical use: install a systemd unit that ships under appResourcesRootDir, then enable it from afterInstall. Stop the unit in beforeInstall so binaries under /opt can be replaced, and disable it in afterRemove.

The payload is installed under /opt/<packageName> (linux.packageName, falling back to packageName), never under the display appName, so the path contains no spaces and is safe in unquoted ExecStart= lines.

electron-builder substitutes ${sanitizedProductName} and ${executable} in afterInstall / afterRemove only when the token is single-quoted:

packaging/linux/after-install.sh
systemctl enable '${executable}-daemon.service'
systemctl start '${executable}-daemon.service'

A double-quoted "${executable}" is left as literal text, so systemctl enable "${executable}.service" silently no-ops instead of failing. beforeInstall, beforeRemove, afterUpgrade, and beforeUpgrade get no macro substitution at all — hardcode unit and path names there.

When Pacman replaces an installed package with a newer version, its .INSTALL calls only pre_upgrade / post_upgrade. None of afterInstall, afterRemove, beforeInstall, or beforeRemove run, and there is no fallback to them. Stop the unit in beforeUpgrade and re-enable or restart it in afterUpgrade, or an upgrade leaves the service dead. Deb and Rpm ignore both upgrade properties: their install scriptlets already fire on upgrades.

AppImage, Snap, and Flatpak do not run these scripts. AppImage is not installed; Snap and Flatpak have their own hook surfaces.

Publish to the Snap Store

Snap targets the Snap Store. The default plugs cover desktop integration, X11 and Wayland, networking, audio playback, and OpenGL. Override confinement when your app needs filesystem access beyond home:

build.gradle.kts
linux {
    snap {
        confinement = SnapConfinement.Strict
        grade = SnapGrade.Stable
        base = "core22"
        plugs = listOf(
            SnapPlug.Desktop,
            SnapPlug.Home,
            SnapPlug.Wayland,
            SnapPlug.Network,
            SnapPlug.AudioPlayback,
        )
        compression = SnapCompression.Xz
    }
}
./gradlew packageSnap

The build host needs snapd and snapcraft --classic.

Publish to Flathub with Flatpak

Flatpak is sandboxed. Declare the permissions your app needs through finishArgs. The defaults are --share=ipc, --socket=x11, --socket=wayland, --socket=pulseaudio, and --device=dri; setting finishArgs replaces them, so include everything you need:

build.gradle.kts
linux {
    flatpak {
        runtime = "org.freedesktop.Platform"
        runtimeVersion = "23.08"
        sdk = "org.freedesktop.Sdk"

        finishArgs = listOf(
            "--share=ipc",
            "--socket=wayland",
            "--socket=pulseaudio",
            "--device=dri",
            "--filesystem=home",
            "--share=network",
        )
    }
}
./gradlew packageFlatpak

Flatpak is a store format and runs through the sandboxed pipeline: native libraries are pre-extracted and JVM arguments are redirected. If flatpak-builder or the runtime isn't installed, the task is skipped rather than failed.

Top-level protocol(...) and fileAssociation(...) entries are merged into the generated .desktop file as MimeType= values:

build.gradle.kts
nativeDistributions {
    protocol("MyApp URL", "myapp")
    fileAssociation(
        mimeType = "application/x-myapp",
        extension = "myapp",
        description = "MyApp Document",
    )
}

This produces:

MimeType=x-scheme-handler/myapp;application/x-myapp;

No manual desktopEntries override is needed.

Configuration reference

linux { } exposes iconFile, packageVersion, shortcut, startupWMClass, packageName, appRelease, appCategory, menuGroup, debMaintainer, debPackageVersion, debDepends, rpmLicenseType, rpmPackageVersion, rpmRequires, pacmanPackageVersion, pacmanDepends, afterInstall, afterRemove, beforeInstall, beforeRemove, afterUpgrade, and beforeUpgrade. Sub-blocks are appImage { }, snap { }, flatpak { }, and signing { }.

appRelease sets the package release number — the digit after the - in DEB and RPM versions. Bump it when you re-release without changing packageVersion. See the full Gradle DSL reference.

Snap and Flatpak layer their own desktop files into the user environment and ignore the traditional .desktop install paths used by DEB and RPM.

What's next