Post

Dunderly HR System: Infrastructure and Environments

Dunderly HR environments: dev, test and prod in Bicep, a production staging slot instead of a fourth environment, private endpoints, and quotas that barely matter.

Dunderly HR System: Infrastructure and Environments

Four environments is the textbook answer. For an HR system with ten concurrent users and a team of four, it’s one environment too many to pay for, patch and keep in step. Dunderly gets three (dev, test, prod), and production’s deployment slot plays the part of staging: the new build runs on production infrastructure, against production configuration, before a single user sees it.

Development environment

Two kinds of “dev”:

  • Local: the .NET Aspire AppHost with SQL Server in a container and Azurite (see Local development setup). This is where most development happens.
  • Shared dev in Azure (rg-dunderly-hr-dev): deployed from every merge to main. It proves the Bicep, the managed identities and the private networking work, which a laptop can’t.

Dev uses the smallest SKUs: App Service B1 (no slots needed), SQL serverless with auto-pause after an hour, Functions Flex Consumption, LRS storage. It holds only synthetic data. A budget alert at a low threshold catches anything left running large.

Test or QA environment

rg-dunderly-hr-test is where E2E tests, the ZAP scan, performance tests and UAT run. It’s built from the same Bicep as production with a parameter file that changes only sizes and names:

SettingDevTestProd
App Service planB1P0v3P0v3, 1 to 2 instances
Deployment slotsNoYes (to rehearse swaps)Yes
SQL serverless max vCores / auto-pause1 / 60 min2 / 60 min2 / off
Storage redundancyLRSLRSGRS
Defender for Storage malware scanningOff (fake scanner)OnOn
DataSyntheticSyntheticReal

Test has its own Entra app registrations and test users, and its own payroll outbound account, which the payment system’s test instance (if there is one) pulls from.

TODO: Does the legacy payment system have a test instance? If not, file acceptance has to be checked by its owner manually from sample files, and the parallel run carries more weight.

Staging environment

No separate staging environment. The staging slot on the production App Service is staging:

  • Same plan, VNet, private endpoints, managed identity permissions and Key Vault as production.
  • A slot-sticky app setting (Hr__IsStagingSlot=true) turns off anything in the API that must only run once, such as scheduled cache warm-ups, while the build is waiting in the slot.
  • Smoke tests run against it, then it’s swapped in (see Deployment automation).

The Functions app has no slots on Flex Consumption; it deploys after the API swap, and its jobs are idempotent, so a brief overlap is harmless. The trade-off: there’s no long-lived staging copy of the data for exploratory testing in prod-like conditions. Test covers that with synthetic data at production scale.

Production environment

rg-dunderly-hr-prod in West Europe (assumption A1), with these properties:

  • Resource lock (CanNotDelete) on the resource group.
  • Diagnostic settings on every resource send logs to the prod Log Analytics workspace.
  • Only the deployment identity (via GitHub OIDC) and PIM-elevated admins can change anything.
  • Defender for Cloud recommendations reviewed monthly.
  • Public network access disabled on SQL and Key Vault; documents storage through private endpoint only.

A separate resource group, rg-dunderly-hr-shared, holds what outlives an environment: the Log Analytics workspace and the action groups for alerts.

Infrastructure as code

Bicep with Azure Verified Modules (ADR-009), in infra/:

1
2
3
4
5
6
7
infra/
├── main.bicep              # subscription scope: resource group + modules
├── modules/                # thin wrappers: network, data, app, functions, monitoring
└── env/
    ├── dev.bicepparam
    ├── test.bicepparam
    └── prod.bicepparam

Every change goes through a PR with az deployment sub what-if output posted as a comment, and is applied by the pipeline, never from a laptop. Portal changes in prod are treated as drift: a weekly scheduled what-if reports differences.

1
az deployment sub what-if -l westeurope -f infra/main.bicep -p infra/env/prod.bicepparam

Why not Terraform? Equally valid, and this blog has plenty on it (Terraform - Azure). For an Azure-only estate run by a .NET team, Bicep avoids a state file and its backend, and gets new Azure features on day one.

Networking and DNS

One VNet per environment, small address spaces (no peering needed):

SubnetProd CIDRUsed by
snet-app10.40.1.0/26App Service and Functions VNet integration (delegated)
snet-pe10.40.2.0/27Private endpoints: SQL, documents Blob, payroll Blob, Key Vault, queues

Private DNS zones (privatelink.database.windows.net, privatelink.blob.core.windows.net, and so on) are linked to the VNet so the apps resolve private IPs. Public DNS: hr.<company domain> CNAME to Static Web Apps, and hr-api.<company domain> to App Service, both with managed certificates.

TODO: Dunderly’s domain name and who manages its DNS; and, if a VPN to the server farm exists, its address ranges so the VNet doesn’t overlap them.

Compute, storage, and databases

Summarized from Hosting, with the reasoning per choice:

ResourceProd choiceWhy this and not the alternative
API computeApp Service Linux P0v3Slots, VNet integration, cheaper than three S1s; containers or AKS would be overkill
Background computeFunctions Flex ConsumptionMonthly and event-driven jobs; scales to zero
WebStatic Web Apps StandardStatic files, PR previews, custom domain
DatabaseAzure SQL serverless GP, max 2 vCoresRelational data, temporal tables, pay per use
DocumentsStorageV2 GRS, hot tier, lifecycle to cool after 180 days~25 GB, rarely read after onboarding
Payroll filesStorageV2 GRS, immutable containersIntegrity and retention

Configuration and secret management

Configuration comes from Bicep outputs into app settings: endpoints, client IDs, feature flags. Secrets, in the rare case there are any, come from Key Vault references (@Microsoft.KeyVault(SecretUri=...)), read by the app’s managed identity. GitHub holds no Azure secrets: the workflow uses OIDC with a federated credential per environment, and the only GitHub “secrets” are IDs (tenant, subscription, client), which aren’t sensitive. See Secrets and key management.

Environment parity

Test and prod are built from the same Bicep and differ only in their parameter files, which are short enough to diff by eye. The deliberate differences are sizes, redundancy, auto-pause and data. Everything else (networking, identities, role assignments, diagnostic settings, policies) is identical.

Dev drifts slightly further (B1, no slots, fake scanner) to save money; that’s acceptable because test is the gate before prod, not dev. Local differs most (containers instead of PaaS), and the integration tests use the same container images as local, so “works locally” and “works in CI” mean the same thing.

Capacity and quota planning

Capacity is almost a non-question at this scale, but it’s worth writing down why:

ResourceNeed (5-year)LimitHeadroom
SQL storage~1 GB structuredServerless GP up to TBsHuge
Blob storage~25.5 GBPetabytesHuge
API instances1 to 2P0v3 scales out to manyHuge
Concurrent users10 (50 tested)Bounded by SQL vCoresLarge

Quotas to check once per subscription: App Service Premium v3 cores in West Europe, Flex Consumption availability in the region, and private endpoint counts (well below limits). The real capacity planning is cost, covered in Capacity and cost monitoring.

Checklist

  • Create the three resource groups plus shared, with locks on prod.
  • Write main.bicep and the three parameter files; run what-if in CI.
  • Set up GitHub OIDC federated credentials per environment.
  • Create private DNS zones and confirm resolution from App Service.
  • Confirm the company domain, DNS owner and any VPN address ranges.
  • Ask whether the payment system has a test instance.
  • Check Premium v3 and Flex Consumption quota in West Europe.
  • Schedule the weekly drift what-if.

This post is licensed under CC BY 4.0 by the author.