TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Configuration as Data in PowerShell with PSD1: Safe Defaults, Validation, and Repeatable Deployments

Configuration as data is a simple practice that pays off fast: you keep your settings in versioned files, separate from your PowerShell code. That means cleaner reviews, safer changes, and predictable runs across dev, staging, and production. In PowerShell, PowerShell Data Files (.psd1) give you a native, typed way to store configuration as plain data without executing code. In this post, youll see how to structure PSD1 configs, layer them with safe defaults, validate required keys up front, and integrate the pattern into CI/CD and containerized deployments.

Why PSD1 for Configuration-as-Data

Benefits you get immediately

  • Safer reviews: PSD1 files are pure data; diffs show only what changed. No logic mixed with settings.
  • Predictability: Validate required keys and types before doing any work, so jobs fail fast and clearly.
  • Repeatability: Defaults plus environment overlays keep behavior consistent across machines and pipelines.
  • Typed values: PSD1 supports booleans, ints, arrays, and nested hashtables without string parsing.
  • Tooling-friendly: You can lint, test, and enforce policies in CI with standard PowerShell modules and Pester.

When to pick PSD1 over JSON/YAML

  • Native PowerShell: If your runtime is PowerShell, PSD1 avoids extra parsing steps.
  • Comments and trailing commas: PSD1 supports comments and is forgiving for humans editing files.
  • No code execution: Import-PowerShellDataFile parses data safely (no script blocks or functions are executed).

Implementing the Pattern: Defaults, Environments, and Validation

Start with safe defaults and overlay environments

Keep defaults in code (or a baseline PSD1), and overlay with an environment-specific PSD1. This makes migrations trivial: add a new default, override only where needed.

$defaults = @{ 
    ApiBase    = 'api.example.local'
    TimeoutSec = 20
    Enabled    = $true
}

$envName = if ($env:APP_ENVIRONMENT) { $env:APP_ENVIRONMENT } else { 'dev' }
$path    = ".\config.$envName.psd1"

$cfg = if (Test-Path $path) {
    Import-PowerShellDataFile -Path $path
} else {
    @{}
}

# Shallow merge: overlay file values on top of defaults
$config = @{}
$defaults.GetEnumerator() | ForEach-Object { $config[$_.Key] = $_.Value }
$cfg.GetEnumerator()       | ForEach-Object { $config[$_.Key] = $_.Value }

Write-Host ("Using {0} (Enabled={1}) Timeout={2}s" -f $config.ApiBase, $config.Enabled, $config.TimeoutSec)

Example config.dev.psd1:

@{
    ApiBase    = 'https://api.dev.example.local'
    TimeoutSec = 15
    Enabled    = $true
    Features   = @{ Metrics = $true; Beta = $true }
}

Example config.prod.psd1:

@{
    ApiBase    = 'https://api.example.com'
    TimeoutSec = 30
    Enabled    = $true
    Features   = @{ Metrics = $true; Beta = $false }
}

Validate required keys up front

Fail fast before any side effects (network calls, file writes). This makes runs predictable and friendly to CI.

$required = @('ApiBase', 'TimeoutSec')
$missing  = $required | Where-Object { -not $config.ContainsKey($_) }
if ($missing) {
    throw ('Missing keys: {0}' -f ($missing -join ', '))
}

Enforce types for safer deployments

Define a lightweight schema so a typo (e.g., TimeoutSec = 'thirty') fails immediately.

$schema = @{
    ApiBase    = [string]
    TimeoutSec = [int]
    Enabled    = [bool]
    Features   = [hashtable]
}

foreach ($k in $schema.Keys) {
    if ($config.ContainsKey($k)) {
        if (-not ($config[$k] -is $schema[$k])) {
            $expected = $schema[$k].FullName
            $actual   = $config[$k].GetType().FullName
            throw "Key '$k' must be of type $expected but was $actual"
        }
    }
}

Support nested settings with a deep merge

If your PSD1 uses nested hashtables, a shallow merge will replace the entire subtree. Use a deep merge to override only the keys you set in the environment file.

function Merge-Hashtable {
    param(
        [Parameter(Mandatory)] [hashtable] $Base,
        [Parameter(Mandatory)] [hashtable] $Override
    )
    $result = @{}
    foreach ($k in $Base.Keys) { $result[$k] = $Base[$k] }
    foreach ($k in $Override.Keys) {
        if ($result.ContainsKey($k) -and $result[$k] -is [hashtable] -and $Override[$k] -is [hashtable]) {
            $result[$k] = Merge-Hashtable -Base $result[$k] -Override $Override[$k]
        } else {
            $result[$k] = $Override[$k]
        }
    }
    return $result
}

$defaults = @{
    ApiBase    = 'api.example.local'
    TimeoutSec = 20
    Enabled    = $true
    Features   = @{ Metrics = $true; Beta = $false }
}

$cfg = Import-PowerShellDataFile -Path ".\config.$envName.psd1"
$config = Merge-Hashtable -Base $defaults -Override $cfg

Prefer object output and structured logging

For libraries and scripts imported as modules, return the configuration object and let callers decide how to log. For CLI tools, you can still print a summary.

Write-Host ("Using {0} (Enabled={1}) Timeout={2}s" -f $config.ApiBase, $config.Enabled, $config.TimeoutSec)
return $config

Operationalizing: Testing, CI/CD, Security, and Performance

Guardrails in CI with Pester

Automate validation so broken configs never merge to main.

# tests/Config.Tests.ps1
$ErrorActionPreference = 'Stop'
$env:APP_ENVIRONMENT = 'dev'
$config = & .\Get-Config.ps1  # your script that builds and returns $config

Describe 'Configuration schema' {
    It 'has required keys' {
        $config.Keys | Should -Contain 'ApiBase'
        $config.Keys | Should -Contain 'TimeoutSec'
    }
    It 'has correct types' {
        ($config.ApiBase)    | Should -BeOfType 'System.String'
        ($config.TimeoutSec) | Should -BeOfType 'System.Int32'
        ($config.Enabled)    | Should -BeOfType 'System.Boolean'
    }
}

In your pipeline, run Invoke-Pester before packaging or deploying.

Prevent typos: flag unknown keys

Unknown keys often mean misspellings that silently do nothing. Warn or fail when the file includes unexpected keys.

$allowed = @('ApiBase','TimeoutSec','Enabled','Features')
$unknown = $config.Keys | Where-Object { $_ -notin $allowed }
if ($unknown) {
    Write-Warning ("Unknown config keys: {0}" -f ($unknown -join ', '))
}

Keep secrets out of PSD1

PSD1 is great for non-secret settings. For secrets, rely on environment variables or SecretManagement providers.

# From environment
$apiToken = $env:API_TOKEN
if (-not $apiToken) { throw 'Missing env: API_TOKEN' }

# Or via Microsoft.PowerShell.SecretManagement
# Install-Module Microsoft.PowerShell.SecretManagement
$apiToken = Get-Secret -Name 'ApiToken'

Combine: config determines which service to call; secrets provide the credentials.

Environment selection in local, CI, and containers

  • Local dev: $env:APP_ENVIRONMENT = 'dev' and run your script.
  • CI: Set the variable per stage (e.g., dev, staging, prod).
  • Containers: Mount a read-only volume with config.prod.psd1 and set APP_ENVIRONMENT=prod via your orchestrator.
# Example entrypoint pattern (simplified)
$envName = if ($env:APP_ENVIRONMENT) { $env:APP_ENVIRONMENT } else { 'prod' }
$configPath = "/app/config.$envName.psd1"
$config = Import-PowerShellDataFile -Path $configPath

Make it fast: load once, reuse

Cache configuration in memory so repeated calls are cheap.

# In a module or script scope
$script:Config = $null
function Get-Config {
    if ($script:Config) { return $script:Config }
    $script:Config = & .\Build-Config.ps1  # returns the validated hashtable
    return $script:Config
}

Operational tips

  • Immutable at runtime: Treat $config as read-only; if you must modify, copy it first.
  • Default conservatively: Prefer timeouts and feature flags that fail safe. Override in env files as you gain confidence.
  • Document keys in the repo: Keep a CONFIG.md that lists each key, type, default, and examples.
  • Schema drift checks: When adding new defaults, add them to $allowed and your Pester tests.

Putting it all together

Heres a compact script that combines defaults, environment overlays, required keys, and types:

$defaults = @{ ApiBase = 'api.example.local'; TimeoutSec = 20; Enabled = $true; Features = @{ Metrics = $true; Beta = $false } }
$schema   = @{ ApiBase = [string]; TimeoutSec = [int]; Enabled = [bool]; Features = [hashtable] }
$required = @('ApiBase','TimeoutSec')

function Merge-Hashtable { param([hashtable]$Base,[hashtable]$Override)
    $r=@{}; foreach($k in $Base.Keys){$r[$k]=$Base[$k]} foreach($k in $Override.Keys){ if($r.ContainsKey($k) -and $r[$k] -is [hashtable] -and $Override[$k] -is [hashtable]){$r[$k]=Merge-Hashtable $r[$k] $Override[$k]} else {$r[$k]=$Override[$k]} } return $r }

$envName = if ($env:APP_ENVIRONMENT) { $env:APP_ENVIRONMENT } else { 'dev' }
$path    = ".\config.$envName.psd1"
$cfg     = if (Test-Path $path) { Import-PowerShellDataFile -Path $path } else { @{} }
$config  = Merge-Hashtable -Base $defaults -Override $cfg

# Validate required
$missing = $required | Where-Object { -not $config.ContainsKey($_) }
if ($missing) { throw ('Missing keys: {0}' -f ($missing -join ', ')) }

# Validate types
foreach ($k in $schema.Keys) {
    if ($config.ContainsKey($k) -and -not ($config[$k] -is $schema[$k])) {
        throw "Key '$k' must be of type $($schema[$k])"
    }
}

# Optional: warn on unknowns
$allowed = $schema.Keys
$unknown = $config.Keys | Where-Object { $_ -notin $allowed }
if ($unknown) { Write-Warning ("Unknown config keys: {0}" -f ($unknown -join ', ')) }

Write-Host ("Using {0} (Enabled={1}) Timeout={2}s" -f $config.ApiBase, $config.Enabled, $config.TimeoutSec)
$config

Make configuration first-class and youll get simpler deployments, clearer reviews, safer changes, and repeatable setupswithout complicating your scripts. Start by moving your environment-specific values into .psd1 files, layer them on top of safe defaults, and validate early. The result is a pattern that scales from a single script to large, automated systems.

← All Posts Home →