Deploying Applications with Group Policy: MSI Packages, PowerShell Startup Scripts, and the Problems We Hit

Windows Group Policy makes it possible to centrally deploy applications to domain-joined PCs, but the traditional Software Installation policy has an important limitation: it is primarily designed around Windows Installer packages (.msi).

During this project I wanted domain computers to automatically receive a standard set of applications, including:

  • Notepad++
  • PuTTY
  • WireGuard
  • Speedtest by Ookla
  • TreeSize Free
  • Google Chrome

The first few packages deployed perfectly using normal Group Policy Software Installation. Speedtest, however, highlighted some of the limitations of the traditional approach and eventually led to a much more flexible solution: PowerShell computer startup scripts.

This article covers the complete process, including what worked, what failed, how the errors were diagnosed, and the deployment method I eventually settled on.


The Original GPO Software Deployment

I created an Apps Group Policy Object and linked it to the OU containing my domain computers.

The normal path for deploying MSI packages is:

Computer Configuration → Policies → Software Settings → Software Installation

Packages can then be added using:

New → Package

The important point is that the installer must be referenced using a UNC network path, for example:

\\Seido\The Nexus\Software\putty-64bit.msi

rather than a mapped drive such as:

X:\Software\putty-64bit.msi

A mapped drive normally belongs to a logged-in user’s session. Computer Group Policy runs as the machine account/SYSTEM and cannot assume that the user’s mapped drives exist.

The Apps GPO initially contained several assigned applications.

Applications deployed through Group Policy

After running:

gpupdate /force

and rebooting the client, most of the applications installed correctly.


Confirming the GPO Was Actually Applying

When one package fails to install, the first thing to establish is whether the entire GPO is failing or only that application.

On the client I ran:

gpresult /r /scope computer

The result included:

Applied Group Policy Objects
-----------------------------
    Computer Configs
    Apps
    Default Domain Policy
    Local Group Policy

The machine also showed membership of:

EDENIA$
Domain Computers
Authenticated Users

This confirmed that the Apps GPO was definitely being applied to the computer.

That was an important distinction.

If the Apps GPO had not appeared in gpresult, the investigation would have needed to focus on OU placement, security filtering, permissions, WMI filtering or replication.

Instead, the problem was isolated to a particular application.


The Speedtest Problem

The troublesome package was:

speedtestbyookla_x64.msi

Notepad++, PuTTY and WireGuard had installed, but Speedtest by Ookla did not.

Because it was an MSI, it should theoretically have been ideal for Software Installation.

I removed the Speedtest package from the GPO, added it again, ran:

gpupdate /force

and rebooted.

It still didn’t install.


Checking Windows Installer Events

The next step was to determine whether Windows Installer had actually attempted to launch Speedtest.

From PowerShell:

Get-WinEvent -FilterHashtable @{
    LogName='Application'
    ProviderName='MsiInstaller'
    StartTime=(Get-Date).AddHours(-2)
} |
Select-Object TimeCreated, Id, LevelDisplayName, Message |
Format-List

There were Windows Installer events, but they related to Microsoft GameInput.

There was nothing for Speedtest.

That was useful information because it suggested the Speedtest installer wasn’t simply starting and then failing.

Windows Installer apparently wasn’t being invoked for that package at all during Group Policy processing.


Testing the Speedtest MSI Manually

Before blaming the MSI itself, I tested exactly the same installer manually.

From an elevated PowerShell prompt:

msiexec /i "\\Seido\The Nexus\Software\speedtestbyookla_x64.msi" /qn /l*v C:\speedtest.log

The /qn option means:

/qn = completely silent installation

The command returned straight to the PowerShell prompt, which is normal for a successful completely silent installation.

I then checked for the package:

Get-Package -Name "*Speedtest*" -ErrorAction SilentlyContinue

The result was:

Name                 Version       ProviderName
----                 -------       ------------
Speedtest by Ookla   1.15.200.1    msi

So we had now proved several things:

The MSI was valid        ✓
The network path worked  ✓
Silent installation worked ✓
The Apps GPO applied     ✓

Yet Software Installation still refused to deploy it.

At that point, continuing to fight the traditional Software Installation extension wasn’t achieving much.


The Better Alternative: Computer Startup Scripts

Group Policy can execute PowerShell scripts when a computer starts.

This immediately removes the MSI-only limitation.

A startup script can deploy:

MSI
EXE
setup.exe + XML
custom installers
configuration files
services

provided the installer itself supports unattended installation.

The path is:

Computer Configuration → Policies → Windows Settings → Scripts (Startup/Shutdown) → Startup

There is a dedicated PowerShell Scripts tab.

This turned out to be much more flexible.


Deploying Speedtest with PowerShell

I created:

Install-Speedtest.ps1

with the following contents:

$Installer   = "\\Seido\The Nexus\Software\speedtestbyookla_x64.msi"
$ProductName = "Speedtest by Ookla"
$LogFile     = "C:\Windows\Temp\Speedtest-Install.log"

# Check whether Speedtest is already installed
$Installed = Get-Package -Name $ProductName -ErrorAction SilentlyContinue

if ($Installed) {
    exit 0
}

# Make sure installer is reachable
if (-not (Test-Path -LiteralPath $Installer)) {
    exit 1
}

# Install silently
$Process = Start-Process `
    -FilePath "msiexec.exe" `
    -ArgumentList "/i `"$Installer`" /qn /norestart /l*v `"$LogFile`"" `
    -Wait `
    -PassThru

# 0 = success
# 3010 = success, reboot required
if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) {
    exit 0
}

exit $Process.ExitCode

The logic is deliberately simple:

Computer starts
       │
       ▼
Is Speedtest already installed?
       │
   ┌───┴────┐
   │        │
  Yes       No
   │        │
 Exit     Can MSI be reached?
            │
         ┌──┴──┐
         │     │
        Yes    No
         │     │
      Install Exit
         │
         ▼
     msiexec /qn

This also prevents Speedtest being unnecessarily installed at every boot.


A Major Gotcha: Don’t Use Mapped Drives

My first startup-script entry exposed another important issue.

The script had been referenced using a path on the mapped X: drive.

Startup script referencing a mapped drive

This is unreliable for a Computer Startup Script.

At startup:

No user is logged in yet
        ↓
User drive mappings don't exist
        ↓
X: may not exist
        ↓
Startup script cannot be found

The solution was to use the Show Files… button in the Group Policy script configuration window.

This opens the GPO’s own startup-script location within SYSVOL.

I copied:

Install-Speedtest.ps1

into that folder and then added the script from there.

The GPO entry therefore only needs to show:

Install-Speedtest.ps1

The PowerShell file itself can still access software using UNC paths such as:

\\Seido\The Nexus\Software\speedtestbyookla_x64.msi

Once this was corrected, Speedtest installed successfully at startup.


Why SYSVOL Is the Right Place for the Script

The startup script effectively lives inside the GPO structure under SYSVOL, similar to:

\\order.realm\SYSVOL\order.realm\Policies\
    {GPO-GUID}\
        Machine\
            Scripts\
                Startup\
                    Install-Speedtest.ps1

The installer itself does not need to be stored there.

A much cleaner layout is:

SYSVOL
│
└── Install-Speedtest.ps1
         │
         └── Uses UNC path
                  │
                  ▼
\\Seido\The Nexus\Software\
        speedtestbyookla_x64.msi

This keeps small scripts with the GPO while allowing large installation files to remain on a dedicated software share.


Deploying TreeSize Free

Once Speedtest worked, the same mechanism could be used for applications distributed as EXE installers.

For TreeSize Free the installer was:

TreeSizeFreeSetup.exe

stored under:

\\Seido\The Nexus\Software\TreeSize\TreeSizeFreeSetup.exe

An example startup script is:

$Installer = "\\Seido\The Nexus\Software\TreeSize\TreeSizeFreeSetup.exe"

$PossiblePaths = @(
    "C:\Program Files\JAM Software\TreeSize Free\TreeSizeFree.exe",
    "C:\Program Files (x86)\JAM Software\TreeSize Free\TreeSizeFree.exe",
    "C:\Program Files\JAM Software\TreeSize\TreeSizeFree.exe"
)

$Installed = $false

foreach ($Path in $PossiblePaths) {

    if (Test-Path -LiteralPath $Path) {
        $Installed = $true
        break
    }
}

if ($Installed) {
    exit 0
}

if (-not (Test-Path -LiteralPath $Installer)) {
    exit 1
}

$Process = Start-Process `
    -FilePath $Installer `
    -ArgumentList "/VERYSILENT /NORESTART" `
    -Wait `
    -PassThru

if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) {
    exit 0
}

exit $Process.ExitCode

Unlike MSI packages, EXE installers do not have a universal silent-install command.

Different installers may use switches such as:

/S
/silent
/quiet
/VERYSILENT

Therefore the correct command-line switches need to be determined for each application.


Deploying Google Chrome

Google provides a 64-bit Enterprise MSI:

googlechromestandaloneenterprise64.msi

stored in my case as:

\\Seido\The Nexus\Software\Chrome\
    googlechromestandaloneenterprise64.msi

Chrome can therefore be deployed using msiexec in exactly the same way.

$Installer   = "\\Seido\The Nexus\Software\Chrome\googlechromestandaloneenterprise64.msi"
$ProductName = "Google Chrome"
$LogFile     = "C:\Windows\Temp\Chrome-Install.log"

$Installed = Get-Package `
    -Name $ProductName `
    -ErrorAction SilentlyContinue

if ($Installed) {
    exit 0
}

if (-not (Test-Path -LiteralPath $Installer)) {
    exit 1
}

$Process = Start-Process `
    -FilePath "msiexec.exe" `
    -ArgumentList "/i `"$Installer`" /qn /norestart /l*v `"$LogFile`"" `
    -Wait `
    -PassThru

if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) {
    exit 0
}

exit $Process.ExitCode

Adding verbose MSI logging is particularly useful during testing:

C:\Windows\Temp\Chrome-Install.log

If installation fails, the log remains on the client for investigation.


Package Detection Matters

One difference that became apparent while testing was how each script determines whether an application already exists.

For an MSI application such as Speedtest, this works nicely:

Get-Package -Name "Speedtest by Ookla"

For Chrome:

Get-Package -Name "Google Chrome"

For EXE-based applications, checking for the installed executable may be easier:

Test-Path "C:\Program Files\Vendor\Application\Application.exe"

However, hard-coded installation paths can create problems because applications may install into:

C:\Program Files

or:

C:\Program Files (x86)

and vendors occasionally change directory names between versions.

For MSI applications, package detection is therefore often preferable.


Why I Didn’t Put Everything into One Massive Script

It would be possible to create:

Install-All-Apps.ps1

containing Speedtest, Chrome, TreeSize, Office and every other application.

However, individual scripts have some advantages.

For example:

Install-Speedtest.ps1
Install-Chrome.ps1
Install-TreeSize.ps1
Install-Office.ps1

This makes troubleshooting considerably easier.

If Chrome fails, I know exactly which deployment component is responsible.

It also means individual applications can be enabled, disabled or modified without affecting unrelated software.


Be Careful with Startup Scripts That Change Networking

Earlier in the project I also used Group Policy to configure WireGuard.

That exposed another important lesson.

The initial VPN script was doing more than merely installing software. It was changing:

DNS
network adapters
routing
WireGuard services

During Group Policy processing this caused the machine to temporarily lose access to:

\\order.realm\SYSVOL

resulting in errors similar to:

The processing of Group Policy failed.

Windows attempted to read the file:

\\order.realm\sysvol\order.realm\Policies\
{31B2F340-016D-11D2-945F-00C04FB984F9}\gpt.ini

and was not successful.

This made it initially look as though Group Policy itself was broken.

In reality, the startup process was modifying the network environment that Group Policy itself depended upon.

This is why I now separate simple application deployment from anything that manipulates networking.


WireGuard: Service Deployment vs Application Import

WireGuard also demonstrated another useful distinction.

Installing a configuration using:

wireguard.exe /installtunnelservice DecoVPN.conf

creates a Windows WireGuard tunnel service.

The resulting adapter appeared in Windows Network Connections as:

DecoVPN
WireGuard Tunnel
WireGuard tunnel created as a Windows adapter

Initially I expected the configuration to visibly import into the WireGuard desktop application.

Instead, the tunnel had been installed as a Windows service.

Testing showed that it was actually functioning:

sc query "WireGuardTunnel$DecoVPN"

returned:

STATE : 4 RUNNING

and:

"C:\Program Files\WireGuard\wg.exe" show

showed:

interface: DecoVPN

peer: ...
endpoint: 82.x.x.x:51820
allowed ips: 192.168.68.0/24
latest handshake: 39 seconds ago
transfer: 8.05 MiB received, 4.65 MiB sent
persistent keepalive: every 25 seconds

So the tunnel itself was working perfectly.

The remaining problem was DNS.


Diagnosing the VPN DNS Problem

The internal server could be reached by IP:

ping 192.168.68.20

returned:

Reply from 192.168.68.20

However:

ping seido.order.realm

failed.

Running:

nslookup seido.order.realm

showed why:

Server:  cache1.service.virginmedia.net
Address: 194.168.4.100

*** can't find seido.order.realm:
Non-existent domain

The machine was sending the lookup to Virgin Media’s public DNS rather than the internal domain DNS server.

The WireGuard adapter therefore needed:

192.168.68.20

as its DNS server.

This could be enforced with PowerShell:

Set-DnsClientServerAddress `
    -InterfaceAlias "DecoVPN" `
    -ServerAddresses "192.168.68.20"

The script was then made deliberately cautious.

Other physical interfaces would only be reset to automatic DNS after internal domain connectivity had been confirmed.

For example:

if (
    Test-Connection `
        -ComputerName "seido.order.realm" `
        -Count 1 `
        -Quiet
) {

    Set-DnsClientServerAddress `
        -InterfaceIndex $NetAdapter.ifIndex `
        -ResetServerAddresses
}

This prevents a failed VPN configuration from unnecessarily changing working network settings.


Another WireGuard Problem: Installing Directly from a UNC Path

During testing another error appeared:

Not a valid Win32 FileTime

The original script was interacting directly with:

\\Seido\The Nexus\Wireguard\DecoVPN.conf

and then removing the currently working tunnel before confirming that the replacement configuration could actually be used.

That was risky.

The improved sequence became:

Copy configuration from server
            ↓
Verify local file exists
            ↓
Only then remove existing tunnel
            ↓
Install new tunnel

The local configuration was stored as:

C:\ProgramData\WireGuard\DecoVPN.conf

This means a temporary failure to access the network share cannot destroy an already-working VPN configuration.

That same principle applies to application deployment:

Validate dependencies before removing or changing something that already works.


Logging Makes Startup Scripts Much Easier to Troubleshoot

Startup scripts run without a normal interactive user interface.

If something fails, it may appear that nothing happened at all.

For MSI packages I therefore prefer verbose logs.

Example:

$LogFile = "C:\Windows\Temp\Application-Install.log"

Start-Process `
    -FilePath "msiexec.exe" `
    -ArgumentList "/i `"$Installer`" /qn /norestart /l*v `"$LogFile`"" `
    -Wait

If the installation fails, search the log for:

Return value 3

which normally occurs close to the actual Windows Installer failure.


Useful Troubleshooting Commands

Several commands proved particularly useful during this project.

Confirm computer GPOs

gpresult /r /scope computer

For significantly more detail:

gpresult /z > C:\gp.txt
notepad C:\gp.txt

Force Group Policy refresh

gpupdate /force

Software assigned to computers will commonly require a reboot before installation occurs.


Check installed MSI packages

Get-Package

or:

Get-Package -Name "*Speedtest*"

Query recent Windows Installer events

Get-WinEvent -FilterHashtable @{
    LogName='Application'
    ProviderName='MsiInstaller'
    StartTime=(Get-Date).AddHours(-2)
} |
Select-Object TimeCreated, Id, LevelDisplayName, Message |
Format-List

Test an MSI silently

msiexec /i "\\server\share\application.msi" /qn

With logging:

msiexec /i "\\server\share\application.msi" /qn /l*v C:\install.log

Find useful errors in the MSI log

Select-String `
    -Path C:\install.log `
    -Pattern "Return value 3" `
    -Context 10,10

A Better Software Deployment Layout

The resulting setup is now essentially:

Active Directory
│
└── Apps GPO
     │
     ├── Traditional Software Installation
     │      ├── Notepad++
     │      ├── PuTTY
     │      └── WireGuard
     │
     └── Computer Startup PowerShell Scripts
            ├── Install-Speedtest.ps1
            ├── Install-TreeSize.ps1
            ├── Install-Chrome.ps1
            └── future applications...

The installers themselves remain centrally stored under:

\\Seido\The Nexus\Software\

For example:

Software
│
├── Chrome
│   └── googlechromestandaloneenterprise64.msi
│
├── TreeSize
│   └── TreeSizeFreeSetup.exe
│
├── speedtestbyookla_x64.msi
│
└── Office
    └── ...

MSI Software Installation vs Startup Scripts

Traditional Software Installation is still useful.

For a simple, well-behaved enterprise MSI:

GPO Software Installation

is extremely easy to manage.

But startup scripts provide considerably more flexibility.

RequirementSoftware InstallationPowerShell Startup
MSI deployment
EXE deployment
Custom silent switchesLimited
Installation detectionLimited
Conditional logic
Custom loggingLimited
Network tests
Multiple deployment methods
Custom error handling

The trade-off is that PowerShell deployment requires more work to design correctly.


Lessons Learned

The biggest lesson from this project is that a package being an MSI does not automatically guarantee that Group Policy Software Installation will behave exactly as expected.

Speedtest was the perfect example.

The installer:

speedtestbyookla_x64.msi

worked perfectly when run as:

msiexec /i speedtestbyookla_x64.msi /qn

yet repeatedly failed to deploy using the Software Installation policy.

Rather than spending more time forcing that particular deployment mechanism to work, moving it to a Computer Startup PowerShell script gave much more control.

The other major lessons were:

  • Use UNC paths, not mapped drives, for computer-level deployments.
  • Store startup .ps1 files inside the GPO using Show Files….
  • Keep installers on a dedicated central software share.
  • Detect whether applications already exist before installing.
  • Use /qn for unattended MSI installation.
  • Add verbose installer logs during testing.
  • Treat exit code 3010 as success with a reboot required.
  • Don’t casually manipulate DNS or networking during Group Policy processing.
  • Verify remote resources before removing a working configuration.
  • Separate complicated deployments into individual scripts so one failure doesn’t affect everything else.

Conclusion

What started as a straightforward attempt to push a few MSI applications through Active Directory ended up producing a much more useful deployment framework.

Group Policy’s built-in Software Installation feature remains great for straightforward MSI packages, but PowerShell computer startup scripts remove most of its limitations.

They allow the same domain infrastructure to deploy:

MSI installers
EXE installers
silent setup programs
Office deployments
configuration files
services
VPN components

while still retaining central control through Group Policy.

For this environment, the combination of:

Active Directory
       +
Group Policy
       +
SYSVOL PowerShell scripts
       +
\\Seido\The Nexus\Software

has turned into a simple but surprisingly capable software deployment system.

And importantly, the Speedtest failure wasn’t wasted troubleshooting—it exposed exactly where traditional GPO Software Installation stops being convenient and where PowerShell becomes the better tool.