Skip to content

Instantly share code, notes, and snippets.

@Doosty
Last active October 22, 2025 20:17
Show Gist options
  • Select an option

  • Save Doosty/2dab302d7445b4fc3b9239958a9fccb0 to your computer and use it in GitHub Desktop.

Select an option

Save Doosty/2dab302d7445b4fc3b9239958a9fccb0 to your computer and use it in GitHub Desktop.
# `nixos-module-dockurr-windows.nix`
# my current module, might or might not work, have not used it in a while
{
config,
lib,
pkgs,
...
}: let
app = "dockurr-windows";
cfg = config.my-nix-lib.${app};
inherit (lib) types mkOption mkEnableOption optionalAttrs mkIf mkMerge strings optionals;
in {
imports = [
# ./winapps # doesnt work on nixos properly, is very dynamically written script...TODO: try using winboat
];
options.my-nix-lib.${app} = {
enable = mkOption {
type = types.bool;
default = false;
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Listen on this host address";
};
ports = {
rdp = mkOption {
type = types.nullOr types.port;
default = 3389;
description = "Rdp remote access port, use `null` if you dont want it exposed on the host address";
};
http = mkOption {
type = types.nullOr types.port;
default = 8006;
description = "Http remote access port, use `null` if you dont want it exposed on the host address";
};
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/${app}";
description = "Location for persistent state data";
};
version = mkOption {
type = types.str;
default = "latest";
example = "4.09";
description = "Version of the dockurr/windows container. Find the version tags at `https://hub.docker.com/r/dockurr/windows/tags`";
};
shareDir = mkOption {
type = types.path;
default = "/var/lib/${app}/shared";
description = "Location for data that will be shared between the host and guest system. Inside the guest the location is `\\\\host.lan\\Data` or `Computer -> Network (must enable discovery) -> host.lan`";
};
passthrough-usb-devices = mkOption {
type = types.listOf types.str;
default = [];
example = ["012a:023b" "0123:0456"];
description = ''
Identifiers of *USB devices* that will be passed through into the Windows container. Use `nix shell nixpkgs#usbutils --command lsusb` to identify your devices
'';
};
privileged = mkOption {
type = types.bool;
default = false;
};
manual-networking = {
enable = mkEnableOption "Create and use a specific container network";
address = mkOption {
type = types.nullOr types.str;
example = "10.89.73.101";
default =
if cfg.manual-networking.enable
then (throw "Must set `manual-networking.address`")
else null;
};
network = mkOption {
type = types.nullOr types.str;
example = "10.89.73.0/24";
description = "Container network subnet in CIDR format";
default =
if cfg.manual-networking.enable
then (throw "Must set `manual-networking.network`")
else null;
};
};
envConfig = mkOption {
default = {};
type = types.submodule {
freeformType = types.str;
options = {
VERSION = mkOption {
type = types.str;
default = "11";
};
RAM_SIZE = mkOption {
type = types.str;
default = "8G";
};
CPU_CORES = mkOption {
type = types.str;
default = "12";
};
DISK_SIZE = mkOption {
type = types.str;
default = "50G";
};
NETWORK = mkOption {
type = types.str;
default = "Y";
};
ARGUMENTS = mkOption {
type = types.str;
default = "";
};
USERNAME = mkOption {
type = types.str;
default = "docker";
};
PASSWORD = mkOption {
type = types.str;
default = "";
};
# GPU: "Y"
};
};
};
};
config = (mkIf cfg.enable) (mkMerge [
{
virtualisation.oci-containers.containers.${app} = {
image = "docker.io/dockurr/windows:${cfg.version}"; # <https://hub.docker.com/r/dockurr/windows/tags>
user = "root:root";
autoStart = false;
# puts `cfg.passthrough-usb-devices` at the end of `ARGUMENTS` env var, in the correct format <https://github.com/dockur/windows?tab=readme-ov-file#how-do-i-pass-through-a-usb-device>
environment =
lib.mapAttrs (
name: value:
if name == "ARGUMENTS"
then
toString value
+ (lib.concatStringsSep "" (
map (
device: let
parts = lib.splitString ":" device;
vendorId = lib.elemAt parts 0;
productId = lib.elemAt parts 1;
in " -device usb-host,vendorid=0x${vendorId},productid=0x${productId}"
)
cfg.passthrough-usb-devices
))
else toString value
)
cfg.envConfig;
ports =
(optionals (cfg.ports.http != null) [
"${cfg.address}:${builtins.toString cfg.ports.http}:8006"
])
++ (optionals (cfg.ports.rdp != null) [
"${cfg.address}:${builtins.toString cfg.ports.rdp}:3389/tcp"
"${cfg.address}:${builtins.toString cfg.ports.rdp}:3389/udp"
]);
extraOptions =
[
"--device=/dev/kvm"
"--device=/dev/net/tun"
"--cap-add=NET_ADMIN"
"--cap-add=NET_RAW"
]
++ (optionals (cfg.privileged)
[
"--privileged" # gives everything
# "--cap-add=SYS_ADMIN,MKNOD"
# "--device=/dev/fuse"
# "--security-opt" "label=disable" # selinux
])
++ (optionals (cfg.passthrough-usb-devices != [])
["--device=/dev/bus/usb"]);
volumes = [
"${cfg.stateDir}:/storage"
"${cfg.shareDir}:/data"
];
};
environment = {
systemPackages =
[
pkgs.freerdp
# cli
(pkgs.writeShellScriptBin "${app}-service-start" ''sudo systemctl start podman-${app}.service'')
(pkgs.writeShellScriptBin "${app}-service-status" ''sudo systemctl status podman-${app}.service'')
(pkgs.writeShellScriptBin "${app}-service-stop" ''sudo systemctl stop podman-${app}.service'')
(pkgs.writeShellScriptBin "${app}-service-logs" ''sudo journalctl --unit podman-${app}.service --follow'')
]
++ (optionals (cfg.ports.http != null)
[
(pkgs.writeShellApplication {
name = "${app}-access-web";
text = ''
systemctl is-active --quiet podman-${app}.service || { echo "Error: ${app} not running"; exit 1; } \
&& xdg-open http://${cfg.address}:${builtins.toString cfg.ports.http}/
'';
})
])
++ (optionals (cfg.ports.rdp != null)
[
(pkgs.writeShellApplication {
name = "${app}-access-rdp-wl";
runtimeInputs = [pkgs.freerdp];
text = ''
systemctl is-active --quiet podman-${app}.service || { echo "Error: ${app} not running"; exit 1; } \
&& wlfreerdp /u:${cfg.envConfig.USERNAME} /p:${cfg.envConfig.PASSWORD} /cert:ignore /v:${cfg.address}:${builtins.toString cfg.ports.rdp} /scale:100 +auto-reconnect +home-drive -wallpaper +dynamic-resolution
'';
})
(pkgs.writeShellApplication {
name = "${app}-access-rdp-x11";
runtimeInputs = [pkgs.rdesktop];
text = ''
systemctl is-active --quiet podman-${app}.service || { echo "Error: ${app} not running"; exit 1; } \
&& rdesktop -u ${cfg.envConfig.USERNAME} ${cfg.address}:3389
'';
})
# gui with yad
(pkgs.writeShellApplication {
name = "${app}-start-and-access-with-freerdp";
runtimeInputs = [pkgs.netcat-gnu pkgs.freerdp pkgs.yad];
text = ''
TIMEOUT=15
# Function to show notifications
show_notification() {
local message="$1"
local title="$2"
local icon="''${3:-info}"
yad --notification --image="gtk-$icon" \
--text="$message" \
--command="" \
--timeout=3 &
# Also show as a popup notification
yad --notification-popup \
--image="gtk-$icon" \
--text="$message" \
--timeout=3 \
--title="$title" &
}
check_port() {
nc -z -w 2 "${cfg.address}" "${builtins.toString cfg.ports.rdp}" >/dev/null 2>&1
return $?
}
start_service() {
# show_notification "Starting podman-dockurr-windows service..." "Service Start" "info"
sudo systemctl start podman-dockurr-windows.service
# show_notification "Waiting for RDP port to become available..." "Port Check" "info"
# Create a progress dialog
(
for i in $(seq 1 $TIMEOUT); do
if check_port; then
echo "100"
echo "# RDP port is now open"
break
fi
# Calculate percentage
percentage=$((i * 100 / TIMEOUT))
echo "$percentage"
echo "# Waiting for port... ($i/$TIMEOUT)"
sleep 1
done
) | yad --progress \
--title="Waiting for RDP Port" \
--text="Checking port availability..." \
--percentage=0 \
--auto-close \
--auto-kill \
--width=300
# Check final status
if ! check_port; then
return 1
fi
}
if ! check_port; then
# show_notification "RDP port is not open. Starting service..." "Service Status" "info"
start_service || {
# show_notification "Failed to start service and open RDP port" "Error" "error"
exit 1
}
fi
# show_notification "Connecting to remote machine..." "RDP Connection" "info"
base_args=(
/u:${cfg.envConfig.USERNAME}
/p:${cfg.envConfig.PASSWORD}
/cert:ignore
/v:${cfg.address}:${builtins.toString cfg.ports.rdp}
/scale:100
+auto-reconnect
+home-drive
-wallpaper
+dynamic-resolution
)
if [ -d "/run/media" ]; then
base_args+=(/drive:'/run/media,RemovableDevices')
fi
if ! wlfreerdp "''${base_args[@]}"; then
show_notification "Failed to connect using wlfreerdp" "Connection Error" "error"
exit 1
fi
'';
})
# "${app}-access-rdp-wayland-2" = execute-if-running "${pkgs.freerdp3}/bin/wlfreerdp /u:docker /p: /v:${address-only}:3389 /w:${builtins.toString cfg.resolution-of-rdp-session.width} /h:${builtins.toString cfg.resolution-of-rdp-session.height} /cert:ignore";
# "${app}-access-rdp-wayland-3" = execute-if-running "${pkgs.freerdp3}/bin/sdl-freerdp /u:docker /p: /v:${address-only}:3389 /w:${builtins.toString cfg.resolution-of-rdp-session.width} /h:${builtins.toString cfg.resolution-of-rdp-session.height} /cert:ignore";
# "${app}-access-rdp-wayland" = execute-if-running "${pkgs.freerdp}/bin/wlfreerdp /u:${cfg.extraEnvConfig.USERNAME} /p:${cfg.extraEnvConfig.PASSWORD} /v:${address-only}:3389 /w:${builtins.toString cfg.resolution-of-rdp-session.width} /h:${builtins.toString cfg.resolution-of-rdp-session.height} /cert:ignore";
]);
};
assertions = [
{
assertion = config.networking.nftables.enable == false;
message = "ERROR: dockur windows container requires iptables, but you are using nftables";
}
];
systemd.tmpfiles.rules = [
"d '${cfg.stateDir}' 0700 root root - -"
# "d '${cfg.shareDir}' 0770 root users - -"
];
# environment.persistence."/persistent".directories = [
# {
# directory = config.my-nix-lib.microsoft.${app}.stateDir;
# mode = "u=rwx,g=,o=";
# }
# ]; # NOTE: for `nix-community/impermanence`
}
(mkIf (cfg.manual-networking.enable) {
# WARNING: if address is only changed it will create errors
# TODO: create a proper systemd service here that recreates the network if addresses change?
virtualisation.oci-containers.containers.${app} = {
extraOptions = [
"--network=manual-${app}"
"--ip=${cfg.manual-networking.address}"
];
};
systemd.services."podman-${app}".preStart = ''
${pkgs.podman}/bin/podman network exists manual-${app} || \
${pkgs.podman}/bin/podman network create manual-${app} \
--subnet=${cfg.manual-networking.network}
'';
networking.firewall = {
extraCommands = mkIf (!config.networking.nftables.enable) ''
iptables -A INPUT -p tcp --destination-port 53 -s ${cfg.manual-networking.address} -j ACCEPT
iptables -A INPUT -p udp --destination-port 53 -s ${cfg.manual-networking.address} -j ACCEPT
'';
};
})
(mkIf (cfg.manual-networking.enable == false) {
systemd.services."podman-${app}".preStart = ''${pkgs.podman}/bin/podman network exists manual-${app} && ${pkgs.podman}/bin/podman network rm manual-${app}'';
})
]);
}
# sample configuration of the module
{
inputs,
...
}: {
imports = [
./nixos-module-dockurr-windows.nix
];
my-nix-lib.dockurr-windows = {
enable = true;
version = "4.11";
envConfig = {
VERSION = "2025";
};
passthrough-usb-devices = [
# sandisk usb drive
"0781:5583"
# flydigi vader4, note: sometimes shows in device manager upon bootup but then disappears when trying to connect with its app...
"05ac:0250"
"045e:028e" # this one works in proxmox vm with wireless dongle
"04b4:2412"
];
manual-networking = {
enable = true;
address = "10.89.73.101";
network = "10.89.73.0/24";
};
# remnant2 save games, for use with Remnant2 save guardian, which only works properly on windows
shareDir = "/home/blazp/.steam/steam/steamapps/compatdata/1282100/pfx/drive_c/users/steamuser/Saved\ Games/Remnant2/Steam/109679480";
};
}
# idea of how to init the state
systemd.services.init-dockurr-state = {
before = ["podman-${app}.service"];
wantedBy = ["multi-user.target" "podman-${app}.service"];
serviceConfig = {
Type = "oneshot";
ExecStart = lib.getExe (pkgs.writeShellApplication {
name = "initialize-dockurr-state";
runtimeInputs = [pkgs.rsync];
text = ''
# write a script that somehow copies premade dockurr files from a fileshare or whatever into `/var/lib/dockurr-windows` if dir is empty
# the img file is 50GB or something, too large to include into nix flake configs
'';
});
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment