Touring NSIS


Going back way back to the early 90s, I remember the wonderfully written installers for Windows 3.1 that installed tools like Lotus 1-2-3, Freelance Graphics and others. This was the time when a guy in a suit sold you computers. The development tools hidden behind these installers were a closely guarded secret. However, sometime around the millennium, Winamp came around installer looked and behaved like something out of a much more expensive piece of software - a proper wizard, a license page, a progress bar, an uninstaller that actually showed up in Add/Remove Programs (in Windows 95 times). It didn’t occur to me for years that the same company that made the MP3 player, Nullsoft, also gave away the tool that built that installer, for free, to anyone. That tool is NSIS (the Nullsoft Scriptable Install System), and a shocking amount of the Windows software you’ve installed in the last 25 years - Firefox for a long stretch, most game mod tools, half of SourceForge was packaged with it.

NSIS never went away. It’s still actively maintained, still free, and still the pragmatic choice for anyone who wants a real installer without paying for InstallShield or wrestling with WiX’s XML dialect. This post is a tour through what it can actually do, backed by a small demo project that GitHub Actions builds, installs in GUI mode (not silently), screenshots mid-install, and verifies - all on a disposable Windows runner.

Repo: github.com/treideme/nsis-demo

What NSIS Actually Is

At its core, NSIS is a script (.nsi) and a compiler (makensis.exe) that turns that script into a single self-contained .exe. There’s no runtime dependency, no XML, no MSI database format to reverse-engineer when something goes wrong - just an imperative scripting language with variables, functions, conditionals, and a plugin system, compiled directly into the installer’s own resources. Contrast that with WiX, where you’re authoring an XML description of an MSI table structure that Windows Installer then interprets at runtime. NSIS is closer to writing a small program than describing a database.

Anatomy of the Demo Script

The demo installer (install.nsi) installs a stand-in “app” (notepad.exe, copied from the CI runner itself at build time - I’m not shipping Microsoft’s binary in the repo) plus the Visual C++ Redistributable, and walks through most of the feature set worth knowing about.

Version resource and branding

VIAddVersionKey /LANG=0 "ProductName" "${APPNAME}"
VIAddVersionKey /LANG=0 "CompanyName" "${COMPANYNAME}"
VIAddVersionKey /LANG=0 "LegalCopyright" "(C) ${COMPANYNAME}"
VIAddVersionKey /LANG=0 "FileVersion" "${VERSION}.0"
VIAddVersionKey /LANG=0 "ProductVersion" "${VERSION}.0"
VIProductVersion "${VERSION}.0"

This is what populates the Details tab in Windows Explorer’s file properties dialog for the compiled .exe: easy to forget, and the first thing that makes an installer look homemade when it’s missing.

For the UI itself, Modern UI 2 (MUI2.nsh) provides the whole page-flow macro set. I skipped the flat default background in favour of something more fun:

!define MUI_HEADERIMAGE
BGGradient 0000FF 000000 FFFFFF

BGGradient paints a full-screen gradient behind the wizard (blue to black, white text), the way installers looked before Windows XP normalized the flat white background. It’s one line, and it’s the single most visually distinctive thing in the whole script.

A custom page with nsDialogs

MUI2 gives you Welcome, License, Components, Directory, InstFiles, and Finish pages out of the box. For anything else, nsDialogs lets you build an arbitrary page from Win32 controls. I used it to show release notes, read from a plain-text file at build time, before the user gets to the components page:

Function ReleaseNotesPageCreate
  nsDialogs::Create 1018
  Pop $0

  ${NSD_CreateLabel} 0 0 100% 12u "What's new in ${APPNAME} ${VERSION}:"
  Pop $1

  nsDialogs::CreateControl EDIT "${DEFAULT_STYLES}|${ES_MULTILINE}|${ES_AUTOVSCROLL}|${ES_READONLY}|${WS_VSCROLL}" ${WS_EX_CLIENTEDGE} 0 15u 100% 190u ""
  Pop $ReleaseNotesText

  StrCpy $4 ""
  ClearErrors
  FileOpen $2 "${RELNOTES}" r
  IfErrors notes_missing
  notes_loop:
    FileRead $2 $3
    IfErrors notes_done
    StrCpy $4 "$4$3"
    Goto notes_loop
  notes_done:
    FileClose $2
    ${NSD_SetText} $ReleaseNotesText "$4"
    Goto notes_end
  notes_missing:
    ${NSD_SetText} $ReleaseNotesText "(No release notes found for this build.)"
  notes_end:

  nsDialogs::Show
FunctionEnd

Page custom ReleaseNotesPageCreate

${RELNOTES} is a compiler define passed on the command line (/DRELNOTES=stage\ReleaseNotes.txt), pointed at whichever releasenotes/*.md file matches the version being built. There’s no Markdown rendering happening; it’s read as plain text into a read-only edit control. But that’s the whole trick: the release note a user reads during install is the same file that lives in version control, picked automatically by version number.

Skipping work that’s already done

The Visual C++ Redistributable section checks the registry before doing anything:

Section "Visual C++ Redistributable (x64)" SecVCRedist
  ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
  ${If} $0 == 1
    DetailPrint "VC++ Redistributable already installed, skipping."
  ${Else}
    DetailPrint "Installing VC++ Redistributable (this can take a minute)..."
    InitPluginsDir
    SetOutPath "$PLUGINSDIR"
    File "/oname=vc_redist.x64.exe" "${SRC}\vc_redist.x64.exe"
    ExecWait '"$PLUGINSDIR\vc_redist.x64.exe" /install /quiet /norestart' $0
    DetailPrint "VC++ Redistributable installer exit code: $0"
  ${EndIf}
SectionEnd

This is shown to the user as an optional, checked-by-default entry on the Components page (!insertmacro MUI_PAGE_COMPONENTS), so it’s both a demonstration of ${If}/${Else} logic from LogicLib.nsh and of ExecWait chain-installing another installer and capturing its exit code. On the actual CI runner, the redistributable is already present, so this branch skips, which is itself worth seeing actually happen.

Previous-install detection is intentionally the boring version. A well-behaved installer always installs to the same path, so there’s no need for a filesystem-wide search plugin. Just check whether the entry point is already there and clean it up first:

IfFileExists "$INSTDIR\${ENTRYPOINT}" 0 NoOldInstall
  DetailPrint "Previous installation found at $INSTDIR, removing..."
  ClearErrors
  RMDir /r "$INSTDIR"
  IfErrors 0 NoOldInstall
    DetailPrint "Could not fully remove the previous installation (in use?). Continuing anyway."
NoOldInstall:

And the uninstaller is a normal Section "Uninstall": it removes the files, the shortcuts, and the Software\Microsoft\Windows\CurrentVersion\Uninstall registry key that makes the app show up (and be removable) in Add/Remove Programs in the first place. It’s easy to write an NSIS installer that never gets this far; Add/Remove Programs integration is a few WriteRegStr calls, not something NSIS gives you automatically.

Running It for Real in GitHub Actions

The interesting part is proving the script actually works, in GUI mode, on a machine that isn’t mine. ci.yml runs on windows-latest and does the whole thing for real:

  1. Installs NSIS itself via choco install nsis (the runner image doesn’t ship it).
  2. Stages the payload: copies notepad.exe from the runner’s own System32, downloads the real VC++ Redistributable from aka.ms, and copies the matching releasenotes/<version>.md.
  3. Compiles the installer with makensis.
  4. Launches it without /S (a real GUI install) and drives the wizard.

Driving the wizard without a human is the part with no NSIS-specific answer; it’s just PowerShell and Win32:

$proc = Start-Process -FilePath .\NsisDemoSetup.exe -PassThru

function Advance {
  for ($i = 0; $i -lt 20; $i++) {
    if ($proc.HasExited) { return }
    $proc.Refresh()
    if ($proc.MainWindowHandle -ne 0) {
      [Native.Win32]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
      Start-Sleep -Milliseconds 300
      [System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
      return
    }
    Start-Sleep -Milliseconds 300
  }
}

Each NSIS wizard page keeps the same top-level window and just swaps its contents, and pressing Enter always triggers whichever button is the current default (Next, Install, or Finish), including on the custom nsDialogs release-notes page, since its edit control isn’t marked ES_WANTRETURN. Four calls to Advance walk through Welcome, the release notes page, Components, and Directory (which relabels its own button to “Install”).

The first version of this had a bug worth admitting to: the real install finishes in well under a second (the payload is tiny and the runner already has the VC++ runtime), so by the time the screenshot step ran, the wizard had already reached the Finish page. I added two deliberate Sleep calls around the file copy in install.nsi (CI-demo padding, not something a real installer needs) purely so there’s an actual window in which to capture the InstFiles page. With that fixed, the runner grabs a real mid-install screenshot with System.Drawing.Graphics.CopyFromScreen:

NSIS Demo installer mid-install

That’s an actual screenshot pulled from a completed run, not a mockup. The run itself is here. After the wizard closes, the workflow verifies the payload and the uninstaller exist and that the VC++ runtime registry key is set, zips the installed directory tree, and uploads three run artifacts: the installer .exe, the screenshot, and the installed files.

Cutting a Release

release.yml runs the same build on a vX.Y.Z tag push, refuses to proceed if there’s no matching releasenotes/X.Y.Z.md (so a release without release notes simply can’t happen), and attaches the installer and the notes to a GitHub Release:

gh release create "${{ github.ref_name }}" `
  "NsisDemoSetup-${{ steps.version.outputs.version }}.exe" `
  "releasenotes/${{ steps.version.outputs.version }}.md" `
  --title "NSIS Demo ${{ steps.version.outputs.version }}" `
  --notes-file "releasenotes/${{ steps.version.outputs.version }}.md"

One thing that cost me a debugging round trip: the default GITHUB_TOKEN GitHub Actions hands a workflow is read-only unless the job explicitly opts in, so gh release create failed with an HTTP 403 until I added permissions: contents: write to the job. Worth remembering any time a workflow needs to write back to the repo it’s running in.

The result is a real, tagged release: NSIS Demo v1.1.0, installer and release notes attached, built entirely by the tag push.

Unattended Installs Across a Fleet

Everything above works because the CI runner behaves like a single person sitting at a keyboard: an actual interactive session exists, and it’s the only thing happening on the machine. Push this exact installer out to a few thousand endpoints through Intune, SCCM, or a login-script GPO instead, and several things stop working. The script isn’t wrong; “unattended fleet install” and “GUI wizard automated by pressing Enter” are just two different problems that happen to look similar in a demo.

Session 0 breaks the GUI-automation trick

Enterprise deployment tools install as the SYSTEM account with no interactive desktop attached (session 0 isolation, since Windows Vista). A GUI installer launched that way doesn’t hang waiting for a click that never comes; there’s no desktop for it to paint on in the first place. The SetForegroundWindow/SendKeys approach in ci.yml only works because GitHub Actions’ Windows runner logs a real user into an interactive session for the job. That’s a fine trick for CI and a useless one for a fleet. The actual answer there is NSIS’s native /S silent switch, which skips every page - Welcome, the custom release-notes page, Components, Directory - and just runs the Section code with whatever selection state the sections had by default.

The release notes page has no unattended equivalent

The whole point of the nsDialogs page was to put the release notes in front of a human during install. In /S mode nobody ever sees it. Gating on human acknowledgment and running silently on 3,000 machines overnight are simply incompatible goals, and that’s not something to patch around - the practical answer for a fleet build is to drop the requirement on the silent path and, if the content still matters, log it or ship it as a separate document instead.

Exit codes and reboot state don’t propagate on their own

ExecWait on the VC++ Redistributable already captures its return code into $0, but the script currently only DetailPrints it and moves on. If that nested install failed, this installer still exits 0. SCCM, Intune’s Win32 app “return code” mapping, and any orchestrator that branches on %ERRORLEVEL% need a script that actually calls NSIS’s SetErrorLevel (or Abort) when something downstream fails, and that recognizes the MSI convention of exit code 3010 for “succeeded but needs a reboot” if it’s chain-installing anything that can trigger one. Skip that, and the fleet quietly records a failed VC++ Redistributable install as a success.

There’s no equivalent of an MSI ProductCode

Windows Installer keeps a database of installed products, queryable by GUID, which is what lets Intune, SCCM, and Group Policy Software Installation detect “is this installed, and at what version” without any custom logic. NSIS has nothing like it: the only detection surface is whatever gets written into the Uninstall registry key by hand (DisplayName, DisplayVersion, and so on, same as this demo does). That’s exactly what Intune’s custom registry detection rules and SCCM’s registry-based detection methods are built to consume, but keeping that key accurate is on the script author, and there’s no native upgrade/downgrade logic or transactional rollback if an install fails halfway. MSI gets both from the platform; an NSIS script has to hand-roll them.

Logging is the other gap SCCM admins hit immediately

DetailPrint only writes to the in-memory list shown on the InstFiles page; it’s gone the moment the installer closes, unlike msiexec /l*v log.txt, which Windows Installer supports natively. NSIS can log to a file, but only from a build of makensis itself compiled with logging enabled (NSIS_CONFIG_LOG), which the ordinary NSIS download doesn’t ship with. Pulling centralized install logs back from a few thousand endpoints is routine with MSI; doing the same here means either building a logging-enabled NSIS toolchain or writing manual FileOpen/FileWrite calls throughout the script.

What actually adapts this for a fleet

In rough order of how much it matters:

  1. Always invoke with /S, and if overriding the install directory, /D=C:\Path has to be the last argument on the command line - NSIS parses it positionally, not as a normal named switch.
  2. Parse fleet-controlled switches with GetOptions/GetParameters from FileFunc.nsh (a /SKIPVCREDIST flag that calls ${UnselectSection} on SecVCRedist, for example) - there’s no MSI-style ADDLOCAL=/REMOVE= public-property mechanism to lean on.
  3. Check every ExecWait result and call SetErrorLevel deliberately, so the process exit code means something to whatever is watching it.
  4. Keep the Uninstall registry key accurate and stable across versions, since it’s the only thing an external detection rule has to go on.
  5. Get a logging-enabled makensis, or add manual file logging, before this ships anywhere without direct screen access.

The one piece of modern tooling that already understands NSIS specifically is WinGet: its manifest schema has a first-class Nullsoft installer type and passes /S for it automatically. Chocolatey packages take the more manual route that’s realistic for most fleets today - a chocolateyInstall.ps1 that calls the NSIS .exe with /S and checks $LASTEXITCODE itself, which is item 3 above done by hand, once, in the packaging layer instead of the installer script. Either way, NSIS’s job in a fleet context is to be a good citizen when something else drives it silently, not to reinvent what Windows Installer already provides.

That maps to a simple rule for picking a tool: NSIS for something a person downloads and clicks through once, MSI/WiX for something a fleet needs to detect, version, and roll back without a person anywhere near it.

Summary

NSIS’s reputation as “that installer from the 2000s” undersells it. Modern UI 2 gets you a competent wizard for free, nsDialogs gets you out to arbitrary custom pages when that’s not enough, and the scripting language - however dated it looks next to a modern build tool - is expressive enough to conditionally skip work, chain-install another installer, and clean up after itself on uninstall. None of that requires anything beyond the stock NSIS distribution; the one plugin I’d reached for initially (Locate, for scanning Program Files for a prior install) turned out to be unnecessary once I remembered that a well-behaved installer always installs to the same place anyway.

That confidence has a boundary, though: it’s specifically confidence in NSIS for a single machine with a human in front of it. The moment the target is a fleet instead of a person, the calculus flips - not because NSIS got worse, but because MSI was built for exactly that case and NSIS was never trying to be.

Further reading:


Published: 2026-02-07
Updated  : 2026-02-07
Not a spam bot? Want to leave comments or provide editorial guidance? Please click any of the social links below and make an effort to connect. I promise I read all messages and will respond at my choosing.
← Shito-Ryu Shodan, Kamakura, and Katas Still to Clean Up Reviving a Parallel-Port Iomega ZIP Drive on Ubuntu 22.04 →