Set up typed configuration
LabKit v2/config
helps you set up and use a protobuf-defined, validated configuration for your Go
application.
Instead of scattering os.Getenv calls and hand-rolled YAML structs through your
code, you define the configuration schema once in protobuf, attach validation
rules to it, and let LabKit parse and validate the file at startup. A bad
config fails the process immediately, with an error naming the exact field.
Every step below mirrors the Go service template example. Each section links to the corresponding file there, so you can compare your work against finished, reviewed code.
Prerequisites
| Tool | Purpose | Install |
|---|---|---|
| Go 1.25+ | Build and run the service | mise use -g go@1.26 |
| buf | Generate Go code from protobuf | mise use -g buf |
| A Go service | The app you’re adding config to | Get Started |
Step 1: Define the schema in protobuf
Configuration starts as a .proto file, not a Go struct. Create
proto/config/v1/config.proto:
edition = "2023";
package myservice.config.v1;
option go_package = "gitlab.com/gitlab-org/ops/my-service/internal/config/v1;configv1";
option features.field_presence = IMPLICIT;
import "buf/validate/validate.proto";
// Config is the configuration schema, loaded from config.yaml at startup
// by LabKit v2/config and validated with protovalidate.
message Config {
// version identifies the schema version. LabKit v2/config reads it to
// drive typed migrations if the schema ever evolves to a version 2.
int32 version = 1;
string name = 2 [(buf.validate.field).string.min_len = 1];
ServerConfig server = 3 [(buf.validate.field).required = true];
// upstream is optional: environments without an upstream dependency
// omit the section entirely. When present, base_url must be a valid URI.
UpstreamConfig upstream = 4;
}
message ServerConfig {
int32 port = 1 [(buf.validate.field).int32 = {gte: 1, lte: 65535}];
}
message UpstreamConfig {
string base_url = 1 [(buf.validate.field).string.uri = true];
}Three things to notice:
- Validation lives in the schema. The
buf.validateannotations —min_len,required, the port range,uri— are enforced by protovalidate every time the file is loaded. Nobody can construct a service with port99999and find out at bind time. - Optional sections are just optional fields.
upstreamhas norequiredannotation, so environments that don’t need it omit the whole section. - The schema is versioned. The
versionfield letsv2/configdrive typed migrations if you ever need a breaking schema change.
Compare with the exemplar’s
proto/config/v1/config.proto,
which adds feature-flag and downstream-service sections using exactly these
patterns. Note what it deliberately leaves out: PostgreSQL connection details
are not application configuration — they arrive through the Fairway-delivered
infrastructure config via v2/postgres instead.
Step 2: Generate the Go types with buf
Two small files at the repository root wire up code generation. buf.yaml
declares the module and its protovalidate dependency:
version: v2
modules:
- path: proto
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- STANDARD
except:
# Our proto packages live under config/{v1} rather than
# myservice/config/{v1}.
- PACKAGE_DIRECTORY_MATCH
breaking:
use:
- FILEbuf.gen.yaml generates Go code into internal/:
version: v2
inputs:
- directory: proto
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.5
out: internal
opt:
- paths=source_relativeRun the generator:
buf dep update
buf generateYou now have internal/config/v1/config.pb.go — the typed Config struct your
application code will use. Commit the generated file; consumers of your module
should not need buf installed. (Exemplar:
buf.yaml,
buf.gen.yaml.)
Step 3: Write the local config file
Create config/local.yaml, the profile your service reads when you run it
directly on your machine:
# Local-dev configuration profile, loaded at startup by LabKit v2/config.
# Schema: proto/config/v1/config.proto (protovalidate rules enforced on load).
version: 1
name: my-service
server:
port: 4000
upstream:
base_url: http://localhost:9090Field names use snake_case, matching the proto definitions. (Exemplar:
config/local.yaml.)
Step 4: Load and validate at startup
In your composition root (main.go or cmd/server/main.go), load the file
into the generated type before anything else starts:
import (
labkitconfig "gitlab.com/gitlab-org/labkit/v2/config"
configv1 "gitlab.com/gitlab-org/ops/my-service/internal/config/v1"
)
configPath := resolveConfigPath()
loader, err := labkitconfig.New()
if err != nil {
return fmt.Errorf("create config loader: %w", err)
}
var cfg configv1.Config
err = loader.Load(configPath, &cfg)
if err != nil {
return fmt.Errorf("load config %q: %w", configPath, err)
}resolveConfigPath gives the file a consistent lookup order across
environments:
// resolveConfigPath picks the configuration file for this process.
// Precedence: CONFIG_DIR (set by the fairway-generated chart when
// spec.values.appConfig is present) over CONFIG_PATH (the Dockerfile
// default) over the bare-host local profile.
func resolveConfigPath() string {
if dir := os.Getenv("CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "config.yaml")
}
if path := os.Getenv("CONFIG_PATH"); path != "" {
return path
}
return "config/local.yaml"
}From here on, configuration access is typed method calls — no string keys, no type assertions:
srv := transport.New(a, domainSvc, cfg.GetServer().GetPort())
upstreamURL := cfg.GetUpstream().GetBaseUrl()Getters on absent optional sections return zero values rather than panicking,
so cfg.GetUpstream().GetBaseUrl() is safe even when upstream: is omitted.
(Exemplar:
cmd/server/main.go.)
Step 5: Watch validation catch a mistake
Prove the schema is doing its job. Edit config/local.yaml and set an
impossible port:
server:
port: 99999Run the service. Startup fails immediately with a validation error that names the field and the violated rule — port values must be between 1 and 65535 — before the process binds a socket or touches a dependency. Put the real value back and the service starts again.
This is the core promise of v2/config: misconfiguration is a startup failure
with a precise error, not a runtime surprise.
Step 6: Guard committed configs in CI with strict mode
Runtime loading is deliberately permissive about unknown fields (so an old binary can roll back safely against a newer config), but you want typos in committed config files caught in CI. LabKit’s strict mode rejects unknown fields; a small test applies it to every committed profile:
func TestCommittedConfigsPassStrictMode(t *testing.T) {
t.Parallel()
loader, err := config.New(config.WithStrictMode())
if err != nil {
t.Fatalf("config.New: %v", err)
}
var cfg configv1.Config
err = loader.Load(filepath.Join("..", "..", "config", "local.yaml"), &cfg)
if err != nil {
t.Errorf("strict load failed: %v", err)
}
}Now a misspelled key — prot: instead of port: — fails the pipeline instead
of silently falling back to a zero value. The exemplar goes one step further
and strict-loads the deployed config embedded in fairway.yaml too, since
Fairway treats that block as an opaque tree and performs no schema validation
of its own. (Exemplar:
internal/config/strict_test.go,
internal/config/appconfig_test.go.)
Where deployed configuration comes from
Locally your service reads config/local.yaml. In a cluster, the
Fairway-generated chart mounts the config declared under
spec.values.appConfig.data in fairway.yaml and sets CONFIG_DIR to point
at it — the same schema, validated by the same rules, delivered per
environment. That workflow has its own guide:
Pass application configuration via Fairway.
What’s next
- Pass application configuration via Fairway — ship per-environment config to your deployed service
- Modify service values and secrets — secrets belong in
v2/secret, not in config files - Go service template exemplar — the complete reference this tutorial mirrors
- LabKit — everything else
v2adoption gives you