Skip to content

Application Configuration

Configuration arrives from several places and resolves to one typed view. Popcorn Web binds TOML files, environment variables, and command-line options to structs before the first request, so an invalid value stops startup instead of surfacing midway through runtime.

Nothing here reflects at runtime. pw generate reads your registration call and writes the binding code ahead of time, which is what keeps the whole mechanism available under TinyGo — and what makes a few of the rules below stricter than a reflection-based binder would need.

What this covers is the configuration a running application reads. popcornweb.toml, which pw itself reads, does not appear here — that is Build Tool Configuration.

For the framework’s own keys and their defaults, see Application Configuration Keys. This page is the machinery underneath them.

APP_ENV selects the runtime environment. It accepts dev, stg, prod, or any other token made of lowercase letters, digits, -, and _. An invalid token fails ParseConfig. When unset or empty it defaults to dev.

Terminal window
APP_ENV=prod ./myapp

pw.Env() returns the resolved token, and pw.EnvDevelopment, pw.EnvStaging, and pw.EnvProduction name the well-known ones.

Selecting an environment determines the project-local filename. Popcorn Web searches the working directory first, then its config/ directory:

  1. ./config.{APP_ENV}.toml
  2. ./config/config.{APP_ENV}.toml

User and system configuration directories use the environment-neutral config.toml. A project tree does not: a bare config.toml there 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.

--config-path replaces the search entirely:

Terminal window
./myapp --config-path ./deploy/staging.toml

Each key is reachable four ways, in increasing precedence:

default < TOML file < environment variable < command-line option

The three names come from one struct field. Field names become snake_case and nest under the prefix, and the prefix is not derived from anything — it is the literal the registration passes:

type AppConfig struct {
Mailer MailerConfig
}
type MailerConfig struct {
FromAddress string `default:"noreply@example.com"`
}
// "app" is the first segment of every key below. Rename the type and nothing
// moves; change this string and all three names move together.
func RegisterConfig() { pw.RegisterConfig[AppConfig]("app") }

Where that call goes and why its position matters is Adding your own settings below. With it in place the key is app.mailer.from_address, the TOML is [app.mailer] with from_address = …, the option is --app-mailer-from_address, and the environment variable is APP_MAILER_FROM_ADDRESS. Dots separating nesting levels become dashes in the option and underscores in the variable; underscores inside a single key survive untouched.

Five tags adjust the result:

Tag Effect
default:"value" value when nothing else supplies one
key:"name" override the stable TOML/config key
opt:"long" / opt:"long,s" override the CLI option, optionally with a short form
env:"NAME" / env:"-" exact environment variable name, or disable environment input
help:"text" description shown in usage and scaffolds

Overriding opt also moves the environment name, which derives from the long option rather than from the key. That is how server.port answers to --port and to PORT.

Three further tags describe a key rather than name it:

Tag Effect
secret:"mask" / "hide" / "show" how the value appears in the startup summary
falsy:"value" the value that counts as “not set” for anything depending on this key
dependon:".sibling" the key this one answers to; a leading dot is relative to the enclosing struct

dependon does not change binding. A dependent key is still read and still applied — it is only omitted from the startup summary while its parent is empty, so a disabled subsystem reports one line instead of seven. A key you set and cannot find in the summary is therefore a question about its parent, not about your spelling.

The full declaration surface — every tag, the duration syntax, the array-of-tables rules, and the TOML subset the loader reads — is Application Configuration Declaration.

Application settings follow the same path as framework settings: declare a struct, register it under a prefix, and read the result from the request context. pw generate turns the registration call into binding code, so adding a setting does not add a parallel parser.

package handlers
import "github.com/shibukawa/popcornweb/pw"
type AppConfig struct {
EnvLabel string `default:"local" help:"environment name shown in the page badge"`
EnvLabelColor string `default:"#64748b" help:"CSS color of the environment badge"`
}
func RegisterConfig() { pw.RegisterConfig[AppConfig]("app") }
func main() {
handlers.RegisterConfig()
if err := pw.Run(context.Background(), handlers.Handlers()); err != nil {
log.Fatal(err)
}
}

Where you place that call matters. Generated definitions register during package init, so the binding must be created after every init has run but before parsing begins. Registration after ParseConfig panics, and the prefix must be a string literal that the generator can read.

Each area of a larger application can register its own struct — see Project structure — but prefixes share one namespace, so give them distinct names (app, billing, search).

app := pw.Config[AppConfig](r)

pw.Config is available anywhere a request context is, and takes nil outside a request. It returns no error: an unparsed prefix 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.

[app]
env_label = "development"
env_label_color = "#059669"
Terminal window
APP_ENV_LABEL=development ./myapp
./myapp --app-env_label=development

Every registered prefix — framework and application alike — can print itself, with default values filled in and help text as comments:

Terminal window
./myapp --generate-config toml > config.dev.toml
./myapp --generate-config env > .env

Because the binary reports registrations from its actual imports, the scaffold matches the packages linked into that build. Add a struct and rerun the command; the new keys appear. Either form exits after writing — the server does not start. See Custom Commands.

Resolved configuration is reported once at startup — as a tree on a terminal, as one structured record everywhere else — so “did that value actually land?” has an answer that costs no extra log line. Each entry shows where its value came from, and a secret tag decides whether it is printed, masked, or dropped. observability.boot_log selects the format. See Configuration Summary.