Skip to content

Instantly share code, notes, and snippets.

@Apeiros-46B
Last active September 13, 2025 18:27
Show Gist options
  • Select an option

  • Save Apeiros-46B/41e8de4eb55f555985011827b2f643c1 to your computer and use it in GitHub Desktop.

Select an option

Save Apeiros-46B/41e8de4eb55f555985011827b2f643c1 to your computer and use it in GitHub Desktop.
Thunderbots development environment for NixOS systems

Thunderbots in NixOS

Thunderbots uses Bazel, which assumes an FHS system. In addition, dependencies are installed imperatively via a script that manually downloads dependencies, making it difficult to port to Nix.

My solution: Use an Ubuntu LXC container with X11 forwarding (for Thunderscope). This can be (mostly) declaratively configured using the Incus options available in NixOS. See the second file for the code.

Setup

  • Add the provided NixOS module to your system configuration (not home-manager).
  • Set options (under my.containers.tbots namespace):
    • (REQUIRED) enable = true
    • (REQUIRED) user, set to your username
    • (REQUIRED) dataDir, set to a folder in your home folder (will be created)
    • (REQUIRED) uid, set to the uid corresponding to your user
    • (optional) packages, list of strings, interpreted as apt packages to be preinstalled
    • (optional) envExtra, shell script to be evaluated in global shell profile
    • (optional) bashrcExtra, shell script to be evaluated in global bashrc
  • sudo nixos-rebuild switch and reboot (to apply user groups)
  • Run incus launch images:ubuntu/24.04/cloud -p tbots-base (Note the /cloud, this is necessary for setup to work)
    • This should print an instance name, referred to as <iname> from here on out
  • Wait for cloud-init to finish: incus exec <iname> -- cloud-init status --wait
  • Log into your user in the guest: incus exec <iname> -- su - $(whoami)
  • (Inside the container) Perform any desired configuration (e.g. vimrc, ssh keys)
  • (Inside the container) cd to the ~/shared folder; this is the bind mount created earlier
    • For convenience, it is recommended to store the Thunderbots repo here so it can be edited on both host and guest
  • (Inside the container) Follow Thunderbots instructions (clone code, run scripts, etc)

Usage

  • You can edit code in the guest container or on the host OS (not sure how well tooling will work on the host, install IDE inside the guest for the best experience).
  • For convenience, you can use git from the host so you don't have to set up ssh keys inside the guest
  • Code must be built in the guest due to dependencies only being installed there
  • Running graphical apps in the guest should automatically forward the window to the host (requires X or Xwayland)
    • There is an alias provided for this: launch <command> [args...] will fork the command to the background, detach it from the terminal, and silence its output

Additional

  • The X socket forwarding should work for Xwayland (Tested using niri+xwayland-satellite)
  • If using Wayland, setup a bind mount for the Wayland socket instead of Xorg (/run/user//wayland-1)
  • If you get an error when starting graphical apps like "couldn't open display :0", run systemctl restart --user socket_setup as user (not as root)

Credits

Code adapted from:

{ config, lib, ... }:
let
cfg = config.my.containers.tbots;
stripTabs = text:
let
shouldStripTab = lns: builtins.all (ln: (ln == "") || (lib.hasPrefix " " ln)) lns;
stripTab = lns: map (ln: lib.removePrefix " " ln) lns;
stripTabs = lns:
if (shouldStripTab lns)
then (stripTabs (stripTab lns))
else lns;
in
builtins.concatStringsSep "\n" (stripTabs (lib.splitString "\n" text));
makeCloudConfig = attrs: "#cloud-config\n" + (lib.generators.toYAML {} attrs);
in {
options.my.containers.tbots = with lib.types; {
enable = lib.mkEnableOption "Thunderbots development environment (Incus container)";
user = lib.mkOption { type = str; };
dataDir = lib.mkOption { type = str; };
uid = lib.mkOption {
type = int;
default = 1000;
};
packages = lib.mkOption {
type = listOf str;
default = [];
};
envExtra = lib.mkOption {
type = str;
default = "";
};
bashrcExtra = lib.mkOption {
type = str;
default = "";
};
hostname = lib.mkOption {
type = str;
default = "tbots";
};
profileName = lib.mkOption {
type = str;
default = "tbots-base";
};
poolName = lib.mkOption {
type = str;
default = "tbots-pool";
};
networkName = lib.mkOption {
type = str;
default = "incusbr0";
};
};
config = lib.mkIf cfg.enable {
systemd.tmpfiles.settings."10-tbots-shared-dir" = {
${cfg.dataDir}.d = {
user = cfg.user;
group = "users";
mode = "0755";
};
};
users.users.${cfg.user}.extraGroups = [ "incus-admin" ];
virtualisation.incus = {
enable = true;
preseed = {
profiles = [
{
config = let
bashrcPath = "/usr/local/bin/bashrc_extra.sh";
initScriptPath = "/usr/local/bin/bootstrap.sh";
socketSetupScriptPath = "/usr/local/bin/socket_setup.sh";
socketSetupServicePath = "/usr/local/etc/socket_setup.service";
in {
"raw.idmap" = "uid ${toString cfg.uid} 1000";
"security.nesting" = true;
"cloud-init.user-data" = makeCloudConfig {
package_update = true;
package_upgrade = true;
package_reboot_if_required = true;
packages = [
{
apt = cfg.packages ++ [
"git"
"wget"
"openssh-client"
];
}
];
hostname = cfg.hostname;
user = {
name = cfg.user;
sudo = [ "ALL=(ALL) NOPASSWD:ALL" ];
groups = [ "video" "render" ];
};
write_files = [
{
path = "/etc/profile.d/02-env-vars.sh";
content = stripTabs (''
export TERM=xterm-256color
export DISPLAY=:0
'' + "\n" + cfg.envExtra);
}
{
path = bashrcPath;
content = stripTabs (''
launch() {
"$@" > /dev/null 2>&1 & disown
}
'' + "\n" + cfg.bashrcExtra);
}
{
path = initScriptPath;
permissions = "0755";
content = stripTabs ''
#!/bin/sh
echo 'source "${bashrcPath}"' >> /etc/bash.bashrc
echo 'source "${bashrcPath}"' >> /root/.bashrc
home="/home/${cfg.user}"
default_wants="$home/.config/systemd/user/default.target.wants"
name="$(basename '${socketSetupServicePath}')"
mkdir -p "$default_wants"
ln -s ${socketSetupServicePath} "$default_wants/$name"
ln -s ${socketSetupServicePath} "$home/.config/systemd/user/$name"
chown -R ${cfg.user}:${cfg.user} $home
'';
}
{
path = socketSetupScriptPath;
permissions = "0755";
content = stripTabs ''
#!/bin/sh
tmp_dir=/tmp/.X11-unix
mkdir -p $tmp_dir
ln -sf /mnt/.x11_socket $tmp_dir/X0
'';
}
{
path = socketSetupServicePath;
content = stripTabs ''
[Unit]
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/socket_setup.sh
[Install]
WantedBy=default.target
'';
}
];
runcmd = [ initScriptPath ];
};
};
devices = {
gpu = {
gid = 44;
type = "gpu";
};
root = {
path = "/";
pool = cfg.poolName;
size = "24GiB";
type = "disk";
};
mount = {
path = "/home/${cfg.user}/shared";
source = cfg.dataDir;
shift = true;
type = "disk";
};
x11_socket = {
source = "/tmp/.X11-unix/X0";
path = "/mnt/.x11_socket";
type = "disk";
};
eth0 = {
name = "eth0";
network = cfg.networkName;
type = "nic";
};
};
name = cfg.profileName;
}
];
storage_pools = [
{
config = {
source = "/var/lib/incus/storage-pools/${cfg.poolName}";
};
driver = "dir";
name = cfg.poolName;
}
];
networks = [
{
config = {
"ipv4.address" = "10.0.100.1/24";
"ipv4.nat" = "true";
};
name = cfg.networkName;
type = "bridge";
}
];
};
};
networking = {
nftables.enable = true;
firewall.trustedInterfaces = [ cfg.networkName ];
networkmanager.unmanaged = [ cfg.networkName ];
};
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment