Win11-Unattend-ISO: Beginner-Friendly Project Guide

Posted June 9, 2026

Welcome. If you are new to customizing Windows ISOs, this page is designed to help you get started quickly and then go deeper when you are ready with the tools and concepts outlined here and in the project repository.

Get the project files on GitHub: Win11-Unattend-ISO

What this project does:

  • Builds a custom Windows 11 ISO using an unattended setup file from the UnattendedWinstall project, creating a clean, debloated, pre-configured Windows system directly from an official Microsoft ISO with no post-installation configuration needed.
  • Optionally adds browser installers and local app installers.
  • Supports Linux/WSL, native Windows PowerShell, and Docker workflows.

For the smoothest experience, use the Windows container workflow because all required dependencies are already preconfigured. The Linux/WSL scripts are also available if you prefer a native approach or want to understand the underlying steps. To get started, install Docker Desktop for Windows.

How to read this page:

  1. Start with Part 1 for setup and quick start commands.
  2. Use Part 2 when you want script-by-script details.
  3. Use Part 3 for lessons learned, troubleshooting notes, and historical context.
  4. Use Part 4 for Azure personal and GitHub public release Git repo setup.

Tip for first-time users: run one baseline build first (without extra apps/browsers), then add options once that succeeds.


Part 1: Main Guide (README)

Win11-Unattend-ISO

Builds a custom Windows 11 ISO by downloading and injecting autounattend.xml from UnattendedWinstall.

It can also inject browser installers (-Browsers) and local app installers from ./apps (.exe/.msi) for first-logon installation.

Warning: Before the build starts, this tool deletes the output ISO (if it already exists) and any existing autounattend.xml in the working directory. It also deletes the downloaded autounattend.xml after the build completes.

Main Scripts

  • Native Linux/WSL: build-winiso.sh
  • Native Windows PowerShell: build-winiso.ps1
  • Docker Linux: Dockerfile
  • Docker Windows containers: Dockerfile.pwsh

Documentation

Prerequisites

You must manually download a Windows 11 ISO from Microsoft before running this tool.

  • Native (Linux/WSL): bash, curl, (jq or python3), p7zip-full, xorriso
  • Native (Windows PowerShell): oscdimg (ADK Deployment Tools), 7z optional. See Installing Windows ADK (Deployment Tools) for oscdimg.
  • Docker (Linux/Windows containers): No local build dependencies are required beyond Docker, but Docker must be installed and configured.

Quick Start

bash build-winiso.sh <input_iso> <output_iso>
pwsh ./build-winiso.ps1 <input_iso> <output_iso>

Optional browsers:

bash build-winiso.sh <input_iso> <output_iso> -Browsers <chrome|opera|firefox|brave|all>
pwsh ./build-winiso.ps1 <input_iso> <output_iso> -Browsers <chrome|opera|firefox|brave|all>

Local Apps (./apps)

  • Place .exe and/or .msi files in ./apps (subdirectories supported). For multi-app silent installs, Ninite works well and has been tested with this workflow.
  • Installers are copied to sources/$OEM$/$1/AppInstallers inside the ISO.
  • A one-time startup script (install-apps-once.cmd) is created when there are installers to run.
  • If ./apps is missing or has no .exe/.msi, the build still succeeds and app injection is skipped.

When -Browsers is used, browser installers are injected into sources/$OEM$/$1/BrowserInstallers.

  • .msi installers run silently via msiexec /i ... /qn /norestart.
  • .exe installers are executed directly, so use silent-capable installers for unattended behavior.

Containers

Use Docker when you want a reproducible environment with no local dependency setup.

Linux container

docker info --format "{{.OSType}}" # Ensure this outputs "linux" before proceeding
docker build -t win11-unattend-iso .
docker run --rm -v /mnt/c/MV-ISO:/mnt/c/MV-ISO win11-unattend-iso /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso
# Add one browser:
docker run --rm -v /mnt/c/MV-ISO:/mnt/c/MV-ISO win11-unattend-iso /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso -Browsers brave
# Add multiple browsers (comma-separated):
docker run --rm -v /mnt/c/MV-ISO:/mnt/c/MV-ISO win11-unattend-iso /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso -Browsers brave,firefox

