A small, reflection-based configuration loader for Go with pluggable
backends (file, environment, Consul) and encoders (JSON, YAML, TOML).
It fills a target struct from one or more sources based on config:"..."
struct tags, supports nested structs, slices, defaults, required keys,
per-field source pinning, and hot-reloading (watch).
go get github.com/Ak-Army/configThe easiest way to hold a configuration is the generic config.Store. You give
it a Handler that supplies the defaults (and optional post-processing) and read
the resolved value back with Store.Config() — the store takes care of the
snapshot plumbing and locking for you.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/Ak-Army/config"
"github.com/Ak-Army/config/backend/env"
"github.com/Ak-Army/config/backend/file"
)
type Settings struct {
Host string `config:"host,required"`
Port int `config:"port"`
Timeout time.Duration `config:"timeout"`
}
// Settings is its own config.Handler[Settings]: Default supplies the defaults
// and Set post-processes the resolved snapshot.
func (*Settings) Default() *Settings {
return &Settings{Port: 8080, Timeout: 30}
}
func (*Settings) Set(s *Settings) {
s.Timeout *= time.Second // scale the raw number into a duration
}
func main() {
loader, err := config.NewLoader(context.Background(),
env.New(env.WithDefaults(".env")),
file.New(file.WithPath("config.json")),
)
if err != nil {
log.Fatal(err)
}
store := config.NewStore[Settings](&Settings{})
if err := config.Load(loader, store); err != nil {
log.Fatal(err) // only structural errors (e.g. target is not a pointer to struct)
}
cfg, err := store.Config()
// Per-load errors (missing required keys, decode failures) are surfaced
// here, not returned by Load.
fmt.Printf("%+v, err: %v\n", cfg, err)
}The loader loads into a config.Store[T] — a generic, concurrency-safe holder
for the configuration struct T. It is the only target Load accepts:
func Load[T any](l *config.Loader, s *config.Store[T]) errorBuild a store with config.NewStore[T] and pass it a config.Handler[T] that
supplies the type-specific parts:
type Handler[T any] interface {
Default() *T // fresh *T with defaults; called for every (re)load
Set(*T) // post-process the resolved snapshot (scale durations, derive fields)
}On every load (including watcher-triggered reloads) the store hands the loader a
fresh *T from Default(), the loader resolves the sources into it, Set()
post-processes the result, and the value is stored. Read the current value back
with store.Config(), which is safe to call concurrently with reloads and
returns the last load's error alongside the config, so a bad reload never
silently replaces a good snapshot.
NewStore also accepts a nil handler, in which case a zero-valued *T is used
for every load and no post-processing runs:
store := config.NewStore[Settings](nil)A common pattern is to let the configuration struct be its own handler by
defining Default and Set on it (as in the quick start above), then passing a
zero value: config.NewStore[Settings](&Settings{}).
`config:"<key>[,required][,encrypted][,backend=<name>]"`
| Token | Meaning |
|---|---|
<key> |
Key looked up in the source data. |
required |
Load fails (via the snapshot error) if the key is not found. |
encrypted |
The value may be an ENC(...) encrypted string; see Encrypted values. |
backend=<name> |
Only read this field from the source whose name matches <name>. May be repeated (backend=a,backend=b) to allow several sources. |
- (as <key>) |
For a struct field: inline its fields into the parent. Otherwise: skip. |
Nested struct, *struct, and []struct fields are resolved recursively;
the key names the sub-document. Fields with no tag are ignored.
Note: the option order matters — the key must come first (
"key,required", not"required,key").
Sensitive fields (passwords, API keys) can be stored encrypted in the config
source. Mark the field with the encrypted tag option and store the value in
the ENC(<kid>:<base64>) envelope, where <kid> names the key it was
encrypted with:
type Settings struct {
DBPass string `config:"db_pass,encrypted"`
}{ "db_pass": "ENC(prod-2026-07:4Yw3...base64...)" }Keys live in a key-ring file: one <kid>: <base64 32-byte key> entry per
line, blank lines and # comments allowed, the first entry is the active
key (used for encryption; the others only decrypt):
# config.keyring
prod-2026-07: 4Yw3...base64... # active
prod-2026-01: 9k2f...base64...
Configure a crypto.Crypto on the loader before calling Load. It wraps the
key ring and the ENC(...) envelope handling, hiding the cipher implementation
from the loader. crypto.New loads a key ring file and turns each raw key
into a crypto.Decrypter with the key parser you supply — the cipher is
pluggable, the crypto/aesgcm subpackage ships AES-256-GCM, but anything
implementing crypto.Decrypter (Vault, KMS, ...) can be a key:
import (
"github.com/Ak-Army/config/crypto"
"github.com/Ak-Army/config/crypto/aesgcm"
)
cr, err := crypto.New("config.keyring", func(key []byte) (crypto.Decrypter, error) {
return aesgcm.New(key)
})
if err != nil { ... }
loader.SetCrypto(cr)Rules:
encryptedapplies only to leafstring/*stringfields (including named string types); on other field types the load reports an error, on struct/list fields the option is ignored.- A tagged field whose value is not in
ENC(...)form passes through as plaintext — the same field can be encrypted in production and plain in a local config. - An
ENC(...)value without a configured crypto, with an unknown key id, or one that fails to decrypt, surfaces as a per-field error viastore.Config(). - The feature is encoder- and backend-agnostic: it works with JSON, YAML and TOML files, env and Consul sources alike.
Use the configcrypt helper (alias it as
go run github.com/Ak-Army/config/cmd/configcrypt):
# generate a key and put it in the keyring
echo "prod-2026-07: $(configcrypt -genkey)" > config.keyring
# encrypt a value with the active key (positional arg or stdin)
configcrypt -key config.keyring "s3cr3t"
# -> ENC(prod-2026-07:4Yw3...base64...)
# decrypt / inspect a value
configcrypt -key config.keyring -d 'ENC(prod-2026-07:4Yw3...)'Programmatic encryption is also available via crypto.EncryptValue.
Every value names its key, so old and new keys can coexist while configs are re-encrypted:
-
Generate a new key and add it as the first entry of the keyring (it becomes the active key); keep the old entry below it.
-
Deploy the keyring — services now decrypt both old and new values.
-
Re-encrypt each config with the active key; only
ENC(...)values change, every other byte of the file stays untouched (works for JSON, YAML, TOML):configcrypt -key config.keyring -rekey -in config.json # preview to stdout configcrypt -key config.keyring -rekey -in config.json -write # rewrite in place
Single values can be re-keyed too:
configcrypt -key config.keyring -rekey 'ENC(...)'. -
Once no config references the old key id (
grep -r 'ENC(prod-2026-01:'), remove its line from the keyring.
| Backend | Constructor | Notable options |
|---|---|---|
| File | file.New(...) |
WithPath, WithWatchInterval, WithOption(backend...) |
| Env | env.New(...) |
WithDefaults (dotenv file), WithPrefix, WithStripPrefix, WithWatchInterval |
| Consul | consul.New(...) |
WithClient (required), WithPrefix, WithStripPrefix |
Common backend options (via WithOption): backend.WithName(...) sets the
name matched by backend=, backend.WithEncoder(...) picks the encoder,
backend.WithWatcher() enables watching.
Resolution is per field: each field independently takes its value from the
first registered source that has the key (deterministic, in registration
order). Because of this, a nested struct can be assembled from several sources
at once — each of its fields is filled from whichever source provides it.
Pin a field to one or more sources with backend=<name> (repeat the option to
allow several); the field is then read only from those, still in registration
order. A pin on a nested struct or []struct locks all of its subfields
to the same sources — a subfield's own backend= cannot widen or change it.
encoder/json (default), encoder/yaml, encoder/toml. Set per source:
file.New(file.WithPath("config.yaml"),
file.WithOption(backend.WithEncoder(yaml.New())))Enable with backend.WithWatcher(). When a watched source changes, every store
previously passed to Load is re-populated and its next Config() returns the
new snapshot. Watching stops when the context.Context given to NewLoader
is cancelled.
There are two error channels:
Load(andNewLoader/AddSource) return structural errors — a bad target type, or a source that fails its initial read.- Per-field errors (missing required keys, decode failures) are surfaced via
store.Config()'s second return value, so a bad reload never replaces a good snapshot silently.