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:
- 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):
.ps1→powershell.exe -NonInteractive -ExecutionPolicy Bypass -File "<path>".cmd→cmd.exe /c "<path>"
- Each run is recorded in the seal/unseal log (
<vDisk>.seal-unseal.logon 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
- 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. - 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.
- A missing or unsupported script aborts before imaging. If a path is
configured but the file does not exist, or the extension is not
.ps1or.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
- Run unattended. No
pause, noRead-Host, no GUI — anything that waits for input blocks until the timeout. - 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).
- Absolute paths only. The working directory is undefined.
- 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.