Skip to content

Instantly share code, notes, and snippets.

@smtchahal
Last active May 17, 2026 09:27
Show Gist options
  • Select an option

  • Save smtchahal/8ede7c75f7f92c2798d1746747eaadaa to your computer and use it in GitHub Desktop.

Select an option

Save smtchahal/8ede7c75f7f92c2798d1746747eaadaa to your computer and use it in GitHub Desktop.
ExpressVPN installer for Bazzite Linux (immutable Fedora Atomic)

ExpressVPN Installer for Bazzite Linux

Installs ExpressVPN on Bazzite Linux (immutable Fedora Atomic), working around the limitations of the official .run installer.

Usage

Step 1 — Download the ExpressVPN installer

Go to expressvpn.com/vpn-download/vpn-linux and download the Linux .run file. It'll land in your ~/Downloads folder.

Step 2 — Download this script

Right-click here and save as install-expressvpn-bazzite-linux.sh — also into ~/Downloads.

Step 3 — Open a terminal

Search for Konsole (KDE) or Terminal in your app grid.

Step 4 — Run the script

bash ~/Downloads/install-expressvpn-bazzite-linux.sh ~/Downloads/expressvpn-linux-universal-<version>.run

Replace <version> with the actual version number in the filename you downloaded. The script will ask for your password when it needs to do anything requiring admin access.

Why this exists

The official ExpressVPN .run installer fails on Bazzite for two reasons:

  1. It detects and rejects being run with sudo.
  2. Without sudo, it tries dnf install for dependencies, which Bazzite blocks on the immutable host.

Even if you work around those, the installer tries to write the app icon and .desktop launcher to /usr/share/pixmaps/ and /usr/share/applications/, which are part of Bazzite's read-only OSTree image. It hits a "Read-only file system" error and aborts mid-install.

This script bypasses all of that by doing each step explicitly, using sudo only for the specific operations that need it, and redirecting the icon and .desktop file to writable XDG user directories instead.

Why not other approaches

  • Flatpak: ExpressVPN does not publish one.
  • AppImage: ExpressVPN does not publish one.
  • rpm-ostree: ExpressVPN dropped separate .rpm packages in version 4.0. Only a .run file is available, so there is nothing to layer.
  • Distrobox: ExpressVPN's daemon rewrites /etc/resolv.conf on connect. Inside any container, /etc/resolv.conf is a bind-mount and not writable the way the daemon expects. This causes HE_ERR_CALLBACK_FAILED and an immediate disconnect. Zero documented success reports for ExpressVPN in any container on Fedora Atomic.

Known caveats

  • Kill switch unreliable: Bazzite runs firewalld, which manages nftables. ExpressVPN's daemon injects raw iptables rules for its kill switch. Firewalld can flush those rules on network events, silently breaking the kill switch mid-session. The VPN tunnel itself works fine.

  • DNS fragility: On connect, the daemon replaces /etc/resolv.conf (a symlink on Bazzite) with a static file. On a clean disconnect it restores it. On a crash or hard kill, it does not. If you lose DNS after an unclean exit, restore it manually:

    sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
  • Future in-app updates: ExpressVPN's updater re-runs the original installer, which will hit the same /usr/share read-only failure. When updating, re-run this script instead (after downloading the new .run file).

#!/bin/bash
set -e
# ExpressVPN installer for Bazzite Linux (immutable Fedora Atomic)
#
# WHY THIS SCRIPT EXISTS
# ----------------------
# The official ExpressVPN .run installer fails on Bazzite for two reasons:
# 1. It detects and rejects being run with sudo.
# 2. Without sudo, it tries `dnf install` for dependencies, which Bazzite
# blocks on the immutable host (rpm-ostree warning, then exits).
#
# Even if you work around those, the installer tries to write the app icon
# and .desktop launcher to /usr/share/pixmaps/ and /usr/share/applications/,
# which are part of Bazzite's read-only OSTree image. It hits a
# "Read-only file system" error and aborts mid-install.
#
# This script bypasses all of that by doing each step explicitly, using sudo
# only for the specific operations that need it, and redirecting the icon and
# .desktop file to writable XDG user directories instead.
#
# WHY NOT OTHER APPROACHES
# ------------------------
# - Flatpak: ExpressVPN does not publish one.
# - AppImage: ExpressVPN does not publish one.
# - rpm-ostree: ExpressVPN dropped separate .rpm packages in version 4.0.
# Only a .run file is available, so there is nothing to layer.
# - Distrobox: ExpressVPN's daemon runs --update-dns-config=static_resolv_conf
# on connect, which tries to rewrite /etc/resolv.conf. Inside any
# container, /etc/resolv.conf is a bind-mount and not writable in
# the way the daemon expects. This causes HE_ERR_CALLBACK_FAILED
# and an immediate disconnect. Confirmed on NixOS and Arch.
# Zero documented success reports for ExpressVPN in any container
# on Fedora Atomic.
#
# KNOWN CAVEATS
# -------------
# - Kill switch unreliable: Bazzite runs firewalld, which manages nftables.
# ExpressVPN's daemon injects raw iptables rules for its kill switch.
# Firewalld can flush those rules on network events, silently breaking the
# kill switch mid-session. The VPN tunnel itself works fine.
#
# - DNS fragility: On connect, the daemon replaces /etc/resolv.conf (which on
# Bazzite is a symlink to /run/systemd/resolve/stub-resolv.conf) with a
# static file. On a clean disconnect it restores it. On a crash or hard kill,
# it does not. If you lose DNS after an unclean exit, restore it manually:
# sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
#
# - Future in-app updates: ExpressVPN's updater re-runs the original installer,
# which will hit the same /usr/share read-only failure. When updating, re-run
# this script instead (after re-extracting the new .run file).
#
# PREREQUISITES
# -------------
# Download the official .run file from expressvpn.com/vpn-download/vpn-linux
# then run this script pointing at it:
# bash install-expressvpn.sh ~/Downloads/expressvpn-linux-universal-14.x.x.xxxxx_release.run
INSTALL_DIR="/opt/expressvpn"
SERVICE_NAME="expressvpn-service"
BRAND="expressvpn"
EXTRACT_DIR="/tmp/evpn_extracted"
EXTRACTED="$EXTRACT_DIR/x64"
echo ""
echo "================================="
echo "ExpressVPN Installer (Bazzite)"
echo "================================="
echo ""
# Resolve the .run file
if [ "$#" -ne 1 ]; then
echo "Usage: bash install-expressvpn.sh /path/to/expressvpn-linux-universal-<version>.run"
echo "Download the .run file from expressvpn.com/vpn-download/vpn-linux"
exit 1
fi
RUN_FILE="$1"
if [ ! -f "$RUN_FILE" ]; then
echo "ERROR: File not found: $RUN_FILE"
exit 1
fi
# If ExpressVPN is running, offer to stop it before proceeding
if systemctl is-active --quiet expressvpn-service || pgrep -x expressvpn-client >/dev/null 2>&1; then
echo "ExpressVPN is currently running."
read -r -p "Stop it now and proceed with installation? [y/N] " confirm
if [[ "$confirm" != [yY] ]]; then
echo "Aborted."
exit 1
fi
sudo systemctl stop expressvpn-service
echo "OK: ExpressVPN stopped"
fi
echo "Using installer: $RUN_FILE"
# Extract the .run file
echo ">>> Extracting installer..."
rm -rf "$EXTRACT_DIR"
sh "$RUN_FILE" --noexec --nox11 --target "$EXTRACT_DIR"
echo "OK"
# Sanity check
if [ ! -d "$EXTRACTED/expressvpnfiles" ]; then
echo "ERROR: Extraction succeeded but expected files not found at $EXTRACTED"
exit 1
fi
# Step 1: Copy files to /opt/expressvpn
echo ">>> [1/6] Copying files to $INSTALL_DIR..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp -rf "$EXTRACTED/expressvpnfiles/"* "$INSTALL_DIR/"
sudo cp "$EXTRACTED/installfiles/"*.sh "$INSTALL_DIR/bin/"
sudo chmod +x "$INSTALL_DIR/bin/"*.sh
sudo find "$INSTALL_DIR" -type d -exec chmod 755 {} \;
sudo find "$INSTALL_DIR/bin" "$INSTALL_DIR/lib" -maxdepth 1 -type f -exec chmod +x {} \;
sudo mkdir -p "$INSTALL_DIR/var"
echo "OK"
# Step 2: Set capability on DNS helper binary
echo ">>> [2/6] Setting capability on expressvpn-unbound..."
sudo setcap 'cap_net_bind_service=+ep' "$INSTALL_DIR/bin/expressvpn-unbound"
echo "OK"
# Step 3: Create system groups
echo ">>> [3/6] Creating system groups..."
getent group expressvpn >/dev/null || sudo groupadd expressvpn
getent group expressvpnhnsd >/dev/null || sudo groupadd expressvpnhnsd
echo "OK"
# Step 4: Install and start systemd service
echo ">>> [4/6] Installing systemd service..."
sudo cp "$EXTRACTED/installfiles/expressvpn-service.service" "/etc/systemd/system/${SERVICE_NAME}.service"
sudo chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
echo "OK"
# Step 5: NetworkManager config (prevent NM from touching the WireGuard interface)
echo ">>> [5/6] Configuring NetworkManager..."
NM_CONF="/etc/NetworkManager/conf.d/wgexpressvpn.conf"
if [ ! -f "$NM_CONF" ]; then
printf '[keyfile]\nunmanaged-devices=interface-name:wgexpressvpn*\n' | sudo tee "$NM_CONF" > /dev/null
fi
echo "OK"
# Step 6: Desktop entry, icon, and CLI symlink (no root needed)
echo ">>> [6/6] Installing desktop entry, icon, and CLI symlink..."
mkdir -p "$HOME/.local/share/pixmaps"
cp "$EXTRACTED/installfiles/app-icon.png" "$HOME/.local/share/pixmaps/${BRAND}.png"
mkdir -p "$HOME/.local/share/applications"
# Patch Icon= to use full path since ~/.local/share/pixmaps isn't always searched by name
sed "s|^Icon=.*|Icon=$HOME/.local/share/pixmaps/${BRAND}.png|" \
"$EXTRACTED/installfiles/${BRAND}.desktop" \
> "$HOME/.local/share/applications/${BRAND}.desktop"
# CLI symlink in ~/.local/bin (already in PATH on modern Fedora)
mkdir -p "$HOME/.local/bin"
ln -sf "$INSTALL_DIR/bin/expressvpnctl" "$HOME/.local/bin/expressvpnctl"
ln -sf "$INSTALL_DIR/bin/expressvpnctl" "$HOME/.local/bin/expressvpn"
echo "OK"
echo ""
echo "================================="
echo "Installation complete!"
echo "================================="
echo ""
echo "Activate your subscription:"
echo " expressvpn activate"
echo ""
echo "Or launch the GUI from your app grid."
echo ""
echo "If DNS breaks after an unclean VPN exit, run:"
echo " sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment