A heavily vibecoded GTK4 GUI interface to manage systemd-nspawn containers
Rust
0
5 commits
updated Oct 5, 2026
A small, focused GTK4 / libadwaita desktop application written in Rust for creating, configuring and running systemd-nspawn containers on Linux.
Nspawn Studio is a front end for the tools you already trust: debootstrap
creates the root filesystem, systemd-nspawn boots it, systemd-run and
machinectl keep it running, and journalctl shows you what happened. The
application never re-implements the container runtime; it generates a plain,
readable bash launcher and drives it.

Managing a container: machine name, root filesystem, boot mode, hostname,
console, resolv.conf/timezone handling and the display/audio integration.
debootstrap a fresh Debian root filesystem (suite, mirror, architecture,
variant and package list are all editable)./var/lib/nspawn-studio/scripts/<name>.sh. It is a normal bash script you can
read, run by hand with sudo, or audit. Every option you pick in the GUI ends
up as an explicit systemd-nspawn flag.chpasswd/useradd (run through
systemd-nspawn), so the login prompt accepts them immediately. Applying is
refused while the container is running.XAUTHORITY cookie
(recommended; works out of the box).DISPLAY and XAUTHORITY are exported through the container's
/etc/profile.d, so programs started after login: see them (a bind
mount, so this also works with a read-only root).xhost +si:localuser:root on start./dev/dri GPU passthrough.--private-network, or --network-veth
with a bridge, plus --port= forwards.-U) with ownership modes,
read-only root, ephemeral mode, seccomp via --system-call-filter=,
no-new-privileges, capability dropping/adding, --inaccessible= masking,
--tmpfs= and raw extra
arguments. A one-click recommended hardening profile (based on the
systemd-nspawn(1) example) is included.systemd-container (systemd-nspawn, machinectl)debootstrap (only needed to create new root filesystems)pkg-configlibgtk-4-dev)libadwaita-1-dev)build-essential / gcc)On Debian 13 "trixie" the whole list is:
sudo apt update
sudo apt install build-essential pkg-config libgtk-4-dev libadwaita-1-dev \
rustc cargo git systemd-container debootstrap wget file patchelf \
squashfs-tools desktop-file-utils xserver-xephyr
The application manipulates containers, so run it as root (or launch it with
pkexec/sudo). Containers are stored under /var/lib/nspawn-studio.
git clone https://github.com/Dhmhtrhs23/Nspawn-Studio.git nspawn-studio
cd nspawn-studio
cargo build --release
sudo ./target/release/nspawn-studio
For development:
cargo run -p nspawn-studio
Run the test suite (unit tests plus end-to-end launcher tests that execute the
generated script against a fake systemd-nspawn):
cargo test
or the convenience script:
./scripts/verify.sh
The binary can also regenerate a launcher without opening the GUI:
sudo ./target/release/nspawn-studio --render <container-name>
# prints the path of the rewritten launcher script
The repository ships a script that builds a self-contained AppImage for x86_64:
./scripts/build-appimage.sh
It performs the following steps:
cargo build --release -p nspawn-studio.desktop file, the icon and an
AppRun entry point.appimagetool (x86_64) into target/appimage-tools/ if needed.dist/nspawn-studio-<version>-x86_64.AppImage.Run it with:
chmod +x dist/nspawn-studio-*.AppImage
sudo ./dist/nspawn-studio-*.AppImage
FUSE. The AppImage is a type-2 AppImage and needs FUSE 2 to mount itself. If it is missing, install
libfuse2/libfuse2t64, or run it with./dist/nspawn-studio-*.AppImage --appimage-extract-and-run.
Note on libraries. The AppImage bundles the application itself and relies on the host for GTK 4 and libadwaita (which any current desktop already has). If you need a fully self-contained bundle, add the libraries with
linuxdeploy:
linuxdeploy --appdir target/appimage/nspawn-studio.AppDir \
--plugin gtk --output appimage
The application also needs systemd-container and (for new root filesystems)
debootstrap installed on the host, because it calls those system tools.
On a Debian/Ubuntu x86_64 host you can cross-compile a native aarch64
binary and package it with the aarch64 appimagetool:
sudo ./scripts/build-arm64.sh
The script enables the arm64 architecture, installs
gcc-aarch64-linux-gnu, qemu-user-static + binfmt-support (needed to run
the aarch64 appimagetool), the arm64 libgtk-4-dev / libadwaita-1-dev
libraries, and a Rust toolchain with the aarch64-unknown-linux-gnu target.
It then cross-compiles and writes
dist/nspawn-studio-<version>-aarch64.AppImage.
The generic build-appimage.sh is architecture aware:
ARCH=aarch64 TARGET=aarch64-unknown-linux-gnu ./scripts/build-appimage.sh
ARCH selects the appimagetool build (and the output file name), TARGET
selects the Rust target triple and therefore which binary gets packaged.
Notes for arm64:
chmod +x then sudo). On the
x86_64 build host you can smoke test it with qemu (the static-pie AppImage
runtime is not always dispatched through binfmt_misc automatically):
qemu-aarch64-static ./dist/nspawn-studio-*-aarch64.AppImage --appimage-extract-and-run --version.systemd-container, debootstrap and the distro's GTK4/libadwaita../scripts/build-appimage.sh
detects aarch64 and produces dist/nspawn-studio-<version>-aarch64.AppImage.Every time a container configuration is saved, Nspawn Studio writes
/var/lib/nspawn-studio/scripts/<name>.sh. A shortened example:
#!/usr/bin/env bash
set -euo pipefail
NAME='demo'
ROOTFS='/var/lib/nspawn-studio/machines/demo'
ARGS=()
ARGS+=("--machine=$NAME")
ARGS+=("--directory=$ROOTFS")
ARGS+=(--console=interactive)
ARGS+=(-b)
ARGS+=(-U)
ARGS+=(--no-new-privileges=yes)
ARGS+=(--drop-capability=CAP_SYS_ADMIN)
ARGS+=(--private-network)
ARGS+=(--port=tcp:8080:80)
ARGS+=(--bind-ro='/srv/data:/data')
if [[ -d /tmp/.X11-unix ]]; then
ARGS+=(--bind=/tmp/.X11-unix)
fi
if [[ -f "$XAUTH" ]]; then
ARGS+=(--bind-ro="$XAUTH:/root/.Xauthority")
ARGS+=(--setenv=XAUTHORITY=/root/.Xauthority)
fi
exec systemd-nspawn "${ARGS[@]}" "$@"
The script resolves session-specific values (DISPLAY, XAUTHORITY,
XDG_RUNTIME_DIR, the PipeWire/PulseAudio/Wayland sockets) at run time, so the
same file works from a terminal and from a systemd unit. When it is executed by
systemd it switches to --console=read-only automatically.
The Users and passwords section of a container lets you:
Applying runs a small shell script inside the container with
systemd-nspawn -D <rootfs> /bin/sh -c .... It creates the accounts, sets
their passwords, creates the sudo group and a
/etc/sudoers.d/90-nspawn-studio drop-in, and installs the sudo package
if it is missing. The new-container dialog has the same fields, and applies
them automatically once the root filesystem exists (after debootstrap, when
you ask it to build now).
Passwords are stored in plain text in the container configuration
(/var/lib/nspawn-studio/containers/<name>.json) so the container can be
re-provisioned later. That file is root-only (mode 0600). Leave the password
blank to keep an existing password unchanged.
| Path | Purpose |
|---|---|
/var/lib/nspawn-studio/containers/<name>.json | container configuration |
/var/lib/nspawn-studio/scripts/<name>.sh | generated launcher |
/var/lib/nspawn-studio/containers/<name>.json | also stores root/user passwords, mode 0600 |
/var/lib/nspawn-studio/units/<name>.service | optional systemd unit |
/var/lib/nspawn-studio/machines/<name>/ | default root filesystem |
Set NSPAWN_STUDIO_HOME to relocate everything (useful for testing).
nspawn-studio/
|- Cargo.toml # cargo workspace
|- core/ # no GUI dependencies, fully unit tested
| |- src/model.rs # container data model (serde)
| |- src/validate.rs # name/path/capability validation
| |- src/generator.rs # systemd-nspawn launcher + unit generation
| |- src/store.rs # on-disk configuration store
| |- src/command.rs # systemctl / machinectl / systemd-run helpers
| '- tests/launcher_integration.rs
|- gui/ # GTK4 + libadwaita front end
| '- src/{main,state,detail,create,widgets,task}.rs
|- data/ # .desktop file and icon
|- scripts/
| |- build-appimage.sh
| '- verify.sh
'- README.md
The split exists so the interesting logic (what flags are produced, how configurations are validated and stored) can be tested without a display server.
cargo test -p nspawn-studio-core runs:
bash -n, then execute it with a fake systemd-nspawn on PATH and assert
that the exact expected flags and runtime guards are passed through../scripts/verify.sh runs the tests, builds the release binary and performs a
short GUI smoke test.
machines/ directory.no-new-privileges. It has been
verified to boot a Debian container cleanly. --inaccessible= masking is
available for paths that exist inside the container image; kernel-provided
paths such as /proc/kcore cannot be masked this way because they do not
exist in the on-disk root filesystem.A heavily vibecoded GTK4 GUI interface to manage systemd-nspawn containers
Rust
0
5 commits
updated Oct 5, 2026
A small, focused GTK4 / libadwaita desktop application written in Rust for creating, configuring and running systemd-nspawn containers on Linux.
Nspawn Studio is a front end for the tools you already trust: debootstrap
creates the root filesystem, systemd-nspawn boots it, systemd-run and
machinectl keep it running, and journalctl shows you what happened. The
application never re-implements the container runtime; it generates a plain,
readable bash launcher and drives it.

Managing a container: machine name, root filesystem, boot mode, hostname,
console, resolv.conf/timezone handling and the display/audio integration.
debootstrap a fresh Debian root filesystem (suite, mirror, architecture,
variant and package list are all editable)./var/lib/nspawn-studio/scripts/<name>.sh. It is a normal bash script you can
read, run by hand with sudo, or audit. Every option you pick in the GUI ends
up as an explicit systemd-nspawn flag.chpasswd/useradd (run through
systemd-nspawn), so the login prompt accepts them immediately. Applying is
refused while the container is running.XAUTHORITY cookie
(recommended; works out of the box).DISPLAY and XAUTHORITY are exported through the container's
/etc/profile.d, so programs started after login: see them (a bind
mount, so this also works with a read-only root).xhost +si:localuser:root on start./dev/dri GPU passthrough.--private-network, or --network-veth
with a bridge, plus --port= forwards.-U) with ownership modes,
read-only root, ephemeral mode, seccomp via --system-call-filter=,
no-new-privileges, capability dropping/adding, --inaccessible= masking,
--tmpfs= and raw extra
arguments. A one-click recommended hardening profile (based on the
systemd-nspawn(1) example) is included.systemd-container (systemd-nspawn, machinectl)debootstrap (only needed to create new root filesystems)pkg-configlibgtk-4-dev)libadwaita-1-dev)build-essential / gcc)On Debian 13 "trixie" the whole list is:
sudo apt update
sudo apt install build-essential pkg-config libgtk-4-dev libadwaita-1-dev \
rustc cargo git systemd-container debootstrap wget file patchelf \
squashfs-tools desktop-file-utils xserver-xephyr
The application manipulates containers, so run it as root (or launch it with
pkexec/sudo). Containers are stored under /var/lib/nspawn-studio.
git clone https://github.com/Dhmhtrhs23/Nspawn-Studio.git nspawn-studio
cd nspawn-studio
cargo build --release
sudo ./target/release/nspawn-studio
For development:
cargo run -p nspawn-studio
Run the test suite (unit tests plus end-to-end launcher tests that execute the
generated script against a fake systemd-nspawn):
cargo test
or the convenience script:
./scripts/verify.sh
The binary can also regenerate a launcher without opening the GUI:
sudo ./target/release/nspawn-studio --render <container-name>
# prints the path of the rewritten launcher script
The repository ships a script that builds a self-contained AppImage for x86_64:
./scripts/build-appimage.sh
It performs the following steps:
cargo build --release -p nspawn-studio.desktop file, the icon and an
AppRun entry point.appimagetool (x86_64) into target/appimage-tools/ if needed.dist/nspawn-studio-<version>-x86_64.AppImage.Run it with:
chmod +x dist/nspawn-studio-*.AppImage
sudo ./dist/nspawn-studio-*.AppImage
FUSE. The AppImage is a type-2 AppImage and needs FUSE 2 to mount itself. If it is missing, install
libfuse2/libfuse2t64, or run it with./dist/nspawn-studio-*.AppImage --appimage-extract-and-run.
Note on libraries. The AppImage bundles the application itself and relies on the host for GTK 4 and libadwaita (which any current desktop already has). If you need a fully self-contained bundle, add the libraries with
linuxdeploy:
linuxdeploy --appdir target/appimage/nspawn-studio.AppDir \
--plugin gtk --output appimage
The application also needs systemd-container and (for new root filesystems)
debootstrap installed on the host, because it calls those system tools.
On a Debian/Ubuntu x86_64 host you can cross-compile a native aarch64
binary and package it with the aarch64 appimagetool:
sudo ./scripts/build-arm64.sh
The script enables the arm64 architecture, installs
gcc-aarch64-linux-gnu, qemu-user-static + binfmt-support (needed to run
the aarch64 appimagetool), the arm64 libgtk-4-dev / libadwaita-1-dev
libraries, and a Rust toolchain with the aarch64-unknown-linux-gnu target.
It then cross-compiles and writes
dist/nspawn-studio-<version>-aarch64.AppImage.
The generic build-appimage.sh is architecture aware:
ARCH=aarch64 TARGET=aarch64-unknown-linux-gnu ./scripts/build-appimage.sh
ARCH selects the appimagetool build (and the output file name), TARGET
selects the Rust target triple and therefore which binary gets packaged.
Notes for arm64:
chmod +x then sudo). On the
x86_64 build host you can smoke test it with qemu (the static-pie AppImage
runtime is not always dispatched through binfmt_misc automatically):
qemu-aarch64-static ./dist/nspawn-studio-*-aarch64.AppImage --appimage-extract-and-run --version.systemd-container, debootstrap and the distro's GTK4/libadwaita../scripts/build-appimage.sh
detects aarch64 and produces dist/nspawn-studio-<version>-aarch64.AppImage.Every time a container configuration is saved, Nspawn Studio writes
/var/lib/nspawn-studio/scripts/<name>.sh. A shortened example:
#!/usr/bin/env bash
set -euo pipefail
NAME='demo'
ROOTFS='/var/lib/nspawn-studio/machines/demo'
ARGS=()
ARGS+=("--machine=$NAME")
ARGS+=("--directory=$ROOTFS")
ARGS+=(--console=interactive)
ARGS+=(-b)
ARGS+=(-U)
ARGS+=(--no-new-privileges=yes)
ARGS+=(--drop-capability=CAP_SYS_ADMIN)
ARGS+=(--private-network)
ARGS+=(--port=tcp:8080:80)
ARGS+=(--bind-ro='/srv/data:/data')
if [[ -d /tmp/.X11-unix ]]; then
ARGS+=(--bind=/tmp/.X11-unix)
fi
if [[ -f "$XAUTH" ]]; then
ARGS+=(--bind-ro="$XAUTH:/root/.Xauthority")
ARGS+=(--setenv=XAUTHORITY=/root/.Xauthority)
fi
exec systemd-nspawn "${ARGS[@]}" "$@"
The script resolves session-specific values (DISPLAY, XAUTHORITY,
XDG_RUNTIME_DIR, the PipeWire/PulseAudio/Wayland sockets) at run time, so the
same file works from a terminal and from a systemd unit. When it is executed by
systemd it switches to --console=read-only automatically.
The Users and passwords section of a container lets you:
Applying runs a small shell script inside the container with
systemd-nspawn -D <rootfs> /bin/sh -c .... It creates the accounts, sets
their passwords, creates the sudo group and a
/etc/sudoers.d/90-nspawn-studio drop-in, and installs the sudo package
if it is missing. The new-container dialog has the same fields, and applies
them automatically once the root filesystem exists (after debootstrap, when
you ask it to build now).
Passwords are stored in plain text in the container configuration
(/var/lib/nspawn-studio/containers/<name>.json) so the container can be
re-provisioned later. That file is root-only (mode 0600). Leave the password
blank to keep an existing password unchanged.
| Path | Purpose |
|---|---|
/var/lib/nspawn-studio/containers/<name>.json | container configuration |
/var/lib/nspawn-studio/scripts/<name>.sh | generated launcher |
/var/lib/nspawn-studio/containers/<name>.json | also stores root/user passwords, mode 0600 |
/var/lib/nspawn-studio/units/<name>.service | optional systemd unit |
/var/lib/nspawn-studio/machines/<name>/ | default root filesystem |
Set NSPAWN_STUDIO_HOME to relocate everything (useful for testing).
nspawn-studio/
|- Cargo.toml # cargo workspace
|- core/ # no GUI dependencies, fully unit tested
| |- src/model.rs # container data model (serde)
| |- src/validate.rs # name/path/capability validation
| |- src/generator.rs # systemd-nspawn launcher + unit generation
| |- src/store.rs # on-disk configuration store
| |- src/command.rs # systemctl / machinectl / systemd-run helpers
| '- tests/launcher_integration.rs
|- gui/ # GTK4 + libadwaita front end
| '- src/{main,state,detail,create,widgets,task}.rs
|- data/ # .desktop file and icon
|- scripts/
| |- build-appimage.sh
| '- verify.sh
'- README.md
The split exists so the interesting logic (what flags are produced, how configurations are validated and stored) can be tested without a display server.
cargo test -p nspawn-studio-core runs:
bash -n, then execute it with a fake systemd-nspawn on PATH and assert
that the exact expected flags and runtime guards are passed through../scripts/verify.sh runs the tests, builds the release binary and performs a
short GUI smoke test.
machines/ directory.no-new-privileges. It has been
verified to boot a Debian container cleanly. --inaccessible= masking is
available for paths that exist inside the container image; kernel-provided
paths such as /proc/kcore cannot be masked this way because they do not
exist in the on-disk root filesystem.