Veeam ·

Veeam Update Repository Mirror on IIS for VSA/VIA (Windows Veeam ONE server)

1. Overview

This guide walks through building a local mirror of the Veeam Update Repository (repository.veeam.com/vsa) using Windows Server and IIS only — no Linux or WSL required. It is intended for environments where Veeam Software Appliance (VSA) or Veeam Infrastructure Appliance (VIA) deployments cannot reach the internet directly (OT networks, dark sites, or other closed environments).

The mirror is hosted on the Veeam ONE server because it is already a Windows server with IIS installed, so there is no extra server to build. This guide is best used in air-gapped, federal or dark sites, or wherever the backup server or Enterprise Manager has no internet access.

The process has four stages:

  1. Download and package the repository content on a Windows machine that has internet access.
  2. Set up an IIS website on a server inside the closed environment to host that content.
  3. Hand-carry the packaged files into the closed environment and deploy them to the IIS server.
  4. Point the VSA/VIA appliance's update configuration at the new local mirror.

2. Prerequisites

  • A Windows machine with internet access, to run the download/packaging script (this can be temporary/disposable).
  • PowerShell 7 installed on that machine (the script has been tested on PowerShell 7).
  • A Windows Server inside the closed environment with the IIS role available.
  • Disk space: budget roughly 30 GB for the downloaded content plus another 30 GB for the zip file at time of writing — this will grow with newer Veeam releases.
  • A method approved by your organization for carrying media into the closed environment (USB drive, approved transfer process, etc.).
  • Correct DNS or hosts-file resolution for whatever hostname you plan to use for the IIS server (e.g. VeeamRepo).

3. Set up IIS on the Veeam ONE server

IIS is hardened by default and will not serve arbitrary file types for download out of the box. This section configures a dedicated site that safely allows browsing and downloading the mirrored repository files.

3.1 Create the repository folder

  1. Create a folder to hold the updates, for example D:\inetpub\veeamrepo.

    File Explorer showing a veeamrepo folder under D:\inetpub
  2. Set the NTFS permissions on the veeamrepo folder to match C:\inetpub\wwwroot. At minimum, grant the IIS_IUSRS group Read & execute, List folder contents and Read. Make sure the permissions are inherited by child objects.

    Security tabs of wwwroot and veeamrepo side by side, both granting IIS_IUSRS Read & execute, List folder contents and Read
  3. Inside veeamrepo, create a subfolder named vsa.

    The veeamrepo folder containing a vsa subfolder and a web.config file

3.2 Create the web.config file

In D:\inetpub\veeamrepo (next to, not inside, the vsa subfolder), create a file named web.config with the content in Appendix 1: web.config.

This configuration denies access to the website's root (which contains the web.config file itself), while explicitly allowing access to the vsa subfolder.

3.3 Create the website in IIS Manager

  1. In IIS Manager, right-click Sites and choose Add Website…

    IIS Manager context menu on Sites with Add Website highlighted
  2. Provide a site name and set the physical path to D:\inetpub\veeamrepo. Then:

    • Enter a port number, e.g. 4433 (make sure it is unused).
    • Select an SSL certificate. You may use the certificate used by Veeam ONE (cross-reference it with the VeeamReporter website in IIS).
    Add Website dialog: physical path D:\inetpub\veeamrepo, https binding on port 4433, Disable Legacy TLS ticked, Veeam ONE Website self-signed certificate selected
  3. In the same dialog, click View… on the selected certificate, open the Details tab and click Copy to File…

    Certificate Details tab with the Copy to File button
  4. Export the certificate as Base-64 encoded X.509 (.CER). Give it a file name and save it, e.g. veeamrepo-root1.cer in C:\temp. You will select this file on the appliance in section 6.

    Certificate Export Wizard with Base-64 encoded X.509 selected
  5. Validate and add MIME types in IIS: select the server and open MIME Types.

    IIS Manager server home with the MIME Types feature highlighted

    Look for the .asc file name extension. If it is not there, click Add… and create it:

    • File name extension: .asc
    • MIME type: text/plain
    Add MIME Type dialog with extension .asc and MIME type text/plain
  6. Allow IIS to accept the “+” sign in request filtering. Launch PowerShell as Administrator and run the following, replacing <SiteName> with the site name from step 2:

    Import-Module WebAdministration
    Set-WebConfigurationProperty -PSPath 'IIS:\Sites\<SiteName>' -Filter 'system.webServer/security/requestFiltering' -Name 'allowDoubleEscaping' -Value $true
    Administrator PowerShell window running the two commands for a site named veeamrepo

4. Download the updates

Use the download script in Appendix 2: sync-mirrorrepo.ps1 and run it on the machine that has internet access. The script downloads everything under https://repository.veeam.com/vsa and then zips it up into a single file.

  1. Before running it, right-click the script file → Properties. If an Unblock checkbox is present (Windows marks files transferred from another source as blocked), tick it and click OK. Files that are still blocked can trigger unexpected access restrictions.
  2. Edit the script file and set the values for:

    SettingWhat it is
    [string]$OutRoot = "D:\Downloads\veeamrepo\vsa"Where the downloaded patches are stored. Keep the last folder named vsa: the script zips this folder itself, so the zip then extracts straight into the layout IIS expects.
    [string]$ZipPath = "D:\Downloads\veeam-vsa.zip"Where the zip file is created.

    Make sure the output folders exist.

  3. Run the script from PowerShell on the download machine. Go to the folder where you saved it and type:

    .\sync-mirrorrepo.ps1
    PowerShell window running .\sync-mirrorrepo.ps1

5. Copy the updates to the mirror server

  1. Unzip veeam-vsa.zip into the folder created in section 3.1 (e.g. D:\inetpub\veeamrepo).
  2. The folder structure should look like the screenshot below. The structure must be kept as it is.

    File Explorer at D:\inetpub\veeamrepo\vsa showing version folders 9.2 and 9.6
  3. Check that the IIS website and the folder structure are in order by browsing to https://<your_server_fqdn_or_hostname>:4433/vsa. The server is your Veeam ONE server, and the port is the one configured in section 3.3.

    Browser showing the IIS directory listing of /vsa/ on port 4433 with folders 9.2 and 9.6

6. Point the appliance at the mirror

Configure the VSA/VIA appliance (or the VBR server, for all managed appliances) to use the new mirror instead of the public Veeam repository.

  1. In the VSA/VIA Host Management Console, go to Updates › Settings and turn on Manual settings override.

    Veeam Host Management Console, Updates › Settings with Manual settings override switched on
  2. Select Mirror repository as the update source. Enter the URL and choose the certificate exported in section 3.3.

    Software repository set to Mirror repository with an https address on port 4433 and server certificate veeamrepo-root1.cer
  3. Save the configuration, then trigger a check for updates to confirm the appliance can reach and read the mirror.

    The Save button on the Settings tab

7. Verify the setup

Run Check for updates and make sure it completes successfully.

8. References

Common issues

1. Expired GPG keys

Occasionally the update check may fail, as in the screenshot below. This can be caused by an outdated cache in the XML file that comes from the source.

Failed to check for updates notification: status code 404 for repodata files while downloading metadata

Resolution/workaround:

  1. Follow the steps in Veeam KB4834.
  2. Delete all the downloaded items and download the files again using the download script.

2. Status 404 for .asc files

The update fails with status 404 when loading an .asc file. By default, IIS will not serve a file whose extension is not listed in its MIME types.

Failed to check for updates: status code 404 for repomd.xml.asc, GPG signature not available

Follow section 3.3, step 5 to add the extension. Afterwards, you can browse to an .asc file to check it downloads — for the error above that is https://<your-site>/vsa/9.6/vbr/13.1/mandatory/repodata/repomd.xml.asc.

3. Downloaded file names contain “%2B”

The files were saved with URL-encoded names (%2B instead of +). Make sure the downloaded files show “+” signs instead of “%2B”. Here is an example of downloaded binaries that contain “%2B”:

File Explorer listing an rpm file named with %2B instead of a plus sign

Rename the files manually, replacing “%2B” with “+”. If many files are affected, use PowerShell to do the renaming — see Appendix 3: rename_files.ps1.

Appendix

Download the files directly:

1. web.config

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <system.webServer>
    <security>
      <authorization>
        <!-- Deny all users access to the root of the website, since it
             contains this web.config -->
        <remove users="*" roles="" verbs="" />
        <add accessType="Deny" users="*" />
      </authorization>
      <requestFiltering allowDoubleEscaping="true" />
    </security>
    <caching enabled="false" enableKernelCache="false" />
  </system.webServer>

  <location path="vsa" allowOverride="false">
    <system.webServer>
      <directoryBrowse enabled="true" showFlags="Date, Time, Size, Extension" />
      <defaultDocument>
        <files>
          <!-- When requesting a file listing, don't serve up the default
               index.html file if it exists. -->
          <clear />
        </files>
      </defaultDocument>

      <security>
        <authorization>
          <!-- Allow all users access to the Public folder -->
          <remove users="*" roles="" verbs="" />
          <add accessType="Allow" users="*" roles="" />
        </authorization>

        <!-- Unblock all sourcecode related extensions (.cs, .aspx, .mdf)
             and files/folders (web.config, bin) -->
        <requestFiltering>
          <hiddenSegments>
            <clear />
          </hiddenSegments>
          <fileExtensions>
            <clear />
          </fileExtensions>
        </requestFiltering>
      </security>

      <!-- Map all extensions to the same MIME type, so all files can be
           downloaded. -->
      <staticContent>
        <clear />
        <mimeMap fileExtension="*" mimeType="application/octet-stream" />
      </staticContent>
    </system.webServer>
  </location>

</configuration>

2. sync-mirrorrepo.ps1

Show the full script (307 lines)
<#
.SYNOPSIS
  Fetch the entire content (recursively) from https://repository.veeam.com/vsa/
  and zip it after download.

.DESCRIPTION
  - Preserves folder structure locally.
  - Skips files that have already been downloaded with the same size.
  - Retries on temporary network errors.
  - Zips the full local copy into a single .zip at the end.

.PARAMETER BaseUrl
  Root URL of the Veeam update repository (default: https://repository.veeam.com/vsa/)

.PARAMETER OutRoot
  Local root folder for download (default: D:\inetpub\veeamrepo\vsa)

.PARAMETER ZipPath
  Location of the zip file after sync (default: D:\Downloads\veeam-vsa.zip)

.PARAMETER MaxRetries
  Number of attempts on error (default: 3)
#>

[CmdletBinding()]
param(
  [string]$BaseUrl   = "https://repository.veeam.com/vsa/",
  [string]$OutRoot   = "D:\inetpub\veeamrepo\vsa",
  [string]$ZipPath   = "D:\Downloads\veeam-vsa.zip",
  [int]$MaxRetries   = 3
)

# Ensure TLS 1.2 for any HTTPS redirect
 try {
   [System.Net.ServicePointManager]::SecurityProtocol = `
     [System.Net.SecurityProtocolType]::Tls12 `
     -bor [System.Net.SecurityProtocolType]::Tls11 `
     -bor [System.Net.SecurityProtocolType]::Tls
 } catch { }

# Use a fixed User-Agent so directory listing isn't denied
$Global:DefaultHeaders = @{
  "User-Agent" = "PowerShell/7 (Sync-VeeamVAS)"
}

# Helper: Create absolute URI from base + relative
function Join-Uri {
  param(
    [Parameter(Mandatory=$true)][string]$Base,
    [Parameter(Mandatory=$true)][string]$Href
  )
  $baseUri = [System.Uri]$Base
  # Handle absolute, root-relative, and relative
  if ($Href -match '^(http|https)://') {
    return $Href
  } elseif ($Href.StartsWith('/')) {
    return ($baseUri.Scheme + "://" + $baseUri.Host + $Href)
  } else {
    return ([System.Uri]$baseUri, $Href -join '')
  }
}

# Decode percent-encoded characters (e.g. %2B -> +) for safe local filesystem naming.
# IMPORTANT: only applied to the LOCAL file/folder name, never to the URL used for the
# actual HTTP request -- IIS/HTTP.sys expects the request to keep its original encoding.
function Get-DecodedLocalName {
  param([Parameter(Mandatory=$true)][string]$EncodedName)
  return [System.Uri]::UnescapeDataString($EncodedName)
}

# Fetch and parse directory listing into files and folders
function Get-DirectoryItems {
  param([Parameter(Mandatory=$true)][string]$Url)

  Write-Verbose "Fetching listing: $Url"

  $resp = Invoke-WebRequest -Uri $Url -Headers $Global:DefaultHeaders -ErrorAction Stop -MaximumRedirection 10

  $content = $resp.Content


  # Simpler and robust: match only href attributes, not the full <a> tag structure.
  # Note: Use the RegexOptions enum, NOT a string.
  $hrefMatches = [System.Text.RegularExpressions.Regex]::Matches(
    $content,
    'href\s*=\s*"([^"]+)"',
    [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
  )

  $items = @()
  foreach ($m in $hrefMatches) {
    $href = $m.Groups[1].Value

    # Exclude parent, sorting, and fragments
    if ($href -eq "../" -or $href.StartsWith("?") -or $href.StartsWith("#")) { continue }

    $abs = Join-Uri -Base $Url -Href $href

    if ($href.EndsWith('/')) {
      # Folder name = last segment without trailing slash
      $rawName = ($href.TrimEnd('/') -split '/')[-1]
      if ([string]::IsNullOrWhiteSpace($rawName)) { $rawName = $href.TrimEnd('/') }
      $name = Get-DecodedLocalName -EncodedName $rawName
      $items += [pscustomobject]@{ Type="Directory"; Name=$name; Url=$abs }
    } else {
      # File name = last segment
      $rawName = ($href -split '/')[-1]
      # Exclude plain listing files
      if ($rawName -match '^index\.(html?|htm)$') { continue }
      $name = Get-DecodedLocalName -EncodedName $rawName
      $items += [pscustomobject]@{ Type="File"; Name=$name; Url=$abs }
    }
  }

  return $items
}

# HEAD to get Content-Length (may be missing on some endpoints)

function Get-RemoteContentLength {
  param([Parameter(Mandatory=$true)][string]$Url)

  # 1) Try HEAD first
  try {
    $head = Invoke-WebRequest -Uri $Url -Method Head -Headers $Global:DefaultHeaders -ErrorAction Stop

    if ($head -and $head.Headers) {
      $clVals = $head.Headers['Content-Length']     # string or string[]
      $clStr  = $null

      if ($clVals -is [string]) {
        $clStr = $clVals
      } elseif ($clVals -is [System.Array]) {
        if ($clVals.Length -gt 0) { $clStr = $clVals[0] }
      }

      if ($clStr -and ($clStr -match '^\d+$')) {
        return [int64]$clStr
      }
    }
  } catch {
    # ignore and fall back
  }

  # 2) Fallback: GET a single byte and parse Content-Range (e.g., "bytes 0-0/123456")
  try {
    # Clone headers and add Range (and optionally disable compression)
    $rangeHeaders = @{}
    foreach ($kv in $Global:DefaultHeaders.GetEnumerator()) { $rangeHeaders[$kv.Key] = $kv.Value }
    $rangeHeaders['Range'] = 'bytes=0-0'
    # $rangeHeaders['Accept-Encoding'] = 'identity'   # optional

    $resp = Invoke-WebRequest -Uri $Url -Method Get -Headers $rangeHeaders -ErrorAction Stop

    if ($resp -and $resp.Headers) {
      $crVals = $resp.Headers['Content-Range']       # string or string[]
      $crStr  = $null
      if ($crVals -is [string]) {
        $crStr = $crVals
      } elseif ($crVals -is [System.Array]) {
        if ($crVals.Length -gt 0) { $crStr = $crVals[0] }
      }

      if ($crStr -and ($crStr -match '/(\d+)$')) {
        return [int64]$Matches[1]
      }
    }
  } catch {
       # Range not supported or server rejects: return $null
  }

  return $null
}

# Download with retry and skip if local file has identical size
function Download-File {
  param(
    [Parameter(Mandatory=$true)][string]$Url,
    [Parameter(Mandatory=$true)][string]$OutFile,
    [int]$MaxRetries = 3
  )
  $remoteLen = Get-RemoteContentLength -Url $Url
  $dir = Split-Path -Path $OutFile -Parent
  if (-not (Test-Path -LiteralPath $dir)) {
    New-Item -ItemType Directory -Path $dir -Force | Out-Null
  }

  if (Test-Path -LiteralPath $OutFile -PathType Leaf) {
    try {
      $localLen = (Get-Item -LiteralPath $OutFile).Length
      if ($remoteLen -ne $null -and $localLen -eq $remoteLen) {
        Write-Host "SKIP (match size)  $Url -> $OutFile  ($localLen bytes)"
        return
      }
    } catch { }
  }

  $attempt = 0
  while ($attempt -lt $MaxRetries) {
    $attempt++
    try {
      Write-Host "Downloading [$attempt/$MaxRetries] $Url"
      Invoke-WebRequest -Uri $Url -Headers $Global:DefaultHeaders -OutFile $OutFile -ErrorAction Stop
      # Validate size if we know remoteLen
      if ($remoteLen -ne $null) {
        $localLen = (Get-Item -LiteralPath $OutFile).Length
        if ($localLen -ne $remoteLen) {
          throw "Size mismatch: local=$localLen, remote=$remoteLen"
        }
      }
      return
    } catch {
      Write-Warning "Download error ($Url): $($_.Exception.Message)"
      Start-Sleep -Seconds ([math]::Min(15, 3 * $attempt))
      if ($attempt -ge $MaxRetries) {
        Write-Error "Giving up after $MaxRetries attempts: $Url"
      }
    }
  }
}

# Recursively sync from URL to local folder
function Sync-Url {
  param(
    [Parameter(Mandatory=$true)][string]$Url,
    [Parameter(Mandatory=$true)][string]$LocalRoot
  )
  if (-not (Test-Path -LiteralPath $LocalRoot)) {
    New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null
  }

  $items = Get-DirectoryItems -Url $Url

  # Folders first, then files
  foreach ($d in $items | Where-Object { $_.Type -eq "Directory" }) {
    $subLocal = Join-Path -Path $LocalRoot -ChildPath $d.Name
    Write-Host "==> Folder: $($d.Url)  ->  $subLocal"
    Sync-Url -Url $d.Url -LocalRoot $subLocal
  }
  foreach ($f in $items | Where-Object { $_.Type -eq "File" }) {
    $outFile = Join-Path -Path $LocalRoot -ChildPath $f.Name
    Download-File -Url $f.Url -OutFile $outFile -MaxRetries $MaxRetries
  }
}

# ==== Execution ====
Write-Host "Starting sync from: $BaseUrl"
Write-Host "Local root folder:  $OutRoot"
Sync-Url -Url $BaseUrl -LocalRoot $OutRoot

# === Verify no percent-encoded names remain locally ===
Write-Host "`nVerifying no percent-encoded characters remain in local file/folder names..."
$stillEncoded = Get-ChildItem -Path $OutRoot -Recurse -Force |
    Where-Object { $_.Name -match '%[0-9A-Fa-f]{2}' }

if ($stillEncoded) {
    Write-Warning "Found $($stillEncoded.Count) item(s) still containing percent-encoding (needs manual review):"
    $stillEncoded | Select-Object FullName | Format-Table -AutoSize
} else {
    Write-Host "Clean -- no percent-encoded names found." -ForegroundColor Green
}

# === Zip (user interaction) ===
$caption = "Create ZIP"
$message = "Do you want to create a new zip file at `"$ZipPath`" from `"$OutRoot`"?"
$choices = [System.Management.Automation.Host.ChoiceDescription[]]@(
    (New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Create or overwrite the zip file.'),
    (New-Object System.Management.Automation.Host.ChoiceDescription '&No',  'Skip creating the zip file.')
)

# Default choice: No (index 1)
$decision = $Host.UI.PromptForChoice($caption, $message, $choices, 1)

if ($decision -eq 0) {
    Write-Host "Preparing to compress: $OutRoot -> $ZipPath"
    try {
        $doCompress = $true
        if (Test-Path -LiteralPath $ZipPath) {
            $overwriteCaption = "Overwrite existing ZIP?"
            $overwriteMessage = "A zip file already exists at `"$ZipPath`". Do you want to overwrite it?"
            $overwriteChoices = [System.Management.Automation.Host.ChoiceDescription[]]@(
                (New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Overwrite the existing zip file.'),
                (New-Object System.Management.Automation.Host.ChoiceDescription '&No',  'Keep the existing zip and skip compression.')
            )
            # Default: No (index 1)
            $overwriteDecision = $Host.UI.PromptForChoice($overwriteCaption, $overwriteMessage, $overwriteChoices, 1)

            if ($overwriteDecision -ne 0) {
                Write-Host "Skipping compression: existing zip kept."
                # Exit the zip step but continue the script
                $doCompress = $false
            } else {
                # Remove existing zip so Compress-Archive can create a fresh one
                Remove-Item -LiteralPath $ZipPath -Force
            }
        }
        if ($doCompress) {
            Compress-Archive -Path $OutRoot -DestinationPath $ZipPath -Force
            Write-Host "ZIP created: $ZipPath"
        }

    } catch {
        Write-Error "Could not compress: $($_.Exception.Message)"
    }
} else {
    Write-Host "Skipping compression per user choice."
}

3. rename_files.ps1

$root = 'D:\inetpub\veeamrepo\vsa'   # change to your mirror directory

Get-ChildItem -LiteralPath $root -Recurse -File |
  Where-Object { $_.Name -match '%[0-9A-Fa-f]{2}' } |
  ForEach-Object {
    $newName = [System.Uri]::UnescapeDataString($_.Name)
    $target  = Join-Path $_.DirectoryName $newName
    if (Test-Path -LiteralPath $target) {
      Write-Warning "Exists, skipping: $target"
    } else {
      Rename-Item -LiteralPath $_.FullName -NewName $newName
    }
  }

Credit: this guide is based on “Veeam Update Repository Mirror on IIS (Windows Server)” by Martin Plesner-Jacobsen on the Veeam Community Resource Hub. Thanks for sharing!