Application Configuration Declaration
A configuration struct is declared once and reached four ways: a default tag,
a TOML file, an environment variable, and a command-line option. pw generate
reads the registration call and writes the binding code, so nothing reflects at
run time — which is what keeps configuration available under TinyGo, and what
makes several rules below stricter than a reflection-based binder would need.
This page is the declaration surface. The framework’s own keys and their defaults are in Application Configuration Keys; the narrative version, with a worked example, is Application Configuration.
Registering a struct
Section titled “Registering a struct”type AppConfig struct { EnvLabel string `default:"local" help:"environment name shown in the page badge"`}
func RegisterConfig() { pw.RegisterConfig[AppConfig]("app") }| Rule | Detail |
|---|---|
| The prefix is a string literal | generation reads it statically; a computed value produces no binding |
The call sits in a directory generate.config lists |
otherwise nothing is generated for it |
Registration happens after every init and before parsing |
generated definitions register during init; registering after ParseConfig panics |
| Prefixes share one namespace | give each area its own — app, billing, search |
| A prefix may contain dots | pw.RegisterConfig[CacheConfig]("middleware.cache") |
Reading it back takes no error handling:
app := pw.Config[AppConfig](r)pw.Config accepts nil outside a request. A registered prefix that was never
parsed yields its declared defaults and an unregistered type yields the zero
value, because a handler reading configuration is already on the response path,
where a nil check would only postpone the same missing value to a later line.
Field types
Section titled “Field types”| Supported | |
|---|---|
string |
|
bool |
|
int |
|
time.Duration |
Go duration syntax in every source |
[]string |
a TOML array, a repeated CLI option, or a comma-separated environment value |
| a named nested struct of those | becomes a nested TOML table |
[]T where T is a named struct in the same package |
filled from an array of tables |
Floats, maps, pointers, and other slice element types cannot be bound. Receive
them in a supported representation and convert after parsing — a float64
declared directly is a generation error reading unsupported basic type float64.
Durations
Section titled “Durations”type MailerConfig struct { SendTimeout time.Duration `default:"5s" help:"outbound send timeout"`}[app.mailer]send_timeout = "1h30m"A bare number is rejected in every source, because 5 cannot say whether it
means seconds or nanoseconds. That applies to the default tag as well, where
an unparsable value fails pw generate rather than startup. Scaffolds emit
durations as quoted strings, and a field with no default starts at "0s".
Only time.Duration itself is treated this way. A named type of your own whose
underlying type is time.Duration binds as an integer.
Repeated settings
Section titled “Repeated settings”type AppConfig struct { Routes []RouteConfig `help:"static routes"`}
type RouteConfig struct { Path string Dir string Listing bool `default:"false"`}[[app.routes]]path = "/"dir = "./public"
[[app.routes]]path = "/files"dir = "./files"listing = trueElement count is data, so an element has no CLI option and no environment
variable: the file is its only source. default still applies, once per
element. The rules:
- The element struct is a named struct in the same package, held by value.
[]*RouteConfigand a struct that reaches itself are both rejected. optorenvon an element field is a generation error rather than a tag that quietly does nothing, and so arefalsy,dependon, andsecret— each of those needs a stable config key, and an element’s key belongs to one element rather than to the configuration.- A subcommand cannot take a slice of structs at all.
- To get a credential or a machine-specific path into an element, write a
${NAME}reference in its value. - The scaffold renders one example
[[…]]block per slice.
Five tags name a key:
| Tag | Effect |
|---|---|
default:"value" |
the value when no source supplies one |
key:"name" |
override the stable TOML and config key |
opt:"long" / opt:"long,s" |
override the CLI long option, optionally with a one-character short form |
env:"NAME" / env:"-" |
an exact environment variable name, or no environment input at all |
help:"text" |
the description shown in usage and scaffolds |
Three describe a key rather than naming it:
| Tag | Effect |
|---|---|
secret:"mask" / "hide" / "show" |
how the value appears in the startup summary |
dependon:"key" / dependon:".sibling" |
the key this one answers to; a leading dot is relative to the enclosing struct |
falsy:"value" |
the value that counts as “not set” for anything depending on this key |
dependon and secret may sit on a nested struct field, where they cover the
whole subtree. falsy may not: it names one value, and a struct has none.
Godoc as the help source
Section titled “Godoc as the help source”A field with no help tag takes its description from its doc comment, and the
generator writes that text back into the struct tag:
type MailerConfig struct { // FromAddress is the envelope sender. FromAddress string `default:"noreply@example.com"`}After one run the source reads
`default:"noreply@example.com" help:"FromAddress is the envelope sender"`.
The tag is the single source of truth from then on: an existing help always
wins, and re-running changes nothing. Only the first paragraph is used, //go:
and lint directives are dropped, and one trailing period is removed. A trailing
line comment works too. The same text feeds generated CLI usage, and a
subcommand registered with an empty help string falls back to its struct godoc.
The three names one field answers to
Section titled “The three names one field answers to”Field names become snake case and nest under the registered prefix. With prefix
app:
type AppConfig struct { Mailer MailerConfig}
type MailerConfig struct { FromAddress string}| Surface | Name |
|---|---|
| Stable config key | app.mailer.from_address |
| TOML | [app.mailer] with from_address = … |
| CLI | --app-mailer-from_address |
| Environment | APP_MAILER_FROM_ADDRESS |
Take the stable key, replace each . with -, and prefix --: that is the
option. Take the option, drop the dashes, and upcase: that is the variable.
Underscores inside a single key survive; only the dots that separate nesting
levels change.
opt moves both, because the environment name derives from the long option
rather than from the key:
Port int `key:"listen_port" default:"8080" opt:"port,p" help:"HTTP listen port"`| Surface | Name |
|---|---|
| Stable key | app.listen_port |
| TOML | [app] listen_port = 8080 |
| CLI | --port 8080 or -p 8080 |
| Environment | PORT=8080 |
With opt present the derived --app-listen_port is not registered. env
moves only the environment name and is used exactly as written; it must begin
with a letter or _, and assigning one name to two fields is a generation
error. That is how observability.service_name answers to OTEL_SERVICE_NAME
while keeping its own TOML key and option.
Where values come from
Section titled “Where values come from”default < TOML file < environment variable < command-line optionPrecedence is fixed and not configurable. An absent key in one layer does not clear the value a lower layer supplied; a present key always overrides.
APP_ENV selects the environment and therefore the project-local filename. It
accepts dev, stg, prod, or any other token of lowercase letters, digits,
-, and _; an invalid token fails ParseConfig, and unset or empty means
dev. Popcorn Web reads the first readable file of:
./config.{APP_ENV}.toml./config/config.{APP_ENV}.toml- the user configuration directory, where the file is the environment-neutral
config.toml - the system configuration directory, likewise
A bare config.toml in the project tree is never read. The asymmetry is
deliberate — a file that applies to every environment is reasonable on one
operator’s machine and misleading in a repository, where “which environment is
this for?” would have no answer.
Files are never merged. The first readable candidate is the only one read,
which lets a local file replace a system one rather than combine with it.
--config-path replaces the search entirely and does not fall back; a missing,
unreadable, or directory path there fails the load.
./myapp --config-path ./deploy/staging.tomlpw.SetConfigLoadOptions adjusts the search before ParseConfig for a binary
that needs configuration before it decides to serve — a CLI subcommand, a
migration runner, a one-shot job. Calling it after parsing panics.
The TOML subset
Section titled “The TOML subset”Configuration reads a restricted subset rather than the full TOML specification:
| Accepted | Not accepted |
|---|---|
| tables, nested tables, bare dotted keys | quoted keys |
| string, bool, integer, and float scalars | inline tables |
| arrays of primitive scalars | nested arrays |
| arrays of tables | |
| comments |
There are two limits here rather than one — what the parser accepts and what a field can receive — and the second is narrower. A TOML float parses and still cannot bind to a field.
Every key after a [[…]] header belongs to that element, so the enclosing
table’s own keys have to come before the first element.
TOML is the asymmetric case for typos: an unknown key parses, matches no field, and is silently not applied, while a misspelled CLI option fails loudly.
Referencing the environment from a file
Section titled “Referencing the environment from a file”${NAME} inside a TOML string expands from the environment at load. The
reference need not span the whole value:
[[middleware.rdb.connections]]group = "primary"dsn = "postgres://app:${PRIMARY_DB_PASSWORD}@db1.internal:5432/app"This exists mainly to get credentials into the elements of an array of tables, which have no option and no variable of their own.
- Only strings expand. Keys, table headers, numbers, and booleans do not. Array
elements and the fields of
[[…]]elements do. - An undefined name fails the load. The file layer outranks defaults, so
expanding to an empty string would quietly erase a
default; failing at startup is easier to notice. A variable set to the empty string counts as defined and expands to"". $$yields one literal$. A$followed by neither{nor$stays literal.- An expanded value still belongs to the file layer, so environment and CLI overrides keep their usual precedence.
- A
${…}written in an environment or CLI value stays literal. - A reference names a raw environment variable; per-field
envnames andenv:"-"do not affect it. - There is no
${NAME:-default}fallback form.
Command-line forms
Section titled “Command-line forms”./myapp --app-mailer-from_address noreply@example.com./myapp --app-mailer-from_address=noreply@example.com./myapp --app-tls-enabled # a bool with no value is true./myapp --app-tls-enabled=false./myapp --app-origins a.example --app-origins b.example # []string accumulatesAn unknown option, a missing value, and an invalid boolean all fail the load.
Scaffolds
Section titled “Scaffolds”Every registered prefix can print itself, with default values filled in and
help text as comments:
./myapp --generate-config toml > config.dev.toml./myapp --generate-config env > .envBecause the scaffold is assembled from the registrations present in that build,
it is the authoritative key list for that binary — including your own prefixes
and excluding any framework capability you never imported. Within a [prefix]
table the keys follow the struct’s declaration order; the tables themselves are
ordered by prefix and type name, so output never depends on package
initialisation order. The .env scaffold is sorted by variable name instead,
having no table grouping to hang declaration order on, and it respects opt,
env:"NAME", and env:"-".
The struct’s godoc becomes the TOML table comment. Either form exits after writing — the server does not start.
Parsing reads process environment variables and does not read a .env file, so
a scaffolded .env needs your shell or a dotenv loader to reach the process
first.
CLI-only subcommands
Section titled “CLI-only subcommands”type MigrateOptions struct { Path string `arg:"required" help:"migration directory"` Label string `arg:"optional" help:"migration label"` DryRun bool `default:"false" help:"print changes without applying"` Extra []string `arg:"*" help:"additional migration inputs"`}
func init() { pw.RegisterSubCommand[MigrateOptions]("migrate", "run database migrations") }options, ok := pw.Command[MigrateOptions]()A subcommand struct reads no TOML and no environment value; its fields come from the command line alone.
| Tag | Meaning |
|---|---|
| (none) | an option, named by the same rules as a configuration field |
arg:"required" |
a positional argument that must be present |
arg:"optional" |
a positional argument that may be omitted |
arg:"*" |
the remaining positional arguments |
./myapp migrate ./migrations./myapp migrate ./migrations --dry_run release extra-a extra-bOptions may appear before or after positional arguments. Only the selected
subcommand returns a value from pw.Command. A missing required argument, an
unknown command or option, and --help all fail parsing with generated usage
text. pw.SubCommand remains as a deprecated alias of RegisterSubCommand.
See Custom Commands.
What the startup summary shows
Section titled “What the startup summary shows”Resolved configuration is reported once at startup, each entry with the source
its value won from. On a terminal this is the configuration tree; in a container
or a pipe it is the same data in a structured log record. secret controls
disclosure, dependon filters inactive branches, and falsy helps that filter
recognize non-boolean forms of “off.”
secret: control what the startup log discloses
Section titled “secret: control what the startup log discloses”Configuration often reaches a log collector, so a credential must not appear
merely because the application printed its effective settings. Use secret
for a sensitive field whose name does not trigger the automatic rule, to omit
a field completely, or to undo an automatic false positive:
type DeliveryConfig struct { Password string SigningMaterial string `secret:"mask"` TokenBucketSize int `default:"128" secret:"show"` InternalNote string `secret:"hide"`}Registered under delivery, the visible part of the startup tree is:
delivery├─ password *****├─ signing_material *****└─ token_bucket_size 128password is masked automatically. signing_material does not look sensitive
to the automatic rule, so secret:"mask" supplies the missing policy.
token_bucket_size contains token but is not a secret, so secret:"show"
undoes that conservative match. internal_note is absent because hide
removes the whole entry; mask would retain the key and source while replacing
only a non-empty value with *****.
The exact automatic rule is case-insensitive substring matching against the
full stable key path. A field with no secret tag is masked when that path
contains any of:
password secret apikey api_key credential access_key accesskey token dsn private_keyA stable key ending in .dsn is the one display exception. It still counts as
sensitive, but the startup summary and pw doctor retain the scheme, host,
port, and database path while replacing user information with ***** and
dropping the query string. If the DSN cannot be parsed safely, the whole value
is masked.
An explicit secret tag always wins: mask masks, hide omits, and show
prints the value. Use show only to correct a name such as
token_bucket_size, never to expose a credential.
dependon: remove disabled branches from the summary
Section titled “dependon: remove disabled branches from the summary”An authentication switch illustrates the noise dependon removes. With
authentication disabled, provider paths and credentials may still have
defaults or values, but they do not describe anything the process will use:
type AuthConfig struct { Enabled bool `default:"false"` Mode string `default:"oidc_only" dependon:".enabled"` OIDC OIDCConfig `dependon:".enabled"`}
type OIDCConfig struct { Issuer string ClientID string ClientSecret string `secret:"mask"`}When auth.enabled is false, the startup tree reports the decision and
nothing underneath it:
auth└─ enabled falseWithout dependon, the same disabled feature would also print mode and every
OIDC setting. A leading dot names a sibling in the enclosing struct, so both
tags above resolve to auth.enabled; an absolute key such as
dependon:"server.tls_enabled" can cross a struct boundary. Putting the tag on
the OIDC struct field applies it to every leaf in that subtree. Dependencies
are transitive: one empty parent hides the dependent and anything that depends
on it.
This is a presentation filter, not a feature switch. Hidden fields are still
bound from TOML, environment variables, and flags; their CLI options, help, and
scaffolds remain. Application behavior must still read Enabled or its own
equivalent.
falsy: teach dependon what “off” means
Section titled “falsy: teach dependon what “off” means”dependon already treats a missing value, an empty string, and boolean false
as off. A string enum often uses a non-empty choice such as none or off
instead. falsy gives that choice the same meaning for display filtering:
type ExportConfig struct { Mode string `default:"none" enum:"none,otlp,stdout" falsy:"none"` Endpoint string `dependon:".mode"` Headers string `secret:"mask" dependon:".mode"`}With mode = "none", the summary shows export.mode and hides endpoint and
headers. Without falsy:"none", none is merely a non-empty string, so both
dependents would remain visible.
Numbers and durations need the same explicit decision because zero can be a
real setting. Here zero disables slow-statement detection and therefore hides
the EXPLAIN option:
type QueryConfig struct { // Zero disables slow-statement detection, and with it EXPLAIN. SlowThreshold time.Duration `falsy:"0s" help:"slow statement threshold"` Explain bool `dependon:".slow_threshold" help:"run EXPLAIN on slow statements"`}The falsy value also fills the field when nothing else sets it:
- no
defaulttag and no source sets the key — the field resolves to the falsy value; - a source sets the key to
""— it resolves to the falsy value, keeping that source as its origin; - a
defaulttag is present — the default wins andfalsynever substitutes.
The comparison is by value rather than by text, so 0, 0s, and 0ms all read
as off. falsy applies only to strings, integers, and durations: booleans
already have false, while lists have no single value the generator can safely
declare to be off. Without a falsy tag a number or a duration cannot be a
dependon parent at all; generation fails rather than guessing that zero means
disabled.
See Startup Summary.
Common errors
Section titled “Common errors”- a prefix that is not a string literal, or an empty one
pw.RegisterConfigcalled afterParseConfig- a field of an unsupported type — a float, a map, a pointer, a slice of
anything but
stringor a named struct - an unparsable
default, including a bare number on atime.Duration opt,env,falsy,dependon, orsecreton an array-of-tables element fieldfalsyon a nested struct field- a number or duration named by
dependonwith nofalsytag of its own - one environment name assigned to two fields
${NAME}naming a variable the process does not have
