Webux Lab - Blog
Webux Lab Logo

Webux Lab

By Studio Webux

Search

By Tommy Gingras

Last update 2026-05-26

FedoraVMBashGaming

Fedora 44 throwaway-VM bootstrap

Idempotent setup for Fedora 44 minimal/server installs. Built from a real working setup; only proven steps are included. Focused on one thing: a gaming VM with Sunshine streaming + a browser.

Each stage is independent, has a feature flag, and is idempotent — re-running skips work already done (tracked via /var/lib/fedora-bootstrap/*.done).

Stages

  1. base — Update + remove cockpit/abrt/cups/etc + create user
  2. rpmfusion — Enable RPM Fusion free + nonfree repos
  3. nvidia — akmod-nvidia + 32-bit libs + nouveau blacklist (auto-skip if no Nvidia GPU)
  4. gaming — gamescope, Steam, labwc, PipeWire, mesa i686, fonts. Everything needed to run Steam Big Picture in a Wayland gamescope session
  5. sunshine — LizardByte's Sunshine streaming host RPM + user-scope systemd unit override + CSRF allowlist
  6. autostart — tty1 autologin → labwc → gamescope → Steam Big Picture
  7. firefox — Firefox from default repos
  8. hostname / firewallhostnamectl + firewalld rules for SSH + Sunshine ports

Usage

sudo ./fedora-bootstrap.sh                       # all defaults
sudo VM_USER=nsure VM_IP=192.168.20.142 VM_HOSTNAME=gaming ./fedora-bootstrap.sh

CLI args:

--user NAME          User to create/configure   (default: SUDO_USER)
--hostname NAME      Set hostname               (default: current)
--ip ADDR            IP for Sunshine CSRF list  (default: auto-detect)
--no-nvidia          --no-sunshine    --no-autostart    --no-firewall

Requirements

  • Fedora 44, fresh minimal install (Server ISO → "Minimal Install" works)
  • Root access (run with sudo)
  • Network connection
  • Nvidia GPU passed through to the VM (or bare metal with an Nvidia card)

What it does NOT do

  • Secure Boot / MOK enrollment is interactive. The script warns at the end:
    sudo mokutil --import /etc/pki/akmods/certs/public_key.der
    
    Set a one-time password, reboot, enroll at the blue MOK Manager screen. Or just disable Secure Boot in firmware.
  • Doesn't install games (Steam handles that).
  • Doesn't pair Moonlight (one-time per client, do it from Sunshine web UI).

Re-running

Each stage writes /var/lib/fedora-bootstrap/<stage>.done. To force re-run:

sudo rm /var/lib/fedora-bootstrap/<stage>.done
sudo ./fedora-bootstrap.sh

Full reset:

sudo rm -rf /var/lib/fedora-bootstrap/
sudo ./fedora-bootstrap.sh

Logs: /var/log/fedora-bootstrap.log.

Troubleshooting

Nvidia module not yet built — Secure Boot is rejecting unsigned modules. Run the mokutil command above and enroll the key.

Sunshine inactive after reboot — Check systemctl --user status app-dev.lizardbyte.app.Sunshine.service from tty2. If enable didn't stick:

systemctl --user enable app-dev.lizardbyte.app.Sunshine.service

Steam game won't launch (Proton)--xwayland-count 2 must be in the gamescope launch in ~/.config/labwc/autostart (the script includes this). Force Proton Experimental on the game's compatibility settings.

CSRF error in Sunshine web UI — The URL in the browser must match an entry in csrf_allowed_origins. The script adds VM_IP, hostname, hostname.local. Edit ~/.config/sunshine/sunshine.conf to add others.

The script

#!/usr/bin/env bash
set -euo pipefail

# ---------------------------------------------------------------------------
# DEFAULTS — env vars override these; CLI args override env vars
# ---------------------------------------------------------------------------

VM_USER="${VM_USER:-${SUDO_USER:-$(logname 2>/dev/null || echo gamer)}}"
VM_HOSTNAME="${VM_HOSTNAME:-$(hostname)}"
VM_IP="${VM_IP:-$(ip -4 -o addr show scope global 2>/dev/null | awk 'NR==1{print $4}' | cut -d/ -f1)}"

INSTALL_BASE_CLEANUP="${INSTALL_BASE_CLEANUP:-1}"
INSTALL_RPMFUSION="${INSTALL_RPMFUSION:-1}"
INSTALL_NVIDIA="${INSTALL_NVIDIA:-auto}"
INSTALL_GAMING="${INSTALL_GAMING:-1}"
INSTALL_SUNSHINE="${INSTALL_SUNSHINE:-1}"
INSTALL_AUTOSTART="${INSTALL_AUTOSTART:-1}"
INSTALL_FIREFOX="${INSTALL_FIREFOX:-1}"
INSTALL_FIREWALL="${INSTALL_FIREWALL:-1}"

STATE_DIR=/var/lib/fedora-bootstrap
LOG_FILE=/var/log/fedora-bootstrap.log

# ---------------------------------------------------------------------------
# CLI ARG PARSING
# ---------------------------------------------------------------------------

while [[ $# -gt 0 ]]; do
  case "$1" in
    --user)         VM_USER="$2"; shift 2 ;;
    --hostname)     VM_HOSTNAME="$2"; shift 2 ;;
    --ip)           VM_IP="$2"; shift 2 ;;
    --no-nvidia)    INSTALL_NVIDIA=0; shift ;;
    --no-sunshine)  INSTALL_SUNSHINE=0; shift ;;
    --no-autostart) INSTALL_AUTOSTART=0; shift ;;
    --no-firefox)   INSTALL_FIREFOX=0; shift ;;
    --no-firewall)  INSTALL_FIREWALL=0; shift ;;
    *)              echo "Unknown arg: $1"; exit 1 ;;
  esac
done

# ---------------------------------------------------------------------------
# HELPERS
# ---------------------------------------------------------------------------

[[ $EUID -eq 0 ]] || { echo "Must run as root (use sudo)."; exit 1; }
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"
exec > >(tee -a "$LOG_FILE") 2>&1

log()         { echo -e "\n\033[1;34m==>\033[0m \033[1m$*\033[0m"; }
warn()        { echo -e "\033[1;33m[warn]\033[0m $*"; }
done_marker() { touch "$STATE_DIR/$1.done"; }
is_done()     { [[ -f "$STATE_DIR/$1.done" ]]; }

ensure_user_exists() {
  if ! id "$VM_USER" &>/dev/null; then
    log "Creating user $VM_USER"
    useradd -m -G wheel "$VM_USER"
    echo "$VM_USER ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/$VM_USER"
    warn "Set password for $VM_USER:"
    passwd "$VM_USER"
  fi
  usermod -aG wheel,video,render,input,audio "$VM_USER"
}

as_user() { sudo -u "$VM_USER" "$@"; }
nvidia_present() { lspci 2>/dev/null | grep -qi 'nvidia.*\(vga\|3d\)'; }

if [[ "$INSTALL_NVIDIA" == "auto" ]]; then
  if nvidia_present; then INSTALL_NVIDIA=1; else INSTALL_NVIDIA=0; fi
fi

# =============================================================================
# STAGE 1: BASE SYSTEM
# =============================================================================

stage_base() {
  is_done base && { log "Skipping base"; return; }
  log "Stage 1: base system update + cleanup"

  if ! grep -q max_parallel_downloads /etc/dnf/dnf.conf; then
    cat >> /etc/dnf/dnf.conf <<EOF
max_parallel_downloads=10
fastestmirror=True
defaultyes=True
EOF
  fi

  dnf upgrade -y --refresh

  if [[ "$INSTALL_BASE_CLEANUP" == "1" ]]; then
    dnf remove -y --skip-broken \
      cockpit\* abrt\* sssd\* cups\* ModemManager PackageKit\* \
      dnf-plugin-system-upgrade 2>/dev/null || true
  fi

  ensure_user_exists
  loginctl enable-linger "$VM_USER" 2>/dev/null || true

  done_marker base
}

# =============================================================================
# STAGE 2: RPM FUSION (required for Nvidia drivers, Steam, codecs)
# =============================================================================

stage_rpmfusion() {
  [[ "$INSTALL_RPMFUSION" == "1" ]] || return
  is_done rpmfusion && { log "Skipping RPM Fusion"; return; }
  log "Stage 2: RPM Fusion repos"

  local fver=$(rpm -E %fedora)
  dnf install -y \
    "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${fver}.noarch.rpm" \
    "https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-${fver}.noarch.rpm"
  dnf upgrade -y

  done_marker rpmfusion
}

# =============================================================================
# STAGE 3: NVIDIA DRIVERS
# akmod-nvidia auto-rebuilds on kernel updates. 32-bit libs needed by Proton.
# Secure Boot? You'll need to enroll the akmods key via MOK after first boot.
# =============================================================================

stage_nvidia() {
  [[ "$INSTALL_NVIDIA" == "1" ]] || return
  is_done nvidia && { log "Skipping Nvidia"; return; }
  log "Stage 3: Nvidia drivers"

  dnf install -y \
    akmods kernel-devel-$(uname -r) kernel-headers \
    akmod-nvidia xorg-x11-drv-nvidia-cuda \
    xorg-x11-drv-nvidia-libs.i686 xorg-x11-drv-nvidia-cuda-libs.i686

  cat > /etc/modprobe.d/blacklist-nouveau.conf <<EOF
blacklist nouveau
blacklist nouveaufb
options nouveau modeset=0
EOF

  cat > /etc/modules-load.d/nvidia.conf <<EOF
nvidia
nvidia_drm
nvidia_modeset
nvidia_uvm
EOF

  cat > /etc/modprobe.d/nvidia.conf <<EOF
options nvidia-drm modeset=1
EOF

  grubby --update-kernel=ALL \
    --args="rd.driver.blacklist=nouveau modprobe.blacklist=nouveau nvidia-drm.modeset=1" || true

  dracut --force --regenerate-all
  akmods --force --kernels "$(uname -r)" || true

  local i=0
  while [[ $i -lt 60 ]] && ! modinfo -F version nvidia &>/dev/null; do
    sleep 2; i=$((i+1))
  done

  if modinfo -F version nvidia &>/dev/null; then
    log "Nvidia module built: $(modinfo -F version nvidia)"
  else
    warn "Nvidia module not yet built — may need a reboot or MOK enrollment"
  fi

  done_marker nvidia
}

# =============================================================================
# STAGE 4: GAMING STACK
# gamescope + Steam + labwc (tiny wlroots compositor for proper D-Bus session)
# + xdg-desktop-portal-wlr (screen capture for Wayland) + PipeWire + 32-bit
# mesa/vulkan for Proton + bubblewrap/fuse for Steam runtime container.
# udev rule gives input group access to /dev/uinput (Sunshine controller injection).
# =============================================================================

stage_gaming() {
  is_done gaming && { log "Skipping gaming stack"; return; }
  log "Stage 4: gamescope + Steam + labwc"

  dnf install -y \
    gamescope steam mangohud gamemode \
    xorg-x11-server-Xwayland \
    pipewire pipewire-pulseaudio pipewire-alsa wireplumber \
    pulseaudio-utils \
    polkit dbus-broker dbus-daemon dbus-x11 \
    libva-utils vulkan-tools \
    bubblewrap fuse fuse-libs \
    liberation-fonts \
    labwc xdg-desktop-portal-wlr \
    alsa-firmware alsa-ucm linux-firmware alsa-sof-firmware \
    mesa-libGL.i686 mesa-libEGL.i686 mesa-vulkan-drivers.i686 vulkan-loader.i686

  cat > /usr/lib/udev/rules.d/85-uinput.rules <<'EOF'
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", GROUP="input", MODE="0660"
EOF
  udevadm control --reload-rules || true
  udevadm trigger || true

  done_marker gaming
}

# =============================================================================
# STAGE 5: SUNSHINE (streaming host for Moonlight)
# - Latest Sunshine RPM from LizardByte's GitHub releases
# - sunshine.conf with CSRF allowlist for VM_IP / hostname / hostname.local
# - user-scope systemd override wires it to graphical-session.target
# =============================================================================

stage_sunshine() {
  [[ "$INSTALL_SUNSHINE" == "1" ]] || return
  is_done sunshine && { log "Skipping Sunshine"; return; }
  log "Stage 5: Sunshine streaming host"

  if ! rpm -q Sunshine &>/dev/null; then
    local url="https://github.com/LizardByte/Sunshine/releases/latest/download/sunshine-fedora-44-amd64.rpm"
    curl -L -o /tmp/sunshine.rpm "$url"
    dnf install -y /tmp/sunshine.rpm
    rm -f /tmp/sunshine.rpm
  fi

  local user_home=$(getent passwd "$VM_USER" | cut -d: -f6)

  mkdir -p "$user_home/.config/sunshine"
  cat > "$user_home/.config/sunshine/sunshine.conf" <<EOF
origin_web_ui_allowed=lan
origin_pin_allowed=lan
csrf_allowed_origins=https://${VM_IP}:47990,https://${VM_HOSTNAME}:47990,https://${VM_HOSTNAME}.local:47990
EOF

  mkdir -p "$user_home/.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d"
  cat > "$user_home/.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/override.conf" <<'EOF'
[Unit]
After=graphical-session.target
PartOf=graphical-session.target

[Install]
WantedBy=graphical-session.target

[Service]
Restart=on-failure
RestartSec=5
EOF

  chown -R "$VM_USER:$VM_USER" "$user_home/.config/sunshine" \
                               "$user_home/.config/systemd"

  as_user XDG_RUNTIME_DIR=/run/user/$(id -u $VM_USER) \
    systemctl --user daemon-reload 2>/dev/null || true
  as_user XDG_RUNTIME_DIR=/run/user/$(id -u $VM_USER) \
    systemctl --user enable app-dev.lizardbyte.app.Sunshine.service 2>/dev/null || true

  done_marker sunshine
}

# =============================================================================
# STAGE 6: AUTOSTART
# tty1 autologin → bash_profile → /usr/local/bin/steam-session → dbus-run-session
# labwc → autostart launches gamescope + Steam Big Picture + Sunshine
#
# --xwayland-count 2 in gamescope is REQUIRED for Proton, otherwise:
#   "nodrv_CreateWindow: Application tried to create a window, but no driver
#    could be loaded."
# =============================================================================

stage_autostart() {
  [[ "$INSTALL_AUTOSTART" == "1" ]] || return
  is_done autostart && { log "Skipping autostart"; return; }
  log "Stage 6: tty1 autologin + labwc/gamescope/Steam session"

  local user_home=$(getent passwd "$VM_USER" | cut -d: -f6)

  mkdir -p /etc/systemd/system/[email protected]
  cat > /etc/systemd/system/[email protected]/autologin.conf <<EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin ${VM_USER} --noclear %I \$TERM
EOF

  cat > /usr/local/bin/steam-session <<'EOF'
#!/bin/bash
# dbus-run-session gives the session a per-user D-Bus bus, which labwc,
# Sunshine, and Steam all need.
systemctl --user start pipewire pipewire-pulse wireplumber
exec dbus-run-session labwc
EOF
  chmod +x /usr/local/bin/steam-session

  mkdir -p "$user_home/.config/labwc"

  cat > "$user_home/.config/labwc/environment" <<'EOF'
XDG_CURRENT_DESKTOP=labwc:wlroots
XDG_SESSION_TYPE=wayland
STEAM_MULTIPLE_XWAYLANDS=1
ENABLE_GAMESCOPE_WSI=1
MOZ_ENABLE_WAYLAND=1
EOF

  cat > "$user_home/.config/labwc/autostart" <<'EOF'
dbus-update-activation-environment --systemd --all 2>/dev/null
systemctl --user import-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP
systemctl --user start graphical-session.target

# Belt-and-suspenders: start Sunshine explicitly
systemctl --user start app-dev.lizardbyte.app.Sunshine.service &

# Steam Big Picture inside gamescope.
# --xwayland-count 2 is REQUIRED for Proton (gives Wine its own X server).
gamescope -W 1920 -H 1080 -r 60 \
  --adaptive-sync --fullscreen \
  --steam --expose-wayland \
  --xwayland-count 2 \
  -- steam -gamepadui &
EOF

  cat > "$user_home/.config/labwc/rc.xml" <<'EOF'
<?xml version="1.0"?>
<labwc_config>
  <core><decoration>no</decoration><gap>0</gap></core>
  <windowRules>
    <windowRule identifier="*">
      <serverDecoration>no</serverDecoration>
      <skipTaskbar>yes</skipTaskbar>
    </windowRule>
  </windowRules>
</labwc_config>
EOF

  if ! grep -q steam-session "$user_home/.bash_profile" 2>/dev/null; then
    cat >> "$user_home/.bash_profile" <<'EOF'

# Auto-launch Steam session on tty1
if [[ -z $DISPLAY ]] && [[ $(tty) == /dev/tty1 ]]; then
  exec /usr/local/bin/steam-session
fi
EOF
  fi

  chown -R "$VM_USER:$VM_USER" "$user_home/.config/labwc" "$user_home/.bash_profile"
  systemctl set-default multi-user.target

  done_marker autostart
}

# =============================================================================
# STAGE 7: FIREFOX
# =============================================================================

stage_firefox() {
  [[ "$INSTALL_FIREFOX" == "1" ]] || return
  is_done firefox && { log "Skipping Firefox"; return; }
  log "Stage 7: Firefox"
  dnf install -y firefox
  done_marker firefox
}

# =============================================================================
# STAGE 8: HOSTNAME + FIREWALL
# Sunshine/Moonlight ports:
#   TCP: 47984 (HTTP), 47989 (HTTP), 47990 (HTTPS web UI), 48010 (RTSP)
#   UDP: 47998-48000 (video/control/audio), 8000 (mDNS)
# =============================================================================

stage_hostname() {
  is_done hostname && return
  log "Stage 8a: hostname"
  hostnamectl set-hostname "$VM_HOSTNAME"
  done_marker hostname
}

stage_firewall() {
  [[ "$INSTALL_FIREWALL" == "1" ]] || return
  is_done firewall && { log "Skipping firewall"; return; }
  log "Stage 8b: firewall"

  if systemctl is-active firewalld &>/dev/null; then
    firewall-cmd --permanent --add-service=ssh
    if [[ "$INSTALL_SUNSHINE" == "1" ]]; then
      firewall-cmd --permanent \
        --add-port=47984/tcp --add-port=47989/tcp \
        --add-port=47990/tcp --add-port=48010/tcp \
        --add-port=47998-48000/udp --add-port=8000/udp
    fi
    firewall-cmd --reload
  else
    warn "firewalld not running, skipping firewall config"
  fi

  done_marker firewall
}

# =============================================================================
# MAIN
# =============================================================================

stage_base
stage_rpmfusion
stage_nvidia
stage_gaming
stage_sunshine
stage_autostart
stage_firefox
stage_hostname
stage_firewall

log "Bootstrap complete."
echo
if [[ "$INSTALL_NVIDIA" == "1" ]] && ! modinfo -F sig_key nvidia &>/dev/null; then
  echo "Nvidia module may not load due to Secure Boot. Enroll the key:"
  echo "  sudo mokutil --import /etc/pki/akmods/certs/public_key.der"
  echo "  # Set a one-time password, reboot, enroll at the blue MOK screen"
fi
echo
echo "Reboot to apply everything:    sudo reboot"
if [[ "$INSTALL_SUNSHINE" == "1" ]]; then
  echo "Sunshine web UI after boot:    https://${VM_IP}:47990"
fi
echo "Steam Big Picture will launch automatically on boot."
echo
echo "State markers: $STATE_DIR (delete a .done file to re-run a stage)"
echo "Full log:      $LOG_FILE"