Windows container

docker info --format "{{.OSType}}" # Ensure this outputs "windows" before proceeding
docker build --no-cache -f Dockerfile.pwsh -t win11-unattend-iso-pwsh C:\MV-ISO
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso
# Add one browser:
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers brave
# Add multiple browsers (comma-separated):
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers brave,firefox

Installing Windows ADK (Deployment Tools) for oscdimg

Required only for native build-winiso.ps1 usage (not required for Docker Windows containers).

  1. Download the ADK installer from Microsoft: https://go.microsoft.com/fwlink/?linkid=2243390
  2. Run adksetup.exe.
  3. On the Select the features you want to install screen, tick only Deployment Tools.
  4. Click Install and wait for completion.
  5. Add oscdimg to your PATH:
$adkBin = "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg"
[System.Environment]::SetEnvironmentVariable('PATH', "$env:PATH;$adkBin", 'User')
  1. Restart PowerShell, then verify:
oscdimg /?

For older troubleshooting notes and archived details, see Part 3: Project History and Lessons.


Part 2: Script Reference

Scripts Reference

This document contains script-level reference details moved from README.

PowerShell Script Reference

build-winiso.ps1

Synopsis:

  • Build orchestrator for a custom unattended Windows ISO.

Description:

  • Performs top-level validation, fail-fast preflight, orchestration cleanup, and ordered execution of child scripts responsible for unattended injection, browser staging, app staging, and first-logon task generation.

Parameters:

  • InputIso: Path to the source Windows ISO.
  • OutputIso: Path to the output custom ISO.
  • Browsers: Optional browser selection list: chrome, opera, firefox, brave, or all.
  • AppFolders: Optional comma-separated list of app folders. Defaults to ./apps behavior when omitted.
  • SettingsConfigPath: Path to orchestrator settings JSON. Defaults to ./config/orchestrator.json.
  • BrowserConfigPath: Optional override for browser configuration JSON path. If omitted, value is read from SettingsConfigPath.
  • UnattendUri: Optional override for unattended XML download URI. If omitted, value is read from SettingsConfigPath.
  • UnattendXmlPath: Optional existing local autounattend.xml path.

Sample command:

pwsh ./build-winiso.ps1 C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers brave,firefox -AppFolders "C:\MV-ISO\apps"
scripts/ApplyUnattend.ps1

Synopsis:

  • Extracts a Windows ISO, injects autounattend, applies staged OEM content, and rebuilds the ISO.

Description:

  • Performs all unattended image operations: extraction/mount, autounattend.xml injection, optional staged content merge, ISO rebuild, and temporary working cleanup.

Parameters:

  • InputIso: Path to the source Windows ISO.
  • OutputIso: Path to the output custom ISO.
  • UnattendXmlPath: Path to the autounattend.xml file.
  • WorkingDirectory: Working directory used for temporary extraction.
  • OemStageRoot: Optional stage root containing prebuilt ISO-relative folders (for example sources/$OEM$/$1/...) that should be merged into the extracted ISO tree.

Sample command:

pwsh ./scripts/ApplyUnattend.ps1 -InputIso C:\MV-ISO\win11.iso -OutputIso C:\MV-ISO\win11-min.iso -UnattendXmlPath C:\MV-ISO\autounattend.xml -WorkingDirectory C:\Temp\winiso_orchestrator -OemStageRoot C:\Temp\winiso_orchestrator\oem-stage
scripts/InstallBrowsers.ps1

Synopsis:

  • Downloads selected browser installers from configuration and stages them for first-logon install.

Description:

  • Reads browser metadata from a JSON configuration file, supports one/many/all selection, downloads installers, and returns generated RunOnce-style entries.

Parameters:

  • BrowserConfigPath: Path to the browser JSON configuration file.
  • StageRoot: Root directory used to stage ISO-relative content.
  • Browsers: Browser selection list: chrome, opera, firefox, brave, or all.

Sample command:

pwsh ./scripts/InstallBrowsers.ps1 -BrowserConfigPath ./config/browsers.json -StageRoot C:\Temp\winiso_orchestrator\oem-stage -Browsers brave,chrome
scripts/AddRunOnceApps.ps1

Synopsis:

  • Discovers local installers and generates first-logon application install entries.

Description:

  • Scans one or more application folders for .exe/.msi installers, stages them for deployment, and returns generated RunOnce-style entries for first-logon execution.

Parameters:

  • StageRoot: Root directory used to stage ISO-relative content.
  • AppFolders: Optional comma-separated folder paths to scan. If omitted, defaults to ./apps.
  • DefaultAppsFolder: Default apps folder path used when AppFolders is not provided.

Sample command:

pwsh ./scripts/AddRunOnceApps.ps1 -StageRoot C:\Temp\winiso_orchestrator\oem-stage -AppFolders "C:\MV-ISO\apps" -DefaultAppsFolder C:\MV-ISO\apps
scripts/RunOnceManager.ps1

Synopsis:

  • Combines staged install tasks into a one-time first-logon CMD runner.

Description:

  • Merges browser/application/extra entries in order, writes a startup script for first-logon execution, and appends cleanup actions after all tasks complete.

Parameters:

  • StageRoot: Root directory used to stage ISO-relative content.
  • BrowserEntries: RunOnce-style entries generated by browser staging.
  • ApplicationEntries: RunOnce-style entries generated by app staging.
  • AdditionalEntries: Optional future RunOnce-style entries.

Sample command:

$browserEntries = @([pscustomobject]@{ Name = 'Install Brave'; Command = 'C:\BrowserInstallers\brave-latest.exe /install' })
$appEntries = @([pscustomobject]@{ Name = 'Install App'; Command = '"C:\AppInstallers\Ninite Installer.exe"' })
pwsh ./scripts/RunOnceManager.ps1 -StageRoot C:\Temp\winiso_orchestrator\oem-stage -BrowserEntries $browserEntries -ApplicationEntries $appEntries

Linux Script Reference

build-winiso.sh

Synopsis:

  • Build orchestrator for Linux/WSL and Linux containers.

Description:

  • Performs top-level validation, fail-fast preflight, orchestration cleanup, settings loading from ./config/orchestrator.sh.json, and ordered execution of child scripts for browser staging, app staging, RunOnce generation, and unattended ISO apply/rebuild.

Parameters:

  • input_iso: Path to the source Windows ISO.
  • output_iso: Path to the output custom ISO.
  • Browsers: Optional browser selection list: chrome, opera, firefox, brave, or all. Supports comma-separated values.
  • AppFolders: Optional comma-separated list of app folders. Defaults to ./apps behavior when omitted.
  • SettingsConfigPath: Optional path to shell orchestrator settings JSON. Defaults to ./config/orchestrator.sh.json.
  • BrowserConfigPath: Optional override for browser configuration JSON path. If omitted, value is read from SettingsConfigPath.
  • UnattendUri: Optional override for unattended XML download URI. If omitted, value is read from SettingsConfigPath.
  • UnattendXmlPath: Optional existing local autounattend.xml path.

Sample command:

bash build-winiso.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso -Browsers brave,firefox -AppFolders /mnt/c/MV-ISO/apps
scripts/ApplyUnattend.sh

Synopsis:

  • Extracts a Windows ISO, injects autounattend, merges staged OEM content, and rebuilds the ISO.

Sample command:

bash scripts/ApplyUnattend.sh --input-iso /mnt/c/MV-ISO/win11.iso --output-iso /mnt/c/MV-ISO/win11-min.iso --unattend-xml-path /mnt/c/MV-ISO/autounattend.xml --working-directory /tmp/winiso_orchestrator --oem-stage-root /tmp/winiso_orchestrator/oem-stage
scripts/InstallBrowsers.sh

Synopsis:

  • Downloads selected browser installers from configuration and stages them for first-logon install.

Sample command:

bash scripts/InstallBrowsers.sh --browser-config-path ./config/browsers.json --stage-root /tmp/winiso_orchestrator/oem-stage --browsers brave,chrome --entries-file /tmp/winiso_orchestrator/browser-entries.tsv
scripts/AddRunOnceApps.sh

Synopsis:

  • Discovers local installers and generates first-logon application install entries.

Sample command:

bash scripts/AddRunOnceApps.sh --stage-root /tmp/winiso_orchestrator/oem-stage --app-folders /mnt/c/MV-ISO/apps --default-apps-folder ./apps --entries-file /tmp/winiso_orchestrator/app-entries.tsv
scripts/RunOnceManager.sh

Synopsis:

  • Combines staged install tasks into a one-time first-logon CMD runner.

Sample command:

bash scripts/RunOnceManager.sh --stage-root /tmp/winiso_orchestrator/oem-stage --browser-entries-file /tmp/winiso_orchestrator/browser-entries.tsv --application-entries-file /tmp/winiso_orchestrator/app-entries.tsv

compare-isos.sh

Synopsis:

  • Compares two Windows ISOs and reports added, removed, and changed files.

Sample command:

bash compare-isos.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso

Part 3: Project History and Lessons

History

This file archives the detailed documentation that used to live in README.md.

Additional Lessons Learned

  1. Keep local installer payloads out of git history and normal tracking.

    • Use .gitignore for apps content and avoid committing binary installers.
  2. Docker builds must tolerate missing optional inputs.

    • ./apps is optional: build should succeed if the folder is missing/empty.
    • Injection should run only when .exe/.msi files actually exist.
  3. Optional features should be explicit in logs.

    • Clear messages for "missing ./apps" and "no installers found" reduce debugging time.
  4. Document examples for both single and multiple -Browsers values.

    • Include -Browsers chrome and comma-separated variants in both Linux and Windows container examples.
  5. Sync + rebuild is critical after script or Dockerfile changes.

    • For Windows container testing, re-sync workspace content and rebuild images with --no-cache when troubleshooting.
  6. Keep orchestrators thin and move stage-specific logic into child scripts.

  7. Linux now mirrors PowerShell modular flow (browser staging, app staging, RunOnce generation, unattended apply/rebuild), which makes debugging and parity checks much easier.

  8. When merging staged content, copy directory contents into destination roots, not the top-level directory node.

  9. Copying sources into an existing sources folder creates unintended nesting (sources/sources/...).

  10. Avoid hard dependency on a single JSON parser in shell tooling.

  11. Linux scripts now support jq first with python3 fallback, reducing environment-specific failures.

  12. Parameter defaults should not depend on runtime-provided script path variables when they can be empty in container entrypoints.

  13. Resolve default paths after startup in script body rather than inside parameter default expressions.

  14. Keep README focused on usage and move deep script internals to dedicated docs.

  15. Script reference content now lives in scripts-reference.md to keep top-level documentation easier to scan.

Original Documentation

Injects an autounattend.xml into a Windows 11 ISO to produce a minimal, unattended installation image. The autounattend.xml is sourced from UnattendedWinstall and downloaded automatically at build time.

Warning: Before the build starts, this tool deletes the output ISO (if it already exists) and any existing autounattend.xml in the working directory. It also deletes the downloaded autounattend.xml after the build completes.

Prerequisites (Original Documentation)

You must manually download a Windows 11 ISO from Microsoft before running this tool.

  • Native (Linux/WSL): bash, curl, p7zip-full, xorriso
  • Native (Windows PowerShell): oscdimg (from Windows ADK Deployment Tools). 7z is optional; if missing, the script uses native ISO mount/copy.
  • Docker (Linux): Docker only — uses Linux container image.
  • Docker (PowerShell — Windows containers): Requires Docker Desktop in Windows container mode, Windows 10/11 host with Hyper-V enabled, and no additional local tools — oscdimg is baked into the image.

Installing Windows ADK (Deployment Tools) for oscdimg (Original Documentation)

  1. Download the ADK installer from Microsoft: https://go.microsoft.com/fwlink/?linkid=2243390
  2. Run adksetup.exe.
  3. On the Select the features you want to install screen, tick only Deployment Tools (you do not need the other features).
  4. Click Install and wait for it to complete.
  5. Add oscdimg to your PATH so PowerShell can find it:
# Default install path (adjust if you chose a custom location)
$adkBin = "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg"
[System.Environment]::SetEnvironmentVariable('PATH', "$env:PATH;$adkBin", 'User')

Restart PowerShell after running this so the new PATH takes effect.

  1. Verify:
oscdimg /?

You should see the oscdimg usage/help output.

Usage

Native

bash build-winiso.sh <input_iso> <output_iso>
pwsh ./build-winiso.ps1 <input_iso> <output_iso>

Optional browser injection:

bash build-winiso.sh <input_iso> <output_iso> -Browsers <chrome|opera|firefox|brave|all>
pwsh ./build-winiso.ps1 <input_iso> <output_iso> -Browsers <chrome|opera|firefox|brave|all>

You can pass multiple browsers:

bash build-winiso.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso -Browsers chrome,firefox

When -Browsers is used, the script downloads the latest installers and injects them into BrowserInstallers/ in the ISO. It also writes:

  • BrowserInstallers/download-links.txt with the source URLs used for each selected browser.
  • sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmd with silent install commands for the selected browsers.

Example:

bash build-winiso.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso
pwsh ./build-winiso.ps1 C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso

Docker

# Build the image
docker build -t win11-unattend-iso .

# Run the container
docker run --rm -v /mnt/c/MV-ISO:/mnt/c/MV-ISO win11-unattend-iso /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso

Docker (PowerShell — Windows containers)

Requires: Docker Desktop switched to Windows containers. Windows 10/11 host with Hyper-V enabled.

# Verify Docker is in Windows container mode
docker info --format "{{.OSType}}"   # must print: windows

# Build/rebuild the image (use --no-cache after script changes)
docker build --no-cache -f Dockerfile.pwsh -t win11-unattend-iso-pwsh C:\MV-ISO

# Run the container
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso

# Run with browser injection
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers brave

# Multiple browsers
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers chrome,firefox

The -v flag mounts the host directory into the container at the same path so the container can read the input ISO and write the output ISO back to the host.

Compare Two ISOs

Use the comparison script to check which files were added/removed/changed between two ISOs.

Native (Linux/WSL)

bash compare-isos.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso

Optional third argument sets report file path:

bash compare-isos.sh /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso /mnt/c/MV-ISO/iso-diff.txt

Docker (Linux)

# Build comparison image
docker build -f Dockerfile.compare -t win11-iso-compare .

# Run compare inside container
docker run --rm -v /mnt/c/MV-ISO:/mnt/c/MV-ISO win11-iso-compare /mnt/c/MV-ISO/win11.iso /mnt/c/MV-ISO/win11-min.iso /mnt/c/MV-ISO/iso-diff.txt

Troubleshooting

Different ISO sizes between Linux and PowerShell outputs

Different ISO sizes do not always mean the payload files are different. Linux uses xorriso and Windows uses oscdimg, so ISO metadata can vary between builds.

Use compare-isos.sh to verify the actual file payload.

Expected Linux-only boot metadata entries:

[BOOT]/1-Boot-NoEmul.img
[BOOT]/2-Boot-NoEmul.img
boot/boot.cat

These differences are expected and do not mean files are missing from the Windows ISO.

Why -h is required for oscdimg in build-winiso.ps1

Without -h, oscdimg can omit hidden files from the rebuilt ISO. One observed example was sources/ws.dat missing from a PowerShell-built ISO.

The fix is to include -h in the oscdimg command so hidden files are preserved.

Why compare-isos.sh enforces locale

The compare script relies on sorted file lists before using comm. Locale differences can change sort order and trigger false errors such as input is not in sorted order.

compare-isos.sh sets LC_ALL=C and sorts paths explicitly to keep results deterministic.

Browser installer text files are normalized across Linux and Windows builds

Both builders write the injected browser helper files in the same format to avoid hash or size differences caused only by encoding or line endings:

  • BrowserInstallers/download-links.txt: ASCII with CRLF line endings
  • sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmd: ASCII with CRLF line endings

Docker (PowerShell): -Browsers parameter not found

If you see:

build-winiso.ps1: A parameter cannot be found that matches parameter name 'Browsers'.

The container image usually predates the -Browsers support. Rebuild the image and run again:

docker rmi win11-unattend-iso-pwsh
docker build --no-cache -f Dockerfile.pwsh -t win11-unattend-iso-pwsh C:\MV-ISO
docker run --rm -v C:\MV-ISO:C:\MV-ISO win11-unattend-iso-pwsh C:\MV-ISO\win11.iso C:\MV-ISO\win11-min.iso -Browsers brave

To confirm the current script supports the parameter:

pwsh -NoLogo -NoProfile -Command "Get-Help ./build-winiso.ps1 -Full | Select-String Browsers"

Docker (PowerShell): no match for platform in manifest

If you see:

failed to resolve source metadata for mcr.microsoft.com/windows/servercore:ltsc2022: no match for platform in manifest

Dockerfile.pwsh uses a Windows base image, so Docker must be in Windows container mode.

Fix:

  1. Right-click Docker Desktop tray icon → Switch to Windows containers
  2. Verify:
docker info --format "{{.OSType}}"   # must print: windows
  1. Rebuild:
docker build --no-cache -f C:\MV-ISO\Dockerfile.pwsh -t win11-unattend-iso-pwsh C:\MV-ISO

Docker (PowerShell): oscdimg not found inside container

If ADK installation failed or oscdimg is not on PATH after image build:

  1. Verify the ADK install step completed:
docker run --rm --entrypoint powershell.exe win11-unattend-iso-pwsh -Command "oscdimg /?"
  1. If not found, rebuild with --no-cache to force fresh ADK download:
docker build --no-cache -f C:\MV-ISO\Dockerfile.pwsh -t win11-unattend-iso-pwsh C:\MV-ISO

What We Learned

  1. Container OS must match Docker engine mode.
  2. If docker info --format "{{.OSType}}" is linux, use Linux base images.
  3. If it is windows, use Windows base images.

  4. PowerShell script is Windows-only; use the shell script for Linux.

  5. build-winiso.ps1 requires Windows and oscdimg (ADK). For Linux/WSL, use build-winiso.sh with xorriso and p7zip-full.

  6. Builder label is an immediate diagnostic signal.

  7. Seeing docker:desktop-linux in build output explains why mcr.microsoft.com/windows/* images fail.

  8. Path and mount style follow container OS.

  9. Linux containers should use Linux paths inside the container (for example /mnt/c/...).
  10. Windows containers should use Windows paths inside the container (for example C:\...).

  11. MCR image tags must exist.

  12. Invalid/nonexistent tags fail before any Dockerfile build steps run.

  13. Use --no-cache while troubleshooting.

  14. This avoids stale layers masking Dockerfile/script changes.

  15. Avoid host symlink ambiguity for build context files.

  16. When building from Windows PowerShell, prefer real files in C:\MV-ISO for Dockerfile.pwsh and scripts.

  17. Dedicated Dockerfiles per OS are simpler and more reliable than cross-platform branching in a single Dockerfile.

  18. A single Dockerfile that tries to support both Linux and Windows engines adds fragile conditional logic and is harder to debug.
  19. Separate Dockerfile (Linux) and Dockerfile.pwsh (Windows) keeps each path clean, independently testable, and clearly named.

Unattended Configuration

The autounattend.xml is provided by UnattendedWinstall - a streamlined Windows 11 unattended installation configuration for a minimal setup with the latest updates.


Inspecting ISO Contents Without Extracting

Use 7z to check whether a file exists inside an ISO or read its contents directly, without extracting the whole image.

Check if a file exists in an ISO

Bash (WSL/Linux):

7z l <iso_path> <file_path_inside_iso>
# Example: list all files under BrowserInstallers/
7z l /mnt/c/MV-ISO/win11-min-brave-bash.iso BrowserInstallers/

PowerShell (Host Windows):

& "C:\Program Files\7-Zip\7z.exe" l "C:\MV-ISO\win11-min-brave-bash.iso" "BrowserInstallers/"

PowerShell (WSL native pwsh):

7z l /mnt/c/MV-ISO/win11-min-brave-bash.iso BrowserInstallers/

View the contents of a file inside an ISO (no extraction)

The -so flag tells 7z to write the extracted file to stdout instead of disk.

Bash (WSL/Linux):

# View a .cmd script embedded in the ISO
7z e -so /mnt/c/MV-ISO/win11-min-brave-bash.iso "sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmd"

# View the download-links.txt embedded in the ISO
7z e -so /mnt/c/MV-ISO/win11-min-brave-bash.iso BrowserInstallers/download-links.txt

PowerShell (Host Windows):

& "C:\Program Files\7-Zip\7z.exe" e -so "C:\MV-ISO\win11-min-brave-bash.iso" "sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmd"
& "C:\Program Files\7-Zip\7z.exe" e -so "C:\MV-ISO\win11-min-brave-bash.iso" "BrowserInstallers/download-links.txt"

PowerShell (WSL native pwsh):

7z e -so /mnt/c/MV-ISO/win11-min-brave-bash.iso "sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmd"
7z e -so /mnt/c/MV-ISO/win11-min-brave-bash.iso BrowserInstallers/download-links.txt

Tip: Pipe through Select-String (PowerShell) or grep (bash) to search inside the file:

bash 7z e -so /mnt/c/MV-ISO/win11-min.iso BrowserInstallers/download-links.txt | grep brave

ps1 7z e -so /mnt/c/MV-ISO/win11-min.iso BrowserInstallers/download-links.txt | Select-String brave


Development Environment Setup (VS Code + WSL)

Keep WSL Repo Mirrored to Windows Folder

If you want to edit only in WSL at /home/warha/Win11-Unattend-ISO and test Windows containers from C:\admin-PS, use the included sync script.

One-way mirror (WSL -> Windows)

cd /home/warha/Win11-Unattend-ISO

# Preview what will change
bash ./sync-to-windows.sh --dry-run

# Apply mirror sync
bash ./sync-to-windows.sh

Default destination is /mnt/c/admin-PS (Windows path C:\admin-PS).

You can also pass a custom destination:

bash ./sync-to-windows.sh /mnt/c/admin-PS

Notes

  • This is a one-way mirror from WSL to Windows.
  • It uses --delete, so files removed in WSL are also removed from C:\admin-PS.
  • It excludes .git/, editor folders, *.iso, autounattend.xml, and common temp/log files.

For Windows container tests, run Docker build from C:\admin-PS in host Windows PowerShell after syncing.

Problem: PowerShell Extension failed to start on Linux/WSL

The VS Code Machine settings.json had Linux terminal profiles pointing to Windows .exe paths, which caused the PowerShell Language Server to crash on startup:

[error] Extension Terminal is undefined.
[error] PowerShell Language Server process didn't start!

Root cause: terminal.integrated.defaultProfile.linux was set to Windows PowerShell with path /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe, and powershell.powerShellAdditionalExePaths listed Windows .exe paths. VS Code on Linux cannot use these as a language server host.

Fix: Native PowerShell 7 in WSL

Install PowerShell 7 natively in Ubuntu/WSL:

. /etc/os-release
sudo apt-get update
sudo apt-get install -y wget apt-transport-https software-properties-common
wget -q https://packages.microsoft.com/config/ubuntu/$VERSION_ID/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm -f packages-microsoft-prod.deb
sudo apt-get update
sudo apt-get install -y powershell
pwsh --version

Tested with: PowerShell 7.6.1 on Ubuntu 24.04 (Noble) in WSL2.

VS Code Machine settings.json — Terminal Profiles

The Machine settings.json now defines four clearly named terminal profiles:

Profile Name Shell Location
WSL PowerShell 7 (native) pwsh Default — native WSL Linux
WSL Bash bash -l Native WSL Linux
Host Windows PowerShell 5.1 powershell.exe via /mnt/c/ Windows host via WSL interop
Host Windows PowerShell 7 pwsh.exe via /mnt/c/ Windows host via WSL interop

Default terminal: WSL PowerShell 7 (native).

To manage VS Code Machine settings from this repo instead of editing them directly in ~/.vscode-server:

# Create symlink so VS Code reads settings from this workspace file
ln -sfn ~/Win11-Unattend-ISO/settings.json ~/.vscode-server/data/Machine/settings.json

# Verify the link is correct
ls -l ~/.vscode-server/data/Machine/settings.json

Note: Use ln -sfn (not ln -s) so the command is safe to re-run — it replaces any existing symlink or file.


Part 4: Azure Personal and GitHub Public Release Git Repo Setup

This repository uses two Git remotes and two branches with separate histories.

The current setup is:

  • master → private development branch tracking azure/master
  • public-main → public release branch tracking github/main

Because master and public-main currently have unrelated histories, do not use merge-based release steps from public-main (for example git merge --squash master).

Repository Remotes

git remote -v

Expected remotes:

azure   git@ssh.dev.azure.com:v3/[User Name]/Win11-Unattend-ISO/Win11-Unattend-ISO
github  git@github.com:[User Name]/Win11-Unattend-ISO.git

Verify branch tracking:

git branch -vv

Expected tracking:

master      ... [azure/master]
public-main ... [github/main]

Branch Purpose

master — Private development branch.

  • Pushes to Azure DevOps.
  • Keeps full commit history.
  • Used for normal daily development.

public-main — Public release branch.

  • Pushes to GitHub main.
  • Contains only curated public-ready commits.
  • Prevents private/intermediate Azure DevOps commits from being pushed to GitHub.

Preflight Safety Checks (Required)

Run this before any release work:

git fetch --all --prune
git status --short --branch
git remote -v
git branch -vv

Rules:

  • Working tree must be clean before switching branches for release.
  • Confirm master tracks azure/master.
  • Confirm public-main tracks github/main.
  • If the tree is not clean, commit or stash private changes first.

Normal Private Development

Work on master as usual:

git checkout master
git status
git add .
git commit -m "Describe private development change."
git push azure master

Azure DevOps keeps the full detailed history.

Publish a Public GitHub Release

First make sure private work is committed and pushed to Azure:

git checkout master
git status
git push azure master

Switch to the public branch and update from GitHub:

git checkout public-main
git pull --ff-only github main

Bring over only the files intended for public release from master:

# Bring specific files
git checkout master -- README.md docs/

# Or bring all tracked files (use with care)
# git checkout master -- .

Review exactly what will be published:

git status --short
git diff --stat

Commit the public update:

git add -A
git commit -m "docs: update public documentation for compare-iso and container workflow"

Push the public branch to GitHub main:

git push github public-main:main

One-Commit Sync from Private Azure to Public GitHub

Use this flow when you want the current master state to appear on GitHub as a single public commit (no intermediate private commit history).

  1. Ensure private branch is up to date in Azure:
git checkout master
git status
git push azure master
  1. Switch to the public branch and sync it with GitHub:
git checkout public-main
git pull --ff-only github main
  1. Copy the current private branch content into public branch working tree:
# Full tracked tree from master
git checkout master -- .

# Optional: include untracked public docs/files if needed
# git add -A
  1. Create one public commit:
git add -A
git commit -m "docs: moved all docs into docs folder and cleaned up markdown files"
  1. Publish that single public commit to GitHub:
git push github public-main:main

Notes:

  • This creates one new public commit representing the current private state you copied.
  • It does not expose intermediate private master commit history to GitHub.
  • Do not run git push github master:main.

Post-Publish Verification

After pushing, verify that public branch and GitHub main match:

git fetch github
git rev-list --left-right --count github/main...public-main
git log --oneline --decorate -n 5 github/main

Expected divergence output:

0       0

Switch Back to Azure Development

After publishing to GitHub, always return to the private development branch:

git checkout master
git status

Continue normal development:

git add .
git commit -m "Describe next private development change."
git push azure master

Important Rule

Do not run:

git push github master:main

That can publish private Azure DevOps history to GitHub.

Always publish to GitHub from public-main using:

git push github public-main:main

Public Release Commit Message Format

Use conventional commit style with a clear scope:

docs: short description
fix: short description
chore: short description

Examples:

docs: update compare-iso and container usage examples
fix: correct docker command for windows container mode
chore: align public docs folder links

Recovery (If Wrong Content Was Pushed)

Prefer a revert commit on public-main:

git checkout public-main
git revert <bad_commit_sha>
git push github public-main:main

Avoid force-push unless explicitly coordinated.

Summary

master
  ↓
Azure DevOps
  = private full history

public-main
  ↓
GitHub main
  = curated public release history