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:
- Pulls the current code, builds the binaries
- Reads a
config-schema.yamlfile that declares every config key the app expects, with type, default value, and "is this a secret" - Generates the WiX XML — the file list, the registry keys, the install directories — from the build output and the schema
- Runs WiX's
candleandlightto produce the MSI - 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:
- Detects a previous install via a registry key under
HKLM\Software\Jobscope - Backs up the existing config directory to
%TEMP%\jobscope-config-backup-{timestamp}before the MSI removes anything - 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:
- Reads the old config from the backup and the new config schema
- 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
- Writes the merged config to the runtime directory
- Starts the service
- 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:
- The list of config keys that need values (only the ones not auto-restored from backup)
- A "test" button next to integration credentials that actually round-trips against the partner's API and confirms the credential works before saving
- A "next" path that runs the database migration if it hasn't been applied, with a progress indicator that's honest about what step is running
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
- Database schema migrations still need a strategy. We pair this installer with an automated DB-change-tracking tool (a separate piece of work) so the migration script applied at first-run is generated from schema diffs, not hand-written.
- Multi-server installs need a different shape. If your enterprise client is running the app across three application servers behind a load balancer, the installer above runs three times. We added a "primary / replica" flag to the schema to handle this, with the secondary nodes skipping migrations.
- Air-gapped environments need the code-signing chain available offline. A few clients in regulated industries needed us to ship a portable signed certificate bundle alongside the MSI. Annoying but well-trodden.
The numbers, after one quarter on the new installer
- Engineer time per client install: 3–6 hours → ~10 minutes (mostly waiting for the MSI)
- Failed installs requiring re-do: ~15% of releases → ~1%
- Clients who could self-upgrade: ~0% → ~85%
- Engineering time freed for product work: roughly half an FTE per quarter
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:
- 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.
- 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.
- Weeks 3–4: add the install-time backup-and-merge custom action. Validate on test environments first.
- Weeks 5–6: add the first-run setup UI. Start with required fields only; iterate on what to auto-detect.
- 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?