Configuration
In gpsystem, loading configuration is a single step: environment variables loaded into a typed Go struct, once, at process start. There is no config() helper and no config cache: the struct is the cache, and a mistyped key is not a runtime null but a compile error.
This is the 12-factor "config from the environment" principle: configuration is read at process start, from the environment, and the binary is identical in every environment (dev, staging, production); only the env differs. The loading mechanism itself, envconf, is a standalone module (github.com/gp-system/envconf) with no gpsystem-specific knowledge: this page is about what a generated project does with it, composing a dozen modules' worth of settings into one struct.
Struct composition
Every standalone module exports its own env-tagged Config struct, naming its own fields without knowing what, if anything, will prefix them. A generated project composes those structs into a single root Config with envPrefix:
package config
import (
"github.com/gp-system/dbx"
"github.com/gp-system/events/outbox"
"github.com/gp-system/framework/server"
"github.com/gp-system/framework/worker"
// gpsystem:imports
)
type Config struct {
Server server.Config
DB dbx.Config `envPrefix:"DB_"`
Worker worker.Config
Outbox outbox.Config `envPrefix:"OUTBOX_"`
// gpsystem:config
}
cfg := envconf.MustLoad[config.Config]()
The composition rule is simple: a module names its own fields, the embedder decides the prefix. dbx.Config's fields are HOST, PORT, USER, ...: under envPrefix:"DB_" they become DB_HOST, DB_PORT, DB_USER. Structs embedded without a prefix (server.Config, worker.Config) read standard top-level names.
The nesting goes deeper: server.Config itself embeds telemetry.Config, which bundles the telemetry/otelx config, the standalone logx module's Config, and the telemetry/sentryx config (all unprefixed, because these are industry-standard names (OTEL_SERVICE_NAME, OTEL_DEV_MODE, LOG_LEVEL, SENTRY_DSN) that every OTel/Sentry-compatible tool expects as-is). worker.Config in turn prefixes its Valkey and scheduler configs (VALKEY_ADDR, SCHEDULER_TIMEZONE).
The env-prefix conventions
Across the modules a project actually wires in, the prefixes settle into a consistent set:
| Prefix | Module | Example variables |
|---|---|---|
| (none) | framework's server | LISTEN_ADDR, SHUTDOWN_TIMEOUT |
| (none) | framework's worker | WORKER_CONCURRENCY, WORKER_QUEUES |
| (none) | framework's realtime | REALTIME_LISTEN_ADDR, REALTIME_CHANNEL_PREFIX |
DB_ | dbx | DB_HOST, DB_USER, DB_PASSWORD |
JWT_ | auth | JWT_SECRET, JWT_ACCESS_TOKEN_TTL |
MAIL_ | mail/smtp | MAIL_HOST, MAIL_FROM_ADDRESS |
VALKEY_ | queue | VALKEY_ADDR, VALKEY_PASSWORD |
OUTBOX_ | events/outbox | OUTBOX_POLL_INTERVAL, OUTBOX_BATCH_SIZE |
SCHEDULER_ | events/scheduler | SCHEDULER_TIMEZONE, SCHEDULER_LEASE_TTL |
S3_ | minio-storage-driver | S3_ENDPOINT, S3_BUCKET |
| (none, standard) | telemetry (otelx/sentryx) + logx | OTEL_*, LOG_*, SENTRY_* |
The shop's env surface, an excerpt combining several of these:
# server.Config: no prefix
LISTEN_ADDR=:3000
SHUTDOWN_TIMEOUT=10s
# telemetry (embedded in server.Config): standard names
OTEL_SERVICE_NAME=shop
OTEL_DEV_MODE=true
LOG_FORMAT=monolog
SENTRY_DSN=
# dbx.Config: under the DB_ prefix
DB_HOST=localhost
DB_USER=shop
DB_PASSWORD=secret
DB_NAME=shop
# worker.Config: WORKER_* and VALKEY_*
WORKER_CONCURRENCY=10
VALKEY_ADDR=localhost:6379
# outbox.Config: under the OUTBOX_ prefix
OUTBOX_POLL_INTERVAL=1s
OTEL_*, SENTRY_*), it's used as-is; where none does, short prefixed names (DB_*, VALKEY_*, WORKER_*). Your deployment looks like any other OTel-instrumented app's. The full per-module variable table, generated project by generated project: Configuration reference..env file loading order
Before parsing, on a best-effort basis, envconf loads the working directory's .env file, if present, via godotenv: it never overrides an existing environment variable, so .env only fills in what the process environment did not already provide. That single rule is what makes the same Config struct behave correctly in every context:
- Local development: nothing is exported in the shell; a generated project ships a
.env.exampleyou copy to.env, and every value comes from the file. - The dev docker-compose stack:
mise run devstarts the compose services (Postgres, Valkey, Mailpit, RustFS) and the app container with the same.env; service hostnames in the file (DB_HOST=postgres,VALKEY_ADDR=valkey:6379) resolve to compose's internal DNS, notlocalhost. See Installation for the full stack. - Staging/production: the real environment (a systemd unit, a Kubernetes ConfigMap/Secret) sets the variables directly; there usually is no
.envfile at all, and even if one were present, the already-set variables would win regardless.
envconf.MustLoad is the first line of main(): a missing required variable fails with the variable's name, at startup. No service ever boots half-configured only to reveal what's missing on the first request. See envconf: Overview for Load/MustLoad/LoadPrefixed themselves, and envconf: Recipes for composition and testing patterns beyond the basic case above (multi-instance connections, t.Setenv in tests).
Configuration as an explicit dependency
In gpsystem, configuration materializes once in main() and travels onward as an explicit parameter (cfg.Server to server.Run, cfg.DB to pg.MustNewPool, and to the modules via the Dependencies struct). A service never touches the environment at runtime. What it receives is the boot-time snapshot, freely constructible in tests. A mistyped field is a compile error, and a missing required env variable is an error at boot, with the variable's name, never a silent null at runtime.
Related pages
- envconf: Overview: the
Load/MustLoad/LoadPrefixedAPI, struct tags, standalone use. - envconf: Recipes: root
Configcomposition, prefixed multi-instance connections, testing witht.Setenv. - Configuration reference: every env variable of every module, with defaults.
- Application lifecycle: what happens after
MustLoad. - Installation: from
.env.exampleto the first boot.