DevOps · 10 min read · 2026-05-09

Single-script installers (WiX + Python) — replacing the multi-day install.

TL;DR — Enterprise Windows software still ships through installers. Most installers are written once, never maintained, and require a half-day of manual config-file copying every time a client upgrades. The fix is not a new installer technology — it's wrapping WiX in a Python build script that handles config restoration, old-version uninstall, and a first-run admin UI. The Jobscope rollout cut a multi-step install to a single click. Here's how.

What the install used to look like

A Jobscope upgrade, in 2024, was a five-step procedure that needed an on-site engineer. Step one: stop the IIS site. Step two: run the MSI from the previous release. Step three: copy the config files from a backup folder into the freshly-installed app directory, manually editing the ones that had new keys. Step four: run a SQL script the DBA had to schedule. Step five: restart IIS, hope.

This took three to six hours per site, depending on how many integration partners had to be re-authenticated, and required someone who knew the codebase to be in the room. With dozens of clients and quarterly releases, a substantial fraction of the engineering team was spending its time on installs instead of features.

The fix wasn't to switch installer technology. WiX is fine. The fix was to put a Python script around WiX that handled the parts WiX is bad at — config file diffing, version-aware migration, and the first-run UI.

What we ended up with

The new install was one MSI. The client double-clicked it. Two minutes later the application was upgraded, the old install was retired, the config files from the previous version were merged with the new defaults, the database schema was up-to-date, and a browser tab opened to a one-page admin UI for the values the install couldn't infer (license key, integration credentials).

If the client clicked the MSI on a fresh machine — no prior install — the same UI ran in "first install" mode and asked for the values needed to set the app up from scratch.

The user-visible flow was:

Double-click installer.msi
      ↓
Standard Windows installer dialog (15 seconds)
      ↓
Service starts, browser opens to http://localhost:8080/setup
      ↓
Admin fills in 3 fields (or accepts defaults from prior install)
      ↓
Done.

The architecture, layer by layer

Layer 1 — the Python build script

This isn't part of the runtime install; it's part of the engineering team's release pipeline. Every release, the build script does the following:

  1. Pulls the current code, builds the binaries
  2. Reads a config-schema.yaml file that declares every config key the app expects, with type, default value, and "is this a secret"
  3. Generates the WiX XML — the file list, the registry keys, the install directories — from the build output and the schema
  4. Runs WiX's candle and light to produce the MSI
  5. Signs it with the team's code-signing certificate (this matters; an unsigned installer triggers SmartScreen warnings on every client and trains them to ignore them)

The build script is roughly 400 lines of Python. The config schema is declarative — adding a new config key for a release is a one-line YAML change, and the installer picks it up the next build.

Layer 2 — the install-time custom action

WiX has a "custom action" mechanism — code that runs during install, written in any language that can produce a Win32 entry point. We wrote ours in C# (because Wix has good support for managed custom actions) but it's a thin wrapper that does three things:

  1. Detects a previous install via a registry key under HKLM\Software\Jobscope
  2. Backs up the existing config directory to %TEMP%\jobscope-config-backup-{timestamp} before the MSI removes anything
  3. Stops the running service cleanly so the file-replace step doesn't fight an active process

Then WiX does its normal MSI install — files replaced, registry updated, service installed. After that, a second custom action runs:

  1. Reads the old config from the backup and the new config schema
  2. Merges them: keys present in both are taken from the old config (preserving the client's settings); new keys get default values from the schema
  3. Writes the merged config to the runtime directory
  4. Starts the service
  5. Opens the browser to the setup UI if any "required, no default" key is missing

Layer 3 — the first-run admin UI

This is the part most installers skip. The setup UI is a small page served by the application itself, not by the installer. It's reachable only from localhost and only until it's been completed once. After completion it's gone — no surprise admin endpoints in production.

The UI shows:

What the UI deliberately doesn't do is "configuration as a feature". It's an install completion page, not an admin console. Every field on it should ideally not exist, because every field is something the installer couldn't decide for itself. Over the year, the field count went from 11 to 4 — the rest were either inferred from context or moved to defaults.

The config-schema discipline

The unsung hero is the config-schema.yaml file. Without it, you're back to manually maintaining the install's list of config keys. With it, the schema is the single source of truth for "what does this application need to run."

# jobscope/config-schema.yaml
keys:
  - name: database.connection_string
    type: string
    secret: true
    required: true
    default: null
    description: |
      MS SQL Server connection string. Setup UI builds this
      from server / database / user / password fields.

  - name: integrations.acme_courier.token_endpoint
    type: url
    secret: false
    required: true
    default: https://api.acme-courier.com/oauth/token
    description: ROPC token endpoint for the courier partner.

  - name: ui.session_timeout_minutes
    type: integer
    secret: false
    required: false
    default: 60
    description: Idle timeout for human sessions, in minutes.

From this file, the build script knows which keys to embed in the installer (defaults), which to ask for in the setup UI (required + no default), and which to treat as secrets (encrypted at rest in the registry). The schema is reviewed in PRs the same way code is — adding a config key is a deliberate act, not a side effect.

What this didn't fix on its own

The numbers, after one quarter on the new installer

The freed time is the underrated outcome. Every hour an engineer isn't doing an install is an hour of feature work or paying down debt. Across a quarter, that's a small product roadmap on its own.

If you're sitting on the old version of this

The build order I'd recommend, from a multi-day-install starting point:

  1. Week 1: introduce the config schema. Don't change the installer. Just write down every config key the app expects, in one place. The exercise alone exposes things.
  2. Week 2: build the Python build script that generates the WiX XML from the schema. Output is the same MSI you've always shipped, but reproducibly.
  3. Weeks 3–4: add the install-time backup-and-merge custom action. Validate on test environments first.
  4. Weeks 5–6: add the first-run setup UI. Start with required fields only; iterate on what to auto-detect.
  5. Week 7: ship to one friendly client; learn; ship to the rest over the next two quarters.

The whole project is on the order of a calendar quarter for a single engineer. The payback is longer than that — every install from then onward is shorter and lower-risk. By release four on the new installer, you've stopped flying engineers to clients.


Got a Windows install that still needs an engineer in the room?