Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
version: '0.2'
language: en
words:
- dcompiler
- devcontainer
- direnv
- hadolint
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/zwift_build_from_scratch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
docker commit \
--change="LABEL org.opencontainers.image.created=${build_date}" \
--change="LABEL org.opencontainers.image.version=${version}" \
--change="ENV ZWIFT_IMAGE_VERSION=${version}" \
--change='CMD [""]' \
-m "built from scratch from version ${version}" \
zwift \
Expand Down
2 changes: 2 additions & 0 deletions docs/advanced/nixos.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ environment variables in camelCase:
programs.zwift = {
# Enable the zwift module and install required dependencies
enable = true;
# Variant: "container" (default) or "volume" (persistent /home/user volume)
variant = "container";
# The Docker image to use for zwift
image = "docker.io/netbrain/zwift";
# The zwift game version to run
Expand Down
85 changes: 85 additions & 0 deletions docs/advanced/volume-variant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: Volume Variant
parent: Advanced
nav_order: 4
---

# Volume Variant

The default mode mounts only the Zwift documents directory as a volume. The **volume variant** mounts the entire
`/home/user` directory as a persistent volume instead. On first run, the container runtime automatically populates the
volume from the image contents — no separate installation step needed.

## Benefits

- **No per-launch chown**: File ownership persists in the volume, eliminating the slow `chown` that Docker users with
non-1000 uid/gid experience on every launch.
- **Persistent wine prefix**: The entire Wine configuration, registry, and installed prerequisites are preserved between
runs.

## How it works

| | Default (`container`) | Volume (`volume`) |
|---------------------|-----------------------------------------------|--------------------------------------------|
| **Image** | `netbrain/zwift:latest` | `netbrain/zwift:latest` (same image) |
| **Volume** | `zwift-$USER` mounted at Zwift docs directory | `zwift-home-$USER` mounted at `/home/user` |
| **First run** | Seconds | Seconds (volume auto-populated from image) |
| **Subsequent runs** | Seconds | Seconds |
| **chown on launch** | Every launch if uid/gid != 1000 | Skipped (ownership persists in volume) |

When a new named volume is mounted to a container path that already has data, Docker (and Podman) automatically copy
the image contents into the volume. This means the first launch is just as fast as any other — no download or
installation wait.

## Usage

### Environment variable

```bash
ZWIFT_VARIANT="volume" zwift
```

Or add to your config file (`~/.config/zwift/config`):

```bash
ZWIFT_VARIANT="volume"
```

### NixOS module

```nix
{
programs.zwift = {
enable = true;
variant = "volume";
};
}
```

### Standalone Nix package

```bash
nix run github:netbrain/zwift#zwift-volume
```

## Updating Zwift

When a new container image is pulled, the launch script automatically detects the version mismatch and syncs the updated
files from the image into the volume. User data (activities, workouts, logs, preferences, screenshots) is preserved
during the sync.

You can also force an update manually by passing `--update` as an entrypoint argument:

```bash
ZWIFT_VARIANT="volume" zwift -- --update
```

## Resetting the installation

To start fresh, remove the persistent volume:

```bash
docker volume rm "zwift-home-$USER"
```

The next launch will re-populate the volume from the current image.
3 changes: 3 additions & 0 deletions docs/configuration/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ These environment variables can be used to alter the execution of the zwift bash
| `DEBUG` | `0` | If set to `1`, enable debug of zwift script `set -x` |
| `VGA_DEVICE_FLAG` | | Override GPU/device flags for container (`--gpus=all`) |
| `PRIVILEGED_CONTAINER` | `0` | If set, container will run in privileged mode, SELinux label separation will be disabled (`--privileged --security-opt label=disable`) |
| `ZWIFT_VARIANT` | `container` | Set to `volume` to mount `/home/user` as a persistent volume (faster startup, no per-launch chown) |

{: .important }
`ZWIFT_UID` and `ZWIFT_GID` can only be used with X11. They do not work in wayland!
Expand All @@ -59,6 +60,8 @@ These environment variables can be used to alter the execution of the zwift bash
the user that started the container. You should not need to change this except in rare cases.
- `WINE_EXPERIMENTAL_WAYLAND="1" zwift` This will start zwift using Wayland and not XWayland. It will start full screen
windowed.
- `ZWIFT_VARIANT="volume" zwift` will mount `/home/user` as a persistent volume, eliminating per-launch `chown` (see
[Volume Variant](../advanced/volume-variant.md) for details).

{: .note }
> To pass extra environment variables to the container, they can be added to `CONTAINER_EXTRA_ARGS` with the `-e` flag.
Expand Down
7 changes: 6 additions & 1 deletion docs/troubleshooting/slow-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ file ownership (`chown`) on every launch. Since the container is ephemeral, the
runs. Only files with incorrect ownership are updated, but the check itself still needs to scan the file tree which can be
slow if you have a large Zwift installation.

If speed is still a concern, consider changing your user to match the container's uid and gid (1000) using `usermod`.
If speed is a concern, you have two options:

1. **Use the volume variant** (`ZWIFT_VARIANT="volume"`): This mounts the entire `/home/user` as a persistent volume,
so file ownership persists between runs and no `chown` is needed after the first launch. See
[Volume Variant](../advanced/volume-variant.md) for details.
2. **Change your user IDs** to match the container's uid and gid (1000) using `usermod`.

**Podman** users are not affected by this since Podman handles uid/gid mapping via user namespaces (`--userns keep-id`).
40 changes: 40 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{
image,
tag,
variant,
dontCheck,
dontPull,
dontClean,
Expand Down Expand Up @@ -45,6 +46,7 @@
nixosRun = pkgs.writeShellScript "zwift-nixos.sh" ''
${pkgs.lib.optionalString (image != "") "export IMAGE=${image}"}
${pkgs.lib.optionalString (tag != "") "export VERSION=${tag}"}
${pkgs.lib.optionalString (variant != "") "export ZWIFT_VARIANT=${variant}"}
${pkgs.lib.optionalString (dontCheck != "") "export DONT_CHECK=${dontCheck}"}
${pkgs.lib.optionalString (dontPull != "") "export DONT_PULL=${dontPull}"}
${pkgs.lib.optionalString (dontClean != "") "export DONT_CLEAN=${dontClean}"}
Expand Down Expand Up @@ -110,6 +112,14 @@
options.programs.zwift = {
enable = mkEnableOption "zwift on linux";

variant = mkOption {
type = enum [
"container"
"volume"
];
default = "container";
description = "Zwift variant: 'container' (default) or 'volume' (persistent /home/user volume, no per-launch chown)";
};
image = lib.mkOption {
type = str;
default = "";
Expand Down Expand Up @@ -227,6 +237,7 @@
(wrapPackage {
inherit
image
variant
containerTool
containerExtraArgs
zwiftUsername
Expand Down Expand Up @@ -316,6 +327,35 @@

desktopItems = [ "bin/Zwift.desktop" ];
};
zwift-volume = wrapPackage {
image = "";
tag = "";
variant = "volume";
dontCheck = "";
dontPull = "";
dontClean = "";
dryRun = "";
interactive = "";
containerTool = "";
containerExtraArgs = "";
zwiftUsername = "";
zwiftPassword = "";
zwiftWorkoutDir = "";
zwiftActivityDir = "";
zwiftLogDir = "";
zwiftScreenshotsDir = "";
zwiftOverrideGraphics = "";
zwiftOverrideResolution = "";
zwiftFg = "";
zwiftNoGameMode = "";
wineExperimentalWayland = "";
networking = "";
zwiftUid = "";
zwiftGid = "";
vgaDeviceFlag = "";
debug = "";
privilegedContainer = "";
};
default = self.packages.x86_64-linux.zwift;
};
};
Expand Down
1 change: 1 addition & 0 deletions src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ RUN dpkg --add-architecture i386 \
libgl1 \
libvulkan1 \
procps \
rsync \
sudo \
wget \
winbind \
Expand Down
15 changes: 10 additions & 5 deletions src/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ readonly ZWIFT_UID="${ZWIFT_UID:-$(id -u user)}"
readonly ZWIFT_GID="${ZWIFT_GID:-$(id -g user)}"
readonly WINE_EXPERIMENTAL_WAYLAND="${WINE_EXPERIMENTAL_WAYLAND:-0}"
readonly CONTAINER_TOOL="${CONTAINER_TOOL:?}"
readonly ZWIFT_VOLUME="${ZWIFT_VOLUME:-0}"

readonly WINE_USER_HOME="/home/user/.wine/drive_c/users/user"
readonly ZWIFT_HOME="/home/user/.wine/drive_c/Program Files (x86)/Zwift"
Expand Down Expand Up @@ -160,12 +161,16 @@ if [[ ${CONTAINER_TOOL} == "docker" ]]; then
fi
fi

msgbox info "Checking file ownership"
if update_ownership; then
msgbox ok "File ownership is correct"
if [[ ${ZWIFT_VOLUME} -eq 1 ]]; then
msgbox ok "Volume variant: skipping ownership update (persisted in volume)"
else
msgbox error "Failed to update file ownership"
exit 1
msgbox info "Checking file ownership"
if update_ownership; then
msgbox ok "File ownership is correct"
else
msgbox error "Failed to update file ownership"
exit 1
fi
fi

startup_cmd=(gosu user:user "${startup_cmd[@]}")
Expand Down
6 changes: 5 additions & 1 deletion src/update_zwift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ else
fi

readonly CONTAINER_TOOL="${CONTAINER_TOOL:?}"
readonly ZWIFT_VOLUME="${ZWIFT_VOLUME:-0}"

readonly WINE_USER_HOME="/home/user/.wine/drive_c/users/user"
readonly ZWIFT_HOME="/home/user/.wine/drive_c/Program Files (x86)/Zwift"
Expand Down Expand Up @@ -155,7 +156,10 @@ cleanup() {
rm -rf -- "${WINE_USER_HOME}/Downloads/Zwift" || true
rm -rf -- "/home/user/.cache/wine*" || true
# remove Zwift documents because it causes permission errors with podman
rm -rf -- "${ZWIFT_DOCS}" || true
# skip in minimal variant — ZWIFT_DOCS lives in the persistent volume
if [[ ${ZWIFT_VOLUME} -ne 1 ]]; then
rm -rf -- "${ZWIFT_DOCS}" || true
fi
}

trap cleanup EXIT
Expand Down
64 changes: 58 additions & 6 deletions src/zwift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ readonly ZWIFT_UID="${ZWIFT_UID:-${UID}}"
readonly ZWIFT_GID="${ZWIFT_GID:-$(id -g)}"
readonly VGA_DEVICE_FLAG="${VGA_DEVICE_FLAG:-}"
readonly PRIVILEGED_CONTAINER="${PRIVILEGED_CONTAINER:-0}"
readonly ZWIFT_VARIANT="${ZWIFT_VARIANT:-container}"

# Initialize CONTAINER_TOOL: Use podman if available
msgbox info "Looking for container tool"
Expand Down Expand Up @@ -329,9 +330,15 @@ container_args+=(
--name "zwift-${USER}"
--hostname "${HOSTNAME}"
--env-file "${container_env_file}"
-v "zwift-${USER}:${ZWIFT_DOCS}"
)

if [[ ${ZWIFT_VARIANT} == "volume" ]]; then
container_args+=(-v "zwift-home-${USER}:/home/user")
container_env_vars+=(ZWIFT_VOLUME="1")
else
container_args+=(-v "zwift-${USER}:${ZWIFT_DOCS}")
fi

###################################################
##### Forward arguments passed to this script #####

Expand Down Expand Up @@ -623,16 +630,61 @@ fi

# Create a volume if not already exists, this is done now as
# if left to the run command the directory can get the wrong permissions
if [[ ${CONTAINER_TOOL} == "podman" ]] && ! ${CONTAINER_TOOL} volume ls | grep -q "zwift-${USER}"; then
mshgbox info "Creating ${CONTAINER_TOOL} volume zwift-${USER}"
if ${CONTAINER_TOOL} volume create "zwift-${USER}"; then
msgbox ok "Created volume zwift-${USER}"
if [[ ${ZWIFT_VARIANT} == "volume" ]]; then
volume_name="zwift-home-${USER}"
else
volume_name="zwift-${USER}"
fi
if [[ ${CONTAINER_TOOL} == "podman" ]] && ! ${CONTAINER_TOOL} volume ls | grep -q "${volume_name}"; then
msgbox info "Creating ${CONTAINER_TOOL} volume ${volume_name}"
if ${CONTAINER_TOOL} volume create "${volume_name}"; then
msgbox ok "Created volume ${volume_name}"
else
msgbox error "Failed to create volume zwift-${USER}"
msgbox error "Failed to create volume ${volume_name}"
exit 1
fi
fi

# Volume variant: sync image contents into volume if image version changed
if [[ ${ZWIFT_VARIANT} == "volume" ]]; then
image_version=""
if image_version="$(${CONTAINER_TOOL} inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${IMAGE}:${VERSION}" 2> /dev/null)"; then
msgbox info "Image version: ${image_version}"
fi

if [[ -n ${image_version} ]]; then
volume_version="$(${CONTAINER_TOOL} run --rm --entrypoint cat -v "${volume_name}:/mnt/volume:ro" "${IMAGE}:${VERSION}" /mnt/volume/.zwift-image-version 2> /dev/null || true)"

if [[ ${image_version} != "${volume_version}" ]]; then
msgbox info "Volume outdated (${volume_version:-empty}), syncing from image (${image_version})..."
if ${CONTAINER_TOOL} run --rm --entrypoint rsync \
-v "${volume_name}:/mnt/volume" \
"${IMAGE}:${VERSION}" \
-a --delete \
--exclude "AppData/Local/Zwift/Activities/" \
--exclude "AppData/Local/Zwift/Workouts/" \
--exclude "AppData/Local/Zwift/Logs/" \
--exclude "AppData/Local/Zwift/prefs.xml" \
--exclude "Pictures/Zwift/" \
/home/user/ /mnt/volume/; then

${CONTAINER_TOOL} run --rm --entrypoint sh \
-v "${volume_name}:/mnt/volume" \
"${IMAGE}:${VERSION}" \
-c "echo '${image_version}' > /mnt/volume/.zwift-image-version"

msgbox ok "Volume synced to image version ${image_version}"
else
msgbox error "Failed to sync volume from image"
fi
else
msgbox ok "Volume is up to date (${image_version})"
fi
else
msgbox warning "Could not determine image version, skipping volume sync"
fi
fi

# Only write environment variables to file when needed
msgbox info "Writing environment variables to temporary file"
if printf '%s\n' "${container_env_vars[@]}" > "${container_env_file}"; then
Expand Down
Loading