Skip to content
Set up typed configuration

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

ToolPurposeInstall
Go 1.25+Build and run the servicemise use -g go@1.26
bufGenerate Go code from protobufmise use -g buf
A Go serviceThe app you’re adding config toGet 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.validate annotations — min_len, required, the port range, uri — are enforced by protovalidate every time the file is loaded. Nobody can construct a service with port 99999 and find out at bind time.
  • Optional sections are just optional fields. upstream has no required annotation, so environments that don’t need it omit the whole section.
  • The schema is versioned. The version field lets v2/config drive 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:
    - FILE

buf.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_relative

Run the generator:

buf dep update
buf generate

You 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:9090

Field 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: 99999

Run 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

Last updated on