Documentation
Everything you need to load, validate, and manage typed configuration in Go.
Getting Started
Install confkit and load your first typed configuration in under a minute.
Installation
terminal
go get github.com/MimoJanra/confkit@v0.9.0
Go 1.24 or later required.
30-Second Example
Define a struct with tags describing where values come from. That's all the configuration you need.
main.go
package main
import (
"log"
"time"
"github.com/MimoJanra/confkit"
)
type Config struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"8080" validate:"min=1,max=65535"`
Timeout time.Duration `env:"TIMEOUT" default:"30s"`
DB struct {
DSN string `env:"DSN" validate:"required" secret:"true"`
MaxConns int `env:"MAX_CONNS" default:"10" validate:"min=1,max=100"`
} `prefix:"DB_"`
}
func main() {
cfg, err := confkit.Load[Config](
confkit.FromFlags(), // highest priority — checked first
confkit.FromEnv(), // overrides file
confkit.FromYAML("config.yaml"), // fallback
)
if err != nil {
log.Fatal(confkit.Explain(err))
}
log.Printf("listening on %s:%d", cfg.Host, cfg.Port)
}
Multiple Sources & Precedence
Sources use first-wins semantics. The first source to provide a value for a field wins. List your highest-priority sources first.
cfg, err := confkit.Load[Config](
confkit.FromEnv(), // 1st: runtime overrides
confkit.FromYAML("config.yaml"), // 2nd: env-specific config
confkit.FromYAML("defaults.yaml"), // 3rd: base defaults (fallback)
)
Human-Readable Errors
When validation fails, confkit.Explain(err) formats the error for humans:
Invalid configuration:
Port
error: must be between 1 and 65535, got 99999
source: env (PORT)
DB.DSN
error: field is required
source: env (DB_DSN)
No secret values are ever shown in error messages — fields tagged secret:"true" always display as <redacted>.
Configuration Sources
confkit loads from any number of sources. Pass them in priority order — first source per field wins.
Environment Variables — FromEnv()
type Config struct {
Port int `env:"PORT" default:"8080"`
Database string `env:"DATABASE_URL" validate:"required"`
}
cfg, err := confkit.Load[Config](confkit.FromEnv())
Env prefix for nested structs
type Config struct {
DB struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"5432"`
} `prefix:"DB_"` // reads DB_HOST, DB_PORT
}
YAML — FromYAML(path)
cfg, err := confkit.Load[Config](confkit.FromYAML("config.yaml"))
JSON — FromJSON(path)
cfg, err := confkit.Load[Config](confkit.FromJSON("config.json"))
TOML — FromTOML(path)
cfg, err := confkit.Load[Config](confkit.FromTOML("config.toml"))
Multi-File Merging — FromYAMLFiles / FromJSONFiles / FromTOMLFiles
Merge multiple files of the same format. The first file to provide a value wins; nested maps are deep-merged.
cfg, err := confkit.Load[Config](
confkit.FromYAMLFiles(
"config/base.yaml", // shipped defaults
"config/production.yaml", // environment overlay
"config/local.yaml", // developer overrides (git-ignored)
),
confkit.FromEnv(), // env vars override everything
)
CLI Flags — FromFlags()
Flags are converted to kebab-case. Supports --key=value, --key value, -k value, and bare --flag for booleans.
type Config struct {
Verbose bool `flag:"verbose" short:"v"`
Output string `flag:"output" short:"o" default:"stdout"`
InputDir string `flag:"input" validate:"required"`
}
// ./mytool -v -o file.txt --input /data
Kubernetes ConfigMap — k8s.FromKubernetesConfigMap
import "github.com/MimoJanra/confkit/k8s"
cfg, err := confkit.Load[Config](
k8s.FromKubernetesConfigMap("default", "app-config"),
)
HashiCorp Vault — vault.FromVault
import (
"os"
"github.com/MimoJanra/confkit/vault"
)
auth := vault.VaultTokenAuth(os.Getenv("VAULT_TOKEN"))
// Also: vault.VaultAppRoleAuth(roleID, secretID)
// Also: vault.VaultKubernetesAuth(role, jwt)
cfg, err := confkit.Load[Config](
vault.FromVault("https://vault.example.com", auth, "/secret/myapp"),
)
AWS SSM Parameter Store — aws.FromAWSSSMParameterStore
import "github.com/MimoJanra/confkit/aws"
cfg, err := confkit.Load[Config](
aws.FromAWSSSMParameterStore("/prod/app/config"),
)
AWS Secrets Manager — aws.FromAWSSecretsManager
cfg, err := confkit.Load[Config](
aws.FromAWSSecretsManager("prod/app-secrets"),
)
Consul KV — consul.FromConsul
import "github.com/MimoJanra/confkit/consul"
cfg, err := confkit.Load[Config](
consul.FromConsulWithOptions("consul.example.com:8500", "token", "dc1"),
)
etcd v3 — etcd.FromEtcd
import "github.com/MimoJanra/confkit/etcd"
cfg, err := confkit.Load[Config](
etcd.FromEtcdWithPrefix(
[]string{"etcd1.example.com:2379", "etcd2.example.com:2379"},
"/myapp",
),
)
Custom Sources
Implement the Source interface — just two methods:
type MySource struct{ data map[string]string }
func (s *MySource) Name() string { return "custom" }
func (s *MySource) Lookup(ctx context.Context, field *confkit.FieldInfo) (any, bool, error) {
val, ok := s.data[field.Path]
return val, ok, nil
}
cfg, err := confkit.Load[Config](&MySource{ data: map[string]string{"Port": "8080"} })
Validation
Validation rules live in struct tags and are applied after all sources are loaded. Rules are comma-separated, all must pass.
Built-in Rules
| Rule | Types | Description |
required | any | Non-zero value required |
min=N | int, float | Value ≥ N |
min=N | string | Length ≥ N characters |
max=N | int, float | Value ≤ N |
max=N | string | Length ≤ N characters |
oneof=a b c | string | Must equal one space-separated option |
email | string | Valid email address |
url | string | Valid URL (any scheme) |
http_url | string | Valid HTTP/HTTPS URL |
ip / ipv4 / ipv6 | string | Valid IP address |
uuid | string | Valid UUID v1–v5 |
hostname | string | Valid hostname per RFC 1123 |
port | int, string | Valid port 1–65535 |
regex=pattern | string | Must match the regular expression |
len=N | string | Must be exactly N characters |
contains=str | string | Must contain the substring |
startswith=str | string | Must start with the prefix |
endswith=str | string | Must end with the suffix |
alpha | string | Letters only |
alphanum | string | Letters and digits only |
numeric | string | Digits only |
lowercase / uppercase | string | All lower- or uppercase |
notempty | string | Non-blank (non-whitespace) required |
Example — combining rules
type Config struct {
Port int `env:"PORT" validate:"required,port"`
AdminEmail string `env:"EMAIL" validate:"required,email"`
APIKey string `env:"API_KEY" validate:"required,len=32,alphanum" secret:"true"`
Environment string `env:"ENV" validate:"oneof=dev staging prod"`
ServiceURL string `env:"SVC_URL" validate:"http_url"`
ServiceID string `env:"SVC_ID" validate:"uuid"`
Region string `env:"REGION" validate:"regex=^[a-z]+-[a-z]+-[0-9]+$"`
}
Custom Validators
Register named validators per load call — no global state:
cfg, err := confkit.LoadWithOptions[Config](
confkit.WithSource(confkit.FromEnv()),
confkit.WithValidator("port-range", func(v reflect.Value) error {
n := v.Int()
if n < 1024 || n > 49151 {
return fmt.Errorf("must be a registered port (1024–49151), got %d", n)
}
return nil
},
)
// Use in tag: validate:"port-range"
Cross-Field Validation (Model Validators)
cfg, err := confkit.LoadWithOptions[TLSConfig](
confkit.WithSource(confkit.FromEnv()),
confkit.WithModelValidator(func(cfg *TLSConfig) error {
if cfg.Enabled && cfg.CertPath == "" {
return fmt.Errorf("tls_cert_path required when tls_enabled is true")
}
return nil
},
)
Programmatic Error Inspection
cfg, err := confkit.Load[Config](confkit.FromEnv())
if err != nil {
report := err.(*confkit.ErrorReport)
for _, fieldErr := range report.Errors {
fmt.Printf("Field: %s\n", fieldErr.Path)
fmt.Printf("Error: %s\n", fieldErr.Message)
fmt.Printf("Rule: %s\n", fieldErr.Rule)
fmt.Printf("Value: %s\n", fieldErr.Value) // empty if Secret=true
}
}
Secret Redaction
Mark any field with secret:"true" and its value will never appear in errors, config dumps, or logs.
Marking Secrets
type Config struct {
Username string `env:"DB_USER"`
Password string `env:"DB_PASSWORD" secret:"true"`
APIKey string `env:"API_KEY" secret:"true" validate:"required"`
}
In Error Messages
Invalid configuration:
APIKey
error: field is required
source: env (API_KEY)
value: <redacted> ← never shows actual value
Safe Config Dump
fields := confkit.ScanFields(cfg)
dump, _ := confkit.DumpConfig(cfg, fields)
log.Println(dump)
// {"Username":"admin","Password":"***REDACTED***","APIKey":"***REDACTED***"}
Safe Logging Practices
// ✅ Safe — secrets are redacted
if err != nil { log.Error(confkit.Explain(err)) }
fields := confkit.ScanFields(cfg)
dump, _ := confkit.DumpConfig(cfg, fields)
log.Info("Config loaded", "config", dump) // ✅ Safe
// ❌ Never do this — exposes secrets
log.Printf("Config: %+v", cfg)
confkit guarantees redaction in: error messages via Explain(), config dumps via DumpConfig(), and programmatic error inspection (FieldError.Value is empty for secrets).
Defaults
Defaults are applied after all sources are loaded — only if a field wasn't set by any source. They are the lowest priority.
Basic Defaults
type Config struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"8080"`
Timeout time.Duration `env:"TIMEOUT" default:"30s"`
Debug bool `env:"DEBUG" default:"false"`
}
Type Coercion
| Type | Example default | Notes |
| int / uint / float | default:"8080" | Decimal number |
| bool | default:"true" | true, false, yes, no, 1, 0 |
| time.Duration | default:"30s" | 5ms, 30s, 1m30s, 2h |
| string | default:"localhost" | As-is |
| []string / []int | default:"a,b,c" | Comma-separated |
Defaults & Validation
Defaults must satisfy validation rules. A default that violates validation is a programmer error — confkit will panic at startup to catch it early.
// ✅ Good — default passes validation
Port int `env:"PORT" default:"8080" validate:"min=1,max=65535"`
// ❌ Bad — panic at startup: default violates max=65535
Port int `env:"PORT" default:"99999" validate:"min=1,max=65535"`
Priority Order
- CLI flags (highest priority)
- Environment variables
- Config files (YAML / JSON / TOML)
- Defaults (lowest priority)
Hot Reload
Watch a config file for changes and reload without restarting your application.
Basic Usage
cfg, watcher, err := confkit.LoadWithWatcher[Config]("config.yaml",
confkit.FromEnv(),
confkit.FromYAML("config.yaml"),
)
if err != nil {
log.Fatal(err)
}
watcher.AddListener(func(oldCfg, newCfg any, err error) {
if err != nil {
log.Printf("Reload failed: %v", err)
return // old config is preserved on error
}
log.Println("Config reloaded")
cfg = newCfg.(Config)
})
watcher.SetPollInterval(2 * time.Second) // default: 500ms
watcher.Start()
defer watcher.Stop()
If a reload fails (invalid YAML, validation error), the old config is preserved and the listener receives a non-nil error.
Thread-Safe Access
type Server struct {
mu sync.RWMutex
cfg Config
}
func (s *Server) Config() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
watcher.AddListener(func(_, new any, err error) {
if err != nil { return }
s.mu.Lock()
s.cfg = new.(Config)
s.mu.Unlock()
})
Schema Generation
Generate JSON Schema, Markdown documentation, and CLI help text automatically from your struct.
JSON Schema
import "github.com/MimoJanra/confkit/schema"
type Config struct {
Port int `env:"PORT" default:"8080" validate:"min=1,max=65535" help:"Server port"`
Host string `env:"HOST" default:"localhost" help:"Server hostname"`
Database string `env:"DATABASE_URL" validate:"required" secret:"true" help:"Connection URL"`
}
s, err := schema.GenerateSchema[Config]()
data, _ := json.MarshalIndent(s, "", " ")
fmt.Println(string(data))
Markdown Reference Table
md := schema.GenerateMarkdown[Config]()
fmt.Println(md)
CLI Help Text
help := schema.GenerateCLIHelp[Config]()
fmt.Println(help)
String Interpolation
Values can reference other fields or env vars using ${NAME}. Resolution order: config fields first, then OS environment. Circular references are detected.
type Config struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"8080"`
BaseURL string `env:"BASE_URL" default:"http://${ HOST}:${ PORT}/api"`
}
// BaseURL → "http://localhost:8080/api" when no overrides
Supported Types
| Type | Parsed from |
string | as-is |
int / int8 / int16 / int32 / int64 | decimal |
uint / uint8 / uint16 / uint32 / uint64 | decimal |
float32 / float64 | decimal |
bool | true false 1 0 yes no |
time.Duration | "5s" "1m30s" "2h" |
[]string | comma-separated "a,b,c" |
[]int | comma-separated "1,2,3" |
map[string]string / map[string]int | KEY=val,KEY2=val2 |