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:
- Start with Part 1 for setup and quick start commands.
- Use Part 2 when you want script-by-script details.
- Use Part 3 for lessons learned, troubleshooting notes, and historical context.
- 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.xmlin the working directory. It also deletes the downloadedautounattend.xmlafter 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
- Detailed script reference: Part 2: Script Reference
Prerequisites
You must manually download a Windows 11 ISO from Microsoft before running this tool.
- Native (Linux/WSL):
bash,curl, (jqorpython3),p7zip-full,xorriso - Native (Windows PowerShell):
oscdimg(ADK Deployment Tools),7zoptional. 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
.exeand/or.msifiles 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/AppInstallersinside the ISO. - A one-time startup script (
install-apps-once.cmd) is created when there are installers to run. - If
./appsis 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.
.msiinstallers run silently viamsiexec /i ... /qn /norestart..exeinstallers 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).
- Download the ADK installer from Microsoft: https://go.microsoft.com/fwlink/?linkid=2243390
- Run
adksetup.exe. - On the Select the features you want to install screen, tick only Deployment Tools.
- Click Install and wait for completion.
- Add
oscdimgto 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')
- 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
-
Keep local installer payloads out of git history and normal tracking.
- Use
.gitignoreforappscontent and avoid committing binary installers.
- Use
-
Docker builds must tolerate missing optional inputs.
./appsis optional: build should succeed if the folder is missing/empty.- Injection should run only when
.exe/.msifiles actually exist.
-
Optional features should be explicit in logs.
- Clear messages for "missing
./apps" and "no installers found" reduce debugging time.
- Clear messages for "missing
-
Document examples for both single and multiple
-Browsersvalues.- Include
-Browsers chromeand comma-separated variants in both Linux and Windows container examples.
- Include
-
Sync + rebuild is critical after script or Dockerfile changes.
- For Windows container testing, re-sync workspace content and rebuild images with
--no-cachewhen troubleshooting.
- For Windows container testing, re-sync workspace content and rebuild images with
-
Keep orchestrators thin and move stage-specific logic into child scripts.
-
Linux now mirrors PowerShell modular flow (browser staging, app staging, RunOnce generation, unattended apply/rebuild), which makes debugging and parity checks much easier.
-
When merging staged content, copy directory contents into destination roots, not the top-level directory node.
-
Copying
sourcesinto an existingsourcesfolder creates unintended nesting (sources/sources/...). -
Avoid hard dependency on a single JSON parser in shell tooling.
-
Linux scripts now support
jqfirst withpython3fallback, reducing environment-specific failures. -
Parameter defaults should not depend on runtime-provided script path variables when they can be empty in container entrypoints.
-
Resolve default paths after startup in script body rather than inside parameter default expressions.
-
Keep README focused on usage and move deep script internals to dedicated docs.
-
Script reference content now lives in
scripts-reference.mdto 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.xmlin the working directory. It also deletes the downloadedautounattend.xmlafter 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).7zis 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 —
oscdimgis baked into the image.
Installing Windows ADK (Deployment Tools) for oscdimg (Original Documentation)
- Download the ADK installer from Microsoft: https://go.microsoft.com/fwlink/?linkid=2243390
- Run
adksetup.exe. - On the Select the features you want to install screen, tick only Deployment Tools (you do not need the other features).
- Click Install and wait for it to complete.
- Add
oscdimgto 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.
- 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.txtwith the source URLs used for each selected browser.sources/$OEM$/$1/ProgramData/Microsoft/Windows/Start Menu/Programs/Startup/install-browsers-once.cmdwith 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 endingssources/$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:
- Right-click Docker Desktop tray icon → Switch to Windows containers
- Verify:
docker info --format "{{.OSType}}" # must print: windows
- 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:
- Verify the ADK install step completed:
docker run --rm --entrypoint powershell.exe win11-unattend-iso-pwsh -Command "oscdimg /?"
- If not found, rebuild with
--no-cacheto 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
- Container OS must match Docker engine mode.
- If
docker info --format "{{.OSType}}"islinux, use Linux base images. -
If it is
windows, use Windows base images. -
PowerShell script is Windows-only; use the shell script for Linux.
-
build-winiso.ps1requires Windows andoscdimg(ADK). For Linux/WSL, usebuild-winiso.shwithxorrisoandp7zip-full. -
Builder label is an immediate diagnostic signal.
-
Seeing
docker:desktop-linuxin build output explains whymcr.microsoft.com/windows/*images fail. -
Path and mount style follow container OS.
- Linux containers should use Linux paths inside the container (for example
/mnt/c/...). -
Windows containers should use Windows paths inside the container (for example
C:\...). -
MCR image tags must exist.
-
Invalid/nonexistent tags fail before any Dockerfile build steps run.
-
Use
--no-cachewhile troubleshooting. -
This avoids stale layers masking Dockerfile/script changes.
-
Avoid host symlink ambiguity for build context files.
-
When building from Windows PowerShell, prefer real files in
C:\MV-ISOforDockerfile.pwshand scripts. -
Dedicated Dockerfiles per OS are simpler and more reliable than cross-platform branching in a single Dockerfile.
- A single Dockerfile that tries to support both Linux and Windows engines adds fragile conditional logic and is harder to debug.
- Separate
Dockerfile(Linux) andDockerfile.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) orgrep(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 fromC:\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).
Symlink: Keep settings.json in the workspace
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(notln -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 trackingazure/masterpublic-main→ public release branch trackinggithub/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
mastertracksazure/master. - Confirm
public-maintracksgithub/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).
- Ensure private branch is up to date in Azure:
git checkout master
git status
git push azure master
- Switch to the public branch and sync it with GitHub:
git checkout public-main
git pull --ff-only github main
- 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
- Create one public commit:
git add -A
git commit -m "docs: moved all docs into docs folder and cleaned up markdown files"
- 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
mastercommit 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