PowerShell JSON Config Files: Managing Script Settings Cleanly

Hardcoding server names, thresholds, and file paths directly in scripts is a habit that guarantees future pain. When an environment changes, every script touching those values needs an edit and a retest. JSON config files solve this cleanly: settings live outside the script, the script reads them at runtime, and you can maintain separate configs for dev, staging, and production without touching a single line of logic.
Quick Answer
Load a JSON file with Get-Content -Raw path\config.json | ConvertFrom-Json, validate required keys exist before using them, and optionally merge an environment-specific override file on top of the base config.
Reading a JSON Config File with Get-Content and ConvertFrom-Json
The standard pattern is two cmdlets chained together. -Raw returns the file as a single string rather than an array of lines, which is what ConvertFrom-Json expects.
# config.json contents:
# {
# "DatabaseServer": "sql01.corp.local",
# "RetentionDays": 30,
# "LogPath": "C:\\Logs\\myapp",
# "NotifyEmail": "[email protected]"
# }
$configPath = Join-Path $PSScriptRoot "config.json"
$config = Get-Content -Path $configPath -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host "Connecting to: $($config.DatabaseServer)"
Write-Host "Retention: $($config.RetentionDays) days"
Using $PSScriptRoot makes the path relative to the script file itself, so the config travels with the script regardless of where you run it from. Always specify -Encoding UTF8 in PowerShell 5.1 to avoid BOM-related parse failures.
Validating Required Keys Are Present
A missing key returns $null silently, which means your script may silently misbehave rather than fail loud. Validate upfront.
function Assert-ConfigKeys {
param(
[PSCustomObject] $Config,
[string[]] $RequiredKeys
)
$missing = $RequiredKeys | Where-Object { $null -eq $Config.$_ }
if ($missing.Count -gt 0) {
throw "Config is missing required key(s): $($missing -join ', ')"
}
}
$required = @('DatabaseServer', 'RetentionDays', 'LogPath')
Assert-ConfigKeys -Config $config -RequiredKeys $required
Throwing early with a descriptive message is far more useful than a cryptic NullReferenceException ten steps later when the value is first used. Put this call at the top of every script that depends on external config.
Supporting Environment-Specific Config Overrides
The base config holds safe defaults; an override file for production, staging, or a specific machine replaces only the keys that differ. This avoids duplication while allowing precise per-environment tuning.
# Look for an environment override alongside the base config
$envName = $env:DEPLOY_ENV ?? "dev" # PS7+; use if/else in PS5
$overridePath = Join-Path $PSScriptRoot "config.$envName.json"
$baseConfig = Get-Content -Path $configPath -Raw -Encoding UTF8 | ConvertFrom-Json
$mergedConfig = $baseConfig # start with base
if (Test-Path -Path $overridePath) {
$override = Get-Content -Path $overridePath -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host "Applying override: $overridePath"
}
Merging Default and Override Config Objects
PowerShell has no built-in deep-merge for PSCustomObjects, but iterating the override’s NoteProperty members is straightforward.
function Merge-Config {
param(
[PSCustomObject] $Base,
[PSCustomObject] $Override
)
# Clone base so we don't mutate the original
$merged = $Base | Select-Object *
$Override.PSObject.Properties | ForEach-Object {
$merged | Add-Member -MemberType NoteProperty `
-Name $_.Name `
-Value $_.Value `
-Force
}
return $merged
}
if (Test-Path -Path $overridePath) {
$override = Get-Content $overridePath -Raw -Encoding UTF8 | ConvertFrom-Json
$mergedConfig = Merge-Config -Base $baseConfig -Override $override
} else {
$mergedConfig = $baseConfig
}
Assert-ConfigKeys -Config $mergedConfig -RequiredKeys $required
Writing Config Changes Back to Disk
When a script updates a setting at runtime — such as recording the last successful run timestamp — write the whole object back as formatted JSON.
$mergedConfig.LastRunUtc = (Get-Date).ToUniversalTime().ToString("o")
$mergedConfig | ConvertTo-Json -Depth 5 |
Set-Content -Path $configPath -Encoding UTF8
Write-Host "Config saved."
Use -Depth 5 (or higher for nested objects) to avoid nested objects being truncated to @{...} strings, which would corrupt your config on the next read.
Securing Sensitive Config Values with SecureString Serialization
For non-interactive scripts that must store a credential, encrypt the password using DPAPI (Windows-only, tied to the current user or machine) and store the ciphertext in the JSON.
# Encrypt once, store ciphertext
$plain = Read-Host "Enter API key" -AsSecureString
$ciphertext = $plain | ConvertFrom-SecureString # DPAPI-encrypted
$config | Add-Member -MemberType NoteProperty -Name ApiKeyCipher -Value $ciphertext -Force
$config | ConvertTo-Json -Depth 5 | Set-Content $configPath -Encoding UTF8
# Decrypt at runtime
$secure = $config.ApiKeyCipher | ConvertTo-SecureString
$plainBSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
$apiKey = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($plainBSTR)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($plainBSTR)
This approach binds the ciphertext to the Windows user account that encrypted it, so the file cannot be decrypted on another machine or by another user without re-encrypting.
Common Errors
- UTF-8 BOM causes ConvertFrom-Json to fail in PS 5.1: Some editors save UTF-8 files with a Byte Order Mark. In PowerShell 5.1, this extra byte prefix confuses
ConvertFrom-Json. Save config files as UTF-8 without BOM, or use[System.IO.File]::ReadAllText($path)as an alternative reader in 5.1. - Missing key throws NullReferenceException far from the source: Accessing
$config.MissingKey.SubPropertygives a confusing error deep in your logic. Always validate required keys immediately after loading the config with a function likeAssert-ConfigKeysshown above.
Related Cmdlets / See Also
Wrapping Up
JSON config files are the simplest path from hardcoded scripts to environment-aware automation. Read with ConvertFrom-Json, validate required keys immediately, merge overrides for per-environment tuning, and use DPAPI-backed SecureString for any sensitive values. Your scripts become portable, and your configs become the single place to tune behavior.


