NAME

Punk::Config - YAML configuration with secrets kept out of the file

SYNOPSIS

# config/punk.yml - safe to commit
views:
  Stencil:
    template_dir: root/templates
    wrapper: layout.tmpl

database:
  dsn:      dbi:Pg:dbname=myapp;host=db
  user:     myapp
  password: { $env: DB_PASSWORD }

plugins:
  RequestId: {}

# MyApp.pm
package MyApp;
use Punk;

config 'config/punk.yml';        # loads, applies, freezes

get '/' => 'Web::Book#home';

DESCRIPTION

Configuration is read once, at boot, from a YAML file; layered by environment; and applied to the application before to_app freezes it. See "config" in Punk for what the known blocks do.

Implemented in C (include/punk/punk_config.h): the layering, the deep merge, secret resolution, the guardrail and the redacted copy. Only YAML parsing (one YAML::XS call per file) and the $exec resolver's subprocess are Perl, both once per boot.

Secrets

A config file should say where a secret comes from, never what it is. A reference is a single-key hash whose key starts with $ - a shape nothing else in a config has, and one that needs nothing from the parser:

password: { $env:     DB_PASSWORD }
password: { $file:    /run/secrets/db_password }
password: { $exec:    [ vault, read, -field=password, secret/db ] }
password: { $literal: not-actually-secret }

$env reads an environment variable (missing is fatal - better than starting with an empty password). $file reads a file and trims one trailing newline, which is exactly how Docker and Kubernetes deliver secrets to a container. $exec runs a command without a shell and takes its standard output, for Vault, aws secretsmanager, op read and friends; a non-zero exit is fatal. $literal is a deliberate inline value that bypasses the guardrail below.

Resolved secrets are kept apart from the public structure: $app->config has [redacted] where each one was, so it can be logged, dumped or serialised safely, and $app->secret($path) reaches the real value. Boot-time consumers are handed the real value directly, so a database password never sits in a general-purpose hash at all.

The guardrail

A plaintext value under a secret-shaped key (anything containing pass, secret, token, api_key, private_key or credential, the exact key auth, or a dsn with password= in it) is almost always a mistake. The loader notices and, by default, warns:

secrets: strict      # refuse to start
secrets: warn        # the default
secrets: off

auth matches only as a whole key, so an author field is left alone.

Layering

config/punk.yml is read first, then config/punk.$PUNK_ENV.yml (development unless PUNK_ENV says otherwise), then config/punk.local.yml. Later layers win. Hashes merge key by key; anything else replaces, so an array in an overlay is a replacement rather than an append.

Add punk.local.yml to .gitignore: it is the one place a developer may keep a throwaway value.

METHODS

load(file => $path, env => $name, secrets => $mode)

Read, layer, resolve and validate. Croaks if no layer exists, if a reference cannot be resolved, or - in strict mode - if the guardrail fires.

config

The public structure: secrets replaced by [redacted].

resolved

The same structure with the real values. What the boot consumers read.

get($dotted_path)

The resolved value at a path, secrets included.

secret($dotted_path)

has_secret($dotted_path)

secret_paths

The resolved secrets.

env

files

The environment name, and the layers actually read.

SEE ALSO

Punk, YAML::XS.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)