Workload-env Stand-up Runbook
End-to-end procedure to bring up a new eSHARS environment (dev, qa,
regression, uat, training, prod) on the tier-based ALZ layout. This is
the environment-level chapter of
Deployment Order — Standing Up the Estate;
the tier-level roots (cpc → dns-zones → dns → wn → wc → ops → wc-secrets → rs/js)
must already be applied. The same procedure is automated end-to-end as the
Infra: 6 - Environment Workload GitHub Actions workflow — see
Stand-up Workflows; this page
is the manual path and the reference for what the workflow does.
(staging exists in workload-env’s tier map for historical reasons but has
no tfvars and is not a deployable environment — don’t confuse it with prod’s
staging deployment slot.) For why configuration and secrets flow the way they do, see
Configuration & Secrets Architecture.
When to use this runbook
- Standing up a brand-new environment for the first time
- Rebuilding an environment after a destroy
- Re-verifying an environment that “deployed fine” but misbehaves at runtime (start at step 4c)
Prerequisites
- Azure CLI authenticated to the eSHARS subscription
(
9639039c-86c6-4859-b014-d28688863cd5):Terminal window az loginaz account set --subscription 9639039c-86c6-4859-b014-d28688863cd5 - OpenTofu installed (
tofu, notterraform) - Working directory at the root of
eshars-ops - Tier-level roots already applied (once per tier):
cpc,dns-zones,dns,wn,wc,ops,rs,js— see the deployment order. Applying an env beforersdoesn’t fail, but the SSRS settings null-coerce out and you must re-apply env later — follow the order. - Scaffold KV seeded (
kv-eshars-scaf-{tier}) with the 8 shared-external vendor secrets — step 4 refuses to run otherwise. - Optional env var (only for a manual/laptop apply; the workflow sets its own):
Do NOT export a SQL admin login or password — neither is an operator input:
Terminal window export TF_VAR_teams_webhook_url='<workflow-url-or-blank>'- Login:
sql_server_admin_logindefaults to the canonical baseeshars-sql-admin(variables.tf), andlocal.sql_admin_loginappends the env →eshars-sql-admin-<env>. ExportingTF_VAR_sql_server_admin_login(especially an already-suffixed value likeeshars-sql-admin-dev) is unnecessary and, if suffixed, doubles toeshars-sql-admin-dev-dev. Leave it unset. - Password: Terraform generates it (
random_password.sql_admin) and writes it to the env KV (SqlAdminPassword). There is noTF_VAR_sql_server_admin_password; exporting one does nothing. Retrieve the generated value from the KV if you need it.
- Login:
- Run from inside the spoke (the ops runner) or on the VPN with working
split-DNS. The env KV is private-endpoint-only: a machine that resolves the
vault to its public endpoint fails the secret writes with
403 ForbiddenByConnection. On a brand-new env the vault name may still be cached to its public IP when the first secret writes fire, so the apply can403even from inside the spoke with the PE-DNS/RBAC polls green — a re-apply clears it once the record has propagated. See Open items / known gaps. - GitHub repository environment created for this env in the
HISDrepo. The env vars/secrets (SQL_ADMIN_PASSWORD,DB_SERVER, …) are Terraform-managed, but the environment itself is not — a GitHub App token can’t create it, so it’s a one-time user-PAT bootstrap. Skipping this makes the apply fail withPOST .../environments/<env>/variables: 404 Not Found:Terminal window # gh authenticated as a USER with admin on HISD (App tokens can't do this)./scripts/setup-github-environments.sh <env> # idempotent (create-or-noop)
Stand-up sequence
1. Initialize the workload-env root for this environment
just init-env devTargets workload-env-<env>.tfstate in the tier state account
(sattf{tier}{region} in rg-eshars-scaffold-{tier}-{region}). Each environment has
its own state file; nonprod CI/CD cannot reach prod state.
2. Plan and review
just plan-env devExpected for a brand-new env: ~40-50 resources to add, 0 to change, 0 to destroy. Walk the plan output and confirm:
- All resources land in the correct tier RGs — convention is
rg-eshars-{component}-{tier}-{region}(component before tier), e.g.rg-eshars-data-nonprod-cus,rg-eshars-web-nonprod-cus - SQL Server is per-env (
sql-eshars-<env>-<region>) - Web app and function app are per-env root apps —
app-eshars-<env>-<region>,func-eshars-<env>-<region>— on the tier-shared App Service Plan (asp-eshars-{tier}-{region}). No per-env slots; onlyprodgets astagingslot (zero-downtime swap) - The env Key Vault is
kv-eshars-<env>-<region>with a private endpoint - Private DNS: the SQL alias zone (
db.<env>.eshars.internal) lands in the network RG (rg-eshars-network-{tier}-{region})
3. Apply
just apply-env devTypical duration: 8-12 minutes (SQL Server creation is the slow step, ~5 minutes).
Two first-apply failure modes on a brand-new env (both prerequisites above):
POST .../environments/<env>/variables: 404 Not Found— the GitHub repository environment was never created. Runsetup-github-environments.sh <env>(prerequisite 8), then re-apply.403 ForbiddenByConnectionon the env-KV secret writes despite green PE-DNS/RBAC polls — the new vault name was still cached public. Re-runjust apply-env <env>; it succeeds once the record has propagated (prerequisite 7).
4. Populate shared-external secrets from the scaffold KV
workload-env creates the shared-external secrets (vendor API keys, e-sign key,
notification webhooks) with PLACEHOLDER-update-after-provisioning values under
lifecycle { ignore_changes = [value] }. A rebuilt env KV therefore comes up with
placeholders — if nothing copies the real values in, the app authenticates to
Adobe Sign / SendGrid / TMHP with the literal placeholder and the vendor returns
401 INVALID_ACCESS_TOKEN (this is exactly how the dev SAForm document-upload
broke on the 2026-07 rebuild).
Canonical source = the per-tier SCAFFOLD Key Vault (kv-eshars-scaf-{tier}).
The scaffold KV lives outside the Terraform estate (rg-eshars-scaffold-{tier}-{region})
so it survives teardown/rebuild — it is the source-of-truth for these values.
copy-kv-secrets.sh derives the source scaffold KV from the env’s tier
automatically:
# Dry run first — resolves the 8 shared-external secrets, refuses any placeholder./scripts/copy-kv-secrets.sh --dry-run dev
# Then for real (kv-eshars-scaf-nonprod -> kv-eshars-dev-cus)./scripts/copy-kv-secrets.sh devTier is derived from the env: dev/qa/regression/staging → nonprod;
uat/training/prod → prod (in lockstep with infra_tier). After writing
the real values, restart the app so it re-reads the vault:
az webapp restart --name app-eshars-<env>-<region> --resource-group rg-eshars-web-<tier>-<region>The script copies 8 shared-external secrets (one value per tier, fanned out to every env KV — nonprod envs share one vendor integration):
EchoSignAppKey(Adobe Sign),SendGridApiKey,TMHPServerKey,zendeskSharedKey,HandShakingKeyDevOpsTeamsWebhookURL,SystemHealthNotificationWebhookURLEligibility,SystemHealthNotificationWebhookURLImports
It refuses to propagate a PLACEHOLDER-* value and fails loudly if a secret is
missing from the scaffold source (that would just re-create the silent 401), so
you seed the scaffold KV first.
One-time scaffold seed (already done for nonprod 2026-07-07; once per tier). The
real values are harvested from the legacy kv-eshars-qa — see the header of
scripts/copy-kv-secrets.sh for the exact loop.
Deliberately NOT copied (do not add these to the copy list):
| Secret | Why not | Correct source |
|---|---|---|
EncryptionKey, EncryptionkeyByte | eSHARS crypto that must match data already encrypted in the DB — a blind copy corrupts | TF-generated tier key (synthetic env) or pinned via tier_encryption_key_override (migrated-data env) |
masterPassword | NOT a crypto key — a universal auth backdoor. A non-empty value lets anyone log in as any user (the app’s auth paths accept it in place of the real password). Its SAFE state is ABSENT. | None — deliberately NOT provisioned (modules/key-vault-secrets no longer creates it; absent = bypass disabled). Never seed or copy a value. See eshars-security. |
AzureFunctionsHostKey | Generated by the Function App resource | Read back from the resource |
ImportApiUserPassword | App-internal cred paired with a SQL user | TF-generate + apply to SQL like SSRSReader (open follow-up) |
Jwt--Secret | In use — the eshars-mobile-api HMAC-SHA256 signing key (KV Jwt--Secret → config Jwt:Secret; mobile-api Program.cs throws if unset). Not consumed by HISD, but not removable. | TF-generated (random_password.jwt_secret in modules/key-vault-secrets) — populated on apply. Do not hand-seed a dummy or remove it. |
Terraform placeholders are protected with lifecycle { ignore_changes = [value] } —
once the script writes a real value, future tofu apply runs leave it alone.
4b. Create the SSRS report-execution login (SSRSReader)
SSRS report data sources connect to SQL as the low-priv SSRSReader login. The KV
secrets (SSRSReaderUsername/SSRSReaderPassword) are TF-generated, but the SQL
login itself is created by a post-apply script. Skip this and reports render
with Login failed for user 'SSRSReader' → SSRS returns 500 (the other half of
the 2026-07 dev SAForm break).
This runs automatically in the stand-up workflow. SSRSReader is a plain SQL
login (username/password), so the script connects as the server’s SQL admin —
no Entra directory resolution, which is why the CI deploy SP runs it unattended (no
managed identity, no Directory Readers grant). The admin credential is the same one
used everywhere else in this runbook, read from two equivalent places:
- login —
$TF_VAR_sql_server_admin_loginif already exported (the workflow sets it, sourcing theSqlServerAdminLoginscaffold-KV secret — see prerequisite 6); otherwise the script reads that sameSqlServerAdminLoginsecret from the scaffold KV directly. Same value, two paths. - password —
SqlAdminPasswordin the env KV (TF-generatedrandom_password.sql_admin).
Both are present after apply (step 3) + the scaffold seed (prerequisite 5), so no
manual step is needed. To run it by hand (same auth path):
./scripts/setup-db-ssrs-reader.sh --dry-run dev rg-eshars-data-nonprod-cus./scripts/setup-db-ssrs-reader.sh dev rg-eshars-data-nonprod-cusAlso confirm the web app’s SSRSDomain app setting = the VM NetBIOS computer
name (ssrs-{short}-{region}, e.g. ssrs-np-cus), NOT the Azure resource name
vm-eshars-ssrs-{tier}-{region} — the wrong value gives a 401 from SSRS on the
render call. (Sourced from report-server remote state computer_name output.)
4c. Gate: verify NO placeholders remain (mandatory)
The env is not ready until every Terraform-placeholder secret in its KV holds a
real value. Run the gate — it fails (exit 1) and lists any secret still on
PLACEHOLDER-*, the same way copy-kv-secrets.sh refuses placeholders for its 8:
./scripts/verify-env-secrets.sh devDo not proceed to smoke-testing the app while this fails. A leftover placeholder is a silent runtime failure (vendor 401, or — worse — unreadable encrypted data on a migrated env), not a deploy error, so this check is the only thing that turns “we documented the manual steps” into “the env actually has them”.
5. Apply Entra group grants to databases (MANUAL — human DBA, once per server)
The SQL server’s Entra admin (sg-eshars-db-nonprod-dba or
sg-eshars-db-prod-dba) is set automatically by tofu apply. The
database-level Entra group grants (reader, writer, dba) are not
Terraform-managed — run the post-provisioning script as a DBA-group member:
# Dry run first./scripts/setup-db-entra-groups.sh --dry-run dev rg-eshars-data-nonprod-cus
# Apply./scripts/setup-db-entra-groups.sh dev rg-eshars-data-nonprod-cus| Env | Resource group |
|---|---|
| dev / qa / regression / staging | rg-eshars-data-nonprod-cus |
| uat / training / prod | rg-eshars-data-prod-cus |
Groups applied for nonprod envs (per database-access-strategy):
| Group | data | log | audit |
|---|---|---|---|
sg-eshars-db-nonprod-reader | Read | Read | Read |
sg-eshars-db-nonprod-writer | Read+Write+Exec | Read+Exec | Read+Exec |
sg-eshars-db-nonprod-dba | db_owner | db_owner | db_owner |
Requires go-sqlcmd with an active az login session (your account must be in
the tier DBA group). Idempotent — safe to re-run.
6. App Service application settings (non-secret config)
Non-secret configuration (URLs, container names, flags) is Terraform-owned:
workload-env/locals.tf (web_app_settings_defaults) derives sensible defaults
from the env’s resource names and merges web_app_settings_override from the
env’s .tfvars:
web_app_settings_override = { "SSRSReportFolder" = "dev/Reports" # ... per-env additions/overrides}The merged map flows to the per-env app’s extra_app_settings — tofu apply
pushes it. Secrets do not go here; they live in the env KV and reach the app
via the greedy Key Vault config builder (see
the architecture page
for the full model, including why a KV secret of the same name overrides an app
setting).
Only prod has a staging slot (deploy → warm → swap); env-specific settings
are slot-sticky there so a swap moves code, not config.
7. Verify
# Resources in each tier RG (component-before-tier naming)az resource list -g rg-eshars-security-nonprod-cus -o tableaz resource list -g rg-eshars-data-nonprod-cus -o tableaz resource list -g rg-eshars-storage-nonprod-cus -o tableaz resource list -g rg-eshars-web-nonprod-cus -o tableaz resource list -g rg-eshars-monitoring-nonprod-cus -o table
# The readiness gate again — must exit 0./scripts/verify-env-secrets.sh dev
# DNS resolution from the spoke (run from a VM in the spoke VNet)Resolve-DnsName eshars-primary.db.dev.eshars.internal # PowerShell on the VMdig eshars-primary.db.dev.eshars.internal # Linux equivalent
# Web app reachablecurl -I https://app-eshars-dev-cus.azurewebsites.net/Then deploy the application via the HISD repo’s tier-aware CD and smoke-test a report end-to-end (SAForm submit exercises Adobe Sign + SSRS + SSRSReader in one pass — the failure-signatures table maps every known symptom to its cause).
Tear-down
just init-env dev # ensure backend is correcttofu destroy -var-file=environments/dev.tfvars # from infrastructure/workload-envKV soft-delete retention is 7 days. If you re-create immediately, you’ll hit
409 Conflict on the env KV — purge it first:
az keyvault purge --name kv-eshars-dev-cus --location centralusOpen items / known gaps
- Placeholder automation — the two remaining manual secrets:
ImportApiUserPassword(TF-generate + matching SQL user) andAzureFunctionsHostKey(post-deploy read-back). Until those land, step 4’s danger box + the 4c gate are the guardrail. (masterPasswordandJwt--Secretare already resolved — not-provisioned and TF-generated respectively, per eshars-ops #53 — no longer open items.) - Post-apply scripts in CI — fold
copy-kv-secrets.sh,setup-db-ssrs-reader.sh, andverify-env-secrets.shinto an apply-env pipeline (tracked). - Dedicated low-priv SQL users for
ESHARSREADONLY/ESHARSREPORTS— both connection-string secrets are TF-derived today (ESHARSREPORTS= the core connection string;ESHARSREADONLY= core withApplicationIntent=ReadOnly). Swapping them to dedicatedeshars_readonly/eshars_reportsSQL users (theSSRSReaderpattern) remains open. - Teams webhook URL deprecation — Microsoft retired Office 365 connectors;
use Power Automate Workflow URLs for
TF_VAR_teams_webhook_url. - Storage account name uniqueness —
saesharsapp{env}{region}is globally unique across Azure. Confirm availability before stand-up:az storage account check-name --name <name>. - GitHub environment bootstrap is manual —
setup-github-environments.sh <env>(prerequisite 8) is a one-time user-PAT step outside CI, because the workload-env App token can’t create a repo environment (only manage vars/secrets inside one). Missing it 404s the apply. Folding it into an apply-env pipeline is blocked by the no-PAT-in-CI constraint (same reason it isn’t in Terraform). - First-apply KV DNS race — even in-spoke, a brand-new env vault can
403 ForbiddenByConnectionon the first secret writes: the module’swait-for-pe-dnspoll (getent) sees the fresh private A-record, but the tofu Go SDK can still hit a stale public answer cached before the PE existed. A re-apply clears it. A durable fix (poll via the SDK’s own resolver, or flush the cache pre-write) is open.
Cross-reference
- Why it works this way: Configuration & Secrets Architecture
- The tier-level chain this slots into: Deployment Order
- Scripts:
eshars-ops/scripts/setup-github-environments.sh,copy-kv-secrets.sh,setup-db-ssrs-reader.sh,setup-db-entra-groups.sh,verify-env-secrets.sh - KV secret module:
eshars-ops/infrastructure/modules/key-vault-secrets/main.tf - Workload-env root:
eshars-ops/infrastructure/workload-env/