PowerShell JSON Config Files: Managing Script Settings Cleanly

PowerShell JSON Config Files: Managing Script Settings Cleanly

PowerShell Tips Editor 5 min read
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.SubProperty gives a confusing error deep in your logic. Always validate required keys immediately after loading the config with a function like Assert-ConfigKeys shown 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.

Send-Item -To