Test environment provisioning from approved configurations

Regression results can differ between two QA stacks built from the same golden image a month apart, before anyone has touched the application. The cause usually sits above the operating system. Most test environment provisioning pins the image and lets everything over it float: the database engine's patch level, the application build, the configuration, the dataset and its schema version, the base users, and the endpoints the application calls.

Configuration v14Approved by QA leadFive layersGolden image first
Checked after each layer

What a golden image leaves out

Red Hat defines a golden image as "an intentionally configured snapshot of a system". The regression dataset, the build under test and the payment sandbox that build calls all sit outside that snapshot. EC2 Image Builder gets the versioning right: it calls its blueprint a recipe and makes each recipe version immutable, so any change produces a new version. Those rules stop at the machine image. The environment configuration in this article applies them from the operating system up through the dataset.

Petrichor's provisioning sequence builds a stack in five layers, and an image covers the first one. QA hits the gap first, because its environments turn over faster than development environments: every validation or regression run needs a fresh copy with a specific dataset, and each rebuild gives a floating layer another chance to move.

Keep the golden image as the first line of the configuration. Baking SQL Server into it saves an install on every build, and it adds one image to maintain for every patch level the team tests against. If the engine goes into the image, install it with SQL Server's image preparation step and run image completion on each new server. The instance name, the service accounts and the collation are chosen at completion, so those stay on the configuration even when the binaries live in the image.

Ten lines on one QA configuration card

Take a hypothetical software vendor, Ostrander Claims Systems, whose claims processing application runs on Windows Server and SQL Server. Its regression configuration, version 14, fits on one card. Both build numbers are Microsoft's September 2026 releases.

LineWhat version 14 pinsHeld by the golden image?
Base imageWindows Server 2022, OS build 20348.5631 (KB5129237)Yes
Database engineSQL Server 2022 Developer edition, CU27, build 16.0.4295.3, server collation SQL_Latin1_General_CP1_CI_ASThe binaries, if baked in
Application buildclaims-app 7.4.2+1187, fetched by SHA-256 checksumNo
ConfigurationConfig repository tag qa-regression-v14, with each feature flag listed by nameNo
DatasetSnapshot regression-base-2026-09-01 of generated claims, schema version 212, dates shifted to the build day on loadNo
Base usersRole accounts for an administrator, an adjuster and an auditor with read accessNo
IntegrationsPayment gateway in the vendor's sandbox mode, outbound email to a capture inbox, the carrier data feed stubbedNo
Secrets and license keysNamed references to entries in the vault, with no secret written on the cardNo
AccessThe claims QA team onlyNo
Approval and expiryApproved by the QA lead on September 22, 2026; retires when version 15 is approved or the schema moves past 212No

Collation is on the card because SQL Server fixes it at installation. The default comes from the operating system's locale, and temporary tables in tempdb take the server collation. Build a server under a different locale and the claims database restored onto it starts failing joins against temporary tables with collation conflicts. Changing the server collation afterward means rebuilding the master database.

The dataset line hides a second failure. A snapshot freezes the rows and leaves the calendar running, so a test that routes claims older than 90 days to review picks up different claims every week the snapshot ages. Shifting every date relative to the build day at load time keeps each run on the same claims. Pinning the application's clock in test mode works too, if the application supports it. The card says which one the configuration uses.

Most of one software vendor's customers who host their own applications on AWS or Azure ask the same thing: which image should we use? On Petrichor the customer decides which options a configuration offers, so its QA lead answers that question once. At one Petrichor customer, the QA lead has three configurations and knows what each one builds.

Developer edition includes every Enterprise feature, so the database line holds for Ostrander because its customers run Enterprise. A vendor whose customers run Standard needs a different line, for the reasons in What a passing test proves in dev, QA and staging. Name the edition even when it seems obvious: an Evaluation instance runs for 180 days, and once it expires it will not start.

The card doubles as the manifest a tester reads back at the start of each run, described in Resetting a QA environment between test runs. Give it a named owner on the platform side and another on the QA team. A card nobody owns drifts away from the stacks built from it.

Put the QA lead's name on every change

Petrichor engineers write the configuration, and Petrichor's provisioning tool builds each stack from it. The customer's QA lead approves each version, and that approval works best as approval of a diff against the version before. A useful approval record holds the version number, the diff, who approved it, the date, and the ID of the first baseline run the version produced.

Suppose Ostrander's version 15 proposes two changes at once: CU28, when Microsoft ships it, and application 7.5.0. The regression suite reports new failures, and nobody can say which change caused them. Split it. Version 15 changes only the cumulative update and runs against the version 14 baseline, and version 16 changes only the application. The split costs one extra baseline run per paired change, and every new failure arrives with one suspect.

Run the old version's baseline twice before judging a new one. A test that fails on only one of the two runs is flaky, and it goes on a separate list for the QA lead instead of into the comparison, where it would read as a regression. QA teams at one Petrichor customer destroy each test stack after its run and keep the baseline results as history, which gives every new version something to be compared against.

Configurations at Petrichor set where a stack starts, and teams can still configure an environment however they need after it is built. The rule that comes with that freedom: once someone changes a stack by hand, its results stay out of the baseline. One modified stack in a baseline makes every later comparison against it meaningless, and nothing in the results shows it. Record the stack ID with every baseline result, so a stack found to have drifted can have its results pulled.

The provisioning sequence, with a check after each layer

Petrichor builds Development and QA environments from a configuration in a fixed order: servers with the right operating systems, then the software, the environment configuration, the customer's application, and last the base users and dataset. The tester signs in after that. For one customer's QA team, a stack is an application server and a SQL server with external access, so a tester can open the application's web page and run it in test mode.

The automation is Salt, Ansible and Terraform, the same tools most teams use. Our engineers run the platform through an API with command line wrappers, and one of them stays on top of the builds, because automation fails silently.

A check after each layer makes a broken layer fail during the build instead of at the tester's first login. For Ostrander's card, the checks look like this:

LayerConfiguration lines it readsCheck before the next layer starts
Servers and operating systemBase imageCurrentBuild and UBR under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion read 20348 and 5631 on every server
SoftwareDatabase engineSELECT SERVERPROPERTY('ProductVersion'), SERVERPROPERTY('Edition'), SERVERPROPERTY('Collation') returns 16.0.4295.3, Developer Edition (64-bit) and SQL_Latin1_General_CP1_CI_AS
Environment configurationConfiguration, secrets, accessansible-playbook site.yml --check --diff, or salt '*' state.apply test=True, reports nothing left to change, and the team gate is in place
Customer's applicationApplication buildThe artifact's SHA-256 matches the card, and the version endpoint reports 7.4.2+1187
Base users and datasetDataset, base usersSchema version reads 212, row counts match the snapshot's manifest, the date shift has been applied, and each role account signs in
Before the tester signs inIntegrationsA test message lands in the capture inbox, and a payment call reaches the sandbox

The first row reads two registry entries because 20348 alone matches every patch level of Windows Server 2022. The monthly revision lives in UBR, and a check that reads only the build number passes a server that missed three months of updates.

Check mode in Ansible is a simulation. Modules that do not support it report nothing and do nothing, and tasks that branch on results registered by earlier tasks produce no output. A cumulative update installed through a shell task can be missing while the check comes back clean, which is why the software row asks the engine for its version directly.

The dataset row exists for a specific failure. A snapshot taken at schema 212 and loaded under a build that expects 214 either fails its migration at load, or the application migrates the data on first start and the dataset no longer matches the one the baseline was recorded against. The second outcome is worse, because every test still runs. Skipping checks like these moves the failure to the tester, who files it as an application bug.

Which patch level did last month's run use?

When the configuration pins its builds, the configuration version answers that. A configuration that says "latest" leaves only whatever the run happened to log.

Windows security updates ship on the second Tuesday of each month, typically at 10:00 AM Pacific, with extra releases when a fix cannot wait. In 2026, SQL Server 2022's security builds have shipped on six of those Tuesdays, while its five cumulative updates arrived on dates of their own. Microsoft's release lists show what "latest" meant for Windows Server 2022 and SQL Server 2022 from July 1 to September 26, 2026, assuming each build installs whatever is newest that day on the cumulative update branch:

Built on (2026)Windows Server 2022SQL Server 2022
July 1 to 1320348.525616.0.4255.1 (CU25)
July 14 to 1520348.538616.0.4262.2 (CU25 + GDR)
July 16 to August 1020348.538616.0.4265.3 (CU26)
August 11 to September 720348.549916.0.4265.3 (CU26)
September 8 to 1320348.562216.0.4275.2 (CU26 + GDR)
September 1420348.563116.0.4275.2 (CU26 + GDR)
September 15 to 2620348.563116.0.4295.3 (CU27)

That makes seven different pairs in one quarter, including one from a Windows build shipped outside the monthly schedule on September 14. The next monthly release lands on October 13. When a run's result matters, store the output of SELECT @@VERSION, which carries the cumulative update and KB number, next to the OS build and its UBR.

Other stacks pin the same way. subscription-manager release --set=9.6 locks Red Hat Enterprise Linux to one minor release, and the lock has to be set before the first update, since updating first leaves dependency errors. A container image pulled by digest (image@sha256:...) stays fixed, and it also stops picking up updated images, security fixes included.

Stacks that stay up between runs also need a drift check, because people change them. terraform plan -detailed-exitcode exits with 2 when it finds changes, 0 when it finds none and 1 on an error, and ansible-playbook --check --diff lists what a run would change. Each tool sees only what its code declares. Terraform compares the servers and networks it created, so a hotfix someone installed inside a server never appears in its plan. Rerun the version queries from the build checks before a long-lived stack's results count toward a baseline. A stack destroyed after every run needs only its pins.

Pinning leaves QA a patch level older than production until someone approves the next version, and the digest tradeoff applies to every layer on the card. The working answer is a monthly configuration version whose only change is that month's security updates, run against the baseline before the QA lead approves it. Windows and SQL Server security builds often land on the same Tuesday, and that pairing is the one worth allowing. Split it only when the version's baseline changes. Environments built for security testing should track the newest patches instead.

Retiring a version before its engine loses support

Every configuration version needs an end, and most triggers can be read off a vendor calendar:

  • SQL Server 2016 left extended support on July 14, 2026. Year 1 of Extended Security Updates runs to July 13, 2027 for customers who buy it.
  • Windows Server 2016 extended support ends January 12, 2027.
  • MySQL 8.0 moved to Oracle Sustaining Support on April 21, 2026, with 8.4 LTS and 9.7 LTS as the upgrade paths.

Two triggers come from inside the configuration: the dataset's schema stops loading under the current application build, or the edition carries a clock, as Evaluation does.

Retiring a version takes it off the team's configuration list, which is a change to that team's gating, and keeps the definition and its last baseline results. A vendor whose customer still runs the old engine may need that version rebuilt to reproduce a support case, and the kept baseline shows what a healthy run looked like. Staying rebuildable means keeping the old install media, the old dataset snapshots, and an engine that gets no security fixes unless someone pays for Extended Security Updates. An unpatched QA server still sits on a network, so build a retired version only while a case needs it and destroy it when the case closes.

When a runbook in the repository will do

Configurations repay the work of maintaining them when QA rebuilds often with a specific dataset. Plenty of teams are better served by something smaller, or by what their cloud already offers.

For QA rebuilt a few times a year, a runbook in the repository and the manifest a tester checks at the start of each run cover it. Containerized web front ends with small seed datasets fit branch preview environments better, where each pull request gets its own copy for as long as the branch is open.

An AWS team with a platform engineer and an application built on managed services already has the pieces. AWS Service Catalog lets administrators assemble portfolios of approved products with constraints, and a new product version propagates to every portfolio that references it. With Image Builder's versioned recipes underneath, that team has an approved environment program today. An application that depends on managed services it cannot replace should stay where it is.

Azure Deployment Environments retires on February 22, 2027, and create, deploy and redeploy operations are expected to be blocked after that date. Microsoft's retirement guide points teams to Azure Resource Manager or Bicep templates, Azure verified modules, and Azure DevOps or GitHub workflows, and warns that deleting an environment's metadata might not delete every resource it deployed, so check Azure Cost Management after cleanup. That migration is the moment to decide where QA runs.

Whichever route a team takes, fix a staging database that is an unmasked copy of production before a configuration or template copies it into every new stack.

We originally built our cloud for software written to run on a company's own servers, in unusual environments, on Windows and Linux. The fit is strongest for QA environments rebuilt for every validation run, and we prefer to start there while production stays where it is, often on AWS.

Write down the steps your team follows to build one QA environment, in order, and bring the list to the call. Talk to a consultant

Talk to a consultant