Skip to content

Custom Sealing Scripts

PVS Forge can run your own scripts on the master target around the imaging run — a pre-sealing script before the image is captured (stop services, clean machine-specific leftovers, run a vendor cleanup tool) and a symmetric post-sealing script afterwards (re-enable whatever you turned off). This page explains how the scripts must be built so they run cleanly, and gives ready-to-use templates.

When and how they run

The two scripts frame the imaging run:

Pre-sealing script  ->  Sealing  ->  Imaging  ->  Post-sealing script  ->  Re-activation
  • The pre-sealing script runs after the pending-reboot check and before auto-updates are disabled — i.e. before the image is captured. Everything it changes ends up in the vDisk.
  • The post-sealing script runs after imaging and before auto-updates are re-enabled. Use it to undo what the pre-sealing script did, e.g. re-enable the services and update tasks the pre-sealing script disabled, so the master target returns to a working state.
  • Both run elevated under the imaging account, non-interactively (headless), with no interactive user profile or desktop.
  • Supported file types (identical for both scripts):
    • .ps1powershell.exe -NonInteractive -ExecutionPolicy Bypass -File "<path>"
    • .cmdcmd.exe /c "<path>"
  • Each run is recorded in the seal/unseal log (<vDisk>.seal-unseal.log on the store) with its start time, the command, stdout, stderr, exit code and duration — so you can see exactly what your script did.

Configure the paths, timeouts and error behaviour in Settings → vDisk & Imaging.

Paths and environment variables

Both script paths may point to a local path on the master target or a UNC share the master target can reach — for example a central script on NETLOGON. PVS Forge fetches a UNC script to a temporary local copy first (using the imaging credentials) and runs that copy, so it works even though the imaging account has no interactive network session.

Environment variables in the path are expanded on the master target:

Supported Examples
Machine variables %SystemRoot%, %windir%, %SystemDrive%, %ProgramFiles%, %ProgramFiles(x86)%, %ProgramData%, %ALLUSERSPROFILE%, %ComputerName%, %Public%
Domain / logon (resolved by PVS Forge) %LOGONSERVER%, %USERDNSDOMAIN%, %USERDOMAIN%
Not supported — do not use Why
%USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %HOMEPATH%, %USERNAME% They resolve to the imaging service account, not a real user — not what you expect

Central scripts on NETLOGON

\\%LOGONSERVER%\NETLOGON\seal.cmd works, but \\<your-domain>\NETLOGON\seal.cmd (e.g. \\contoso.com\NETLOGON\seal.cmd) is more robust — it always resolves to a live domain controller. UNC access needs domain credentials: the imaging account must be able to read the share.

Per-master-target scripts with %MT%

With several master targets you can keep one script configuration and still run target-specific scripts, using the %MT% placeholder in the path:

Token Applies to Example (3 targets)
%MT% all master targets (no position filter) runs for 1, 2 and 3
%MT2% only position 2 runs for 2
%MT13% positions 1 and 3 — each digit is one position (not "thirteen"), max position 9 runs for 1 and 3

The position is the running number shown in front of each entry in the master-target list (top = 1). %MT% is replaced by the current target's short (NetBIOS) name and can be used as a folder or a file-name part:

  • \\%LOGONSERVER%\NETLOGON\%MT%\pre.ps1\\...\NETLOGON\SRV04\pre.ps1
  • \\%LOGONSERVER%\NETLOGON\scripts\pre-%MT13%.ps1 (position 1) → ...\pre-SRV04.ps1

Behaviour:

  • Position not selected by %MT<digits>% → the script is simply not run for that target (no error, no abort).
  • Script meant for this target (%MT% for all targets, or a matching %MT<digits>% position) that does not exist → the run is aborted before imaging. If a target is meant to run the hook, its script must be present — otherwise it is treated as a configuration mistake.
  • A fixed path (no %MT% token) that does not exist → likewise aborted before imaging, as before.

Positions are permanent

The position belongs to the master target and never changes while the target stays in the list — not through imaging runs, not through restarting PVS Forge, and not when further targets are added (new ones go to the end or fill a gap, see below). Your %MT<digits>% filters stay stable.

A target that belongs to a running imaging run cannot be deleted — cancel the run first (stop icon on the row), then remove the target.

When a target is deleted, all remaining targets keep their numbers — the list then shows a visible gap (e.g. 1, 3). The next newly added target fills the smallest gap (position 2 in the example). After deleting and re-adding, review whether a %MT<digits>% filter targets the newly filled position. With more than 9 targets, positions 10+ cannot be addressed by the digit filter — use the name-based approach (%MT% with the hostname as a folder or file name), which is the recommended default anyway; %MT<digits>% is the special case for a deliberate single selection.

What a non-zero exit code does

A script error never aborts the imaging run

  1. A failing script does not stop imaging. A non-zero exit code, a timeout or an exception is written to the seal/unseal log (including stdout/stderr) and shown in the live log — but the run continues and re-activation always happens, so the master target is never left sealed.
  2. A script error is always reported. A failing script marks the run with a note in the status and in the notification email — so you always see it, and never assume "all good" when a script silently failed. Imaging still completes and the vDisk is still imported.
  3. A missing or unsupported script aborts before imaging. If a path is configured but the file does not exist, or the extension is not .ps1 or .cmd, the readiness check stops the run before imaging starts — nothing is sealed yet. Both scripts are checked together.
Situation Result
Exit code 0 Script succeeded, nothing logged as an error
Non-zero exit / timeout / exception Logged to seal/unseal + live log, run continues, and the job is flagged in the status and email
Path missing or unsupported extension Caught by the readiness check — imaging aborts before it starts

End every successful script explicitly with exit 0 (PowerShell) or exit /b 0 (CMD) so the log shows a clean result.

The rules

Build your scripts for the service-account context

  1. Run unattended. No pause, no Read-Host, no GUI — anything that waits for input blocks until the timeout.
  2. Respect the timeout. If a script runs longer than the configured timeout (one shared value for both scripts, default 90 s), it is killed (and the failure is logged).
  3. Absolute paths only. The working directory is undefined.
  4. No network access at runtime. The imaging account has no interactive network session while the script runs. The script path may be a UNC share (PVS Forge fetches it for you), but inside the script avoid net use, mapped drives and reading other shares — pre-stage what you need locally.

Pitfalls in the service-account context

Pitfall Problem Fix
pause / Read-Host Blocks until the timeout Remove it
net stop on an already-stopped service Returns exit code 2 (logged as an error) Use sc stop (always exit 0)
net use / reading shares inside the script No network session at runtime Avoid; pre-stage files locally
$env:APPDATA / $env:LOCALAPPDATA Point at the service-account profile Use absolute paths
GUI calls (notepad, mmc, …) Block without a desktop Remove them
Start-Process without -Wait Script exits while the child keeps running until the timeout Always -Wait
Relative paths Working directory unknown Use absolute paths

Prefer sc over net stop / net start

sc stop <service> always returns exit code 0 — even if the service is already stopped or does not exist. net stop returns exit code 2 for an already-stopped service, which would be logged as a script error.

Disable services — don't just stop them

A stopped but still-enabled service starts again when the target boots, so stopping alone does not keep an updater off the running machine. In the pre-sealing script set the start type to Disabled; in the post-sealing script set it back to Automatic (or Manual). The same applies to update scheduled tasks — many vendors trigger updates from the Task Scheduler, so disable those tasks in pre and re-enable them in post.

Templates

Pre-sealing (seal.ps1)

# seal.ps1 - pre-sealing template for PVS Forge
# Runs headless under the imaging account, BEFORE the image is captured.
# Rules: no prompts, absolute paths, finish with exit 0 on success.

$ErrorActionPreference = 'Stop'

try {
    # --- DISABLE updaters/services so they do not start on the booted target ---
    # Disable, not just stop: a stopped-but-enabled service restarts on boot.
    foreach ($svc in 'MyUpdater', 'AnotherSvc') {
        Set-Service  -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
        Stop-Service -Name $svc -Force                -ErrorAction SilentlyContinue
    }

    # --- Disable vendor update scheduled tasks (many updaters run via Task Scheduler) ---
    Disable-ScheduledTask -TaskName 'MyVendorUpdate' -ErrorAction SilentlyContinue | Out-Null

    # --- Remove machine-specific leftovers ---
    Remove-ItemProperty -Path 'HKLM:\SOFTWARE\MyApp' -Name 'LastUser' -ErrorAction SilentlyContinue
    Remove-Item -Path 'C:\ProgramData\MyApp\cache\*' -Recurse -Force -ErrorAction SilentlyContinue

    # --- Run an external cleanup tool and WAIT for it ---
    # Without -Wait the script would exit before the tool finishes.
    Start-Process -FilePath 'C:\Tools\cleanup.exe' -ArgumentList '/silent' -Wait

    Write-Output 'Pre-sealing completed.'
    exit 0
}
catch {
    # The message goes to the seal/unseal log; the run continues regardless.
    Write-Error "Sealing script failed: $($_.Exception.Message)"
    exit 1
}

Post-sealing (unseal.ps1)

# unseal.ps1 - post-sealing template for PVS Forge
# Runs headless under the imaging account, AFTER imaging, BEFORE re-activation.
# Re-enable EXACTLY what seal.ps1 disabled, so the target boots in a working state.

$ErrorActionPreference = 'Stop'

try {
    foreach ($svc in 'MyUpdater', 'AnotherSvc') {
        Set-Service   -Name $svc -StartupType Automatic -ErrorAction SilentlyContinue
        Start-Service -Name $svc                        -ErrorAction SilentlyContinue
    }

    Enable-ScheduledTask -TaskName 'MyVendorUpdate' -ErrorAction SilentlyContinue | Out-Null

    Write-Output 'Post-sealing completed.'
    exit 0
}
catch {
    Write-Error "Post-sealing script failed: $($_.Exception.Message)"
    exit 1
}

CMD (seal.cmd)

@echo off
REM seal.cmd - pre-sealing template for PVS Forge
REM Runs headless under the imaging account. Use sc (always exit 0), never "net stop".

REM Disable (not just stop) so the updater stays off when the target boots.
sc config MyUpdater start= disabled
sc stop MyUpdater
sc config AnotherSvc start= disabled
sc stop AnotherSvc

REM Disable a vendor update scheduled task
schtasks /Change /TN "MyVendorUpdate" /DISABLE

del /q "C:\ProgramData\MyApp\cache\*"

REM Always finish with exit 0 on success
exit /b 0

Test before you rely on it

Run the script manually on the master target in a non-interactive, elevated context to approximate the real environment — for example with PsExec (-s runs as SYSTEM, which is close enough to catch headless/profile issues):

psexec -s -i powershell.exe -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\seal.ps1"
echo $LASTEXITCODE   # 0 = clean; non-zero is logged but does not stop imaging

If it completes without prompting and returns 0, it will run cleanly during sealing.