Configure a service from Consul¶
By the end of this tutorial you will have a program that loads its configuration from a
running HashiCorp Consul, reads typed values from it, reacts when a value changes in Consul
without restarting, and writes a value back, all through the same config store a file uses.
Unlike Getting started, this one needs one external service: a Consul agent, which you will run locally in development mode. Work through it top to bottom; you run the program after each step.
Prerequisites¶
- Go 1.27.1 or newer.
- The Consul binary on your
PATH.
1. Start Consul¶
In its own terminal, run a throwaway single-node agent that keeps its data in memory:
Leave it running. Its HTTP API is now on 127.0.0.1:8500, which is the default the SDK uses, so you
will not have to configure an address.
2. Seed some configuration¶
In a second terminal, put a few keys under an app/ prefix. Consul keys are paths; the adapter
will turn the slashes into a nested tree:
consul kv put app/server/host localhost
consul kv put app/server/port 8080
consul kv put app/log/level info
3. Create a module¶
mkdir consuldemo && cd consuldemo
go mod init consuldemo
go get gitlab.com/phpboyscout/go/config
go get gitlab.com/phpboyscout/go/config-consul
go get github.com/hashicorp/consul/api/v2
You build the Consul client yourself and hand it to the adapter, so every address, token and TLS decision stays yours. The adapter never reaches for credentials.
4. Read from Consul¶
Create main.go:
package main
import (
"context"
"fmt"
"log"
capi "github.com/hashicorp/consul/api/v2"
"gitlab.com/phpboyscout/go/config"
configconsul "gitlab.com/phpboyscout/go/config-consul"
)
func main() {
client, err := capi.NewClient(capi.DefaultConfig()) // 127.0.0.1:8500 by default
if err != nil {
log.Fatal(err)
}
store, err := config.NewStore(context.Background(),
config.WithBackend(configconsul.FromClient(client, "app/")),
)
if err != nil {
log.Fatal(err)
}
view := store.View()
fmt.Println("host:", view.GetString("server.host"))
fmt.Println("port:", view.GetInt("server.port"))
// Provenance names the source, so you can always answer "where did this come from?".
if src, ok := view.Origin("server.port"); ok {
fmt.Println("port came from:", src) // consul:app/
}
}
Run it:
The prefix app/ was stripped and the remaining path split into a tree, so app/server/port
reads as server.port. Consul stores bytes, so "8080" is a string. GetInt coerces it, the
same as it would for an environment variable.
5. Merge Consul over defaults¶
A Consul layer is just a layer: it takes part in precedence and per-key merge like any file. Add
compiled-in defaults beneath it, so Consul overrides them key by key. Change NewStore:
store, err := config.NewStore(context.Background(),
config.WithReaders(config.NamedSource{
Name: "embedded:defaults.yaml",
Content: []byte("server:\n port: 1234\n scheme: http\n"),
}),
config.WithBackend(configconsul.FromClient(client, "app/")), // outranks the defaults
)
Run again: server.port is still 8080 (Consul wins), but server.scheme is http. The
default survives, because merging is per-key, not layer-replace.
6. React to a change, without restarting¶
Ask the store to watch. The adapter turns this into a Consul blocking query, so a change in Consul returns the query at once instead of waiting for a timer. Before the program would exit, add a watch and a block:
store.AddObserverFunc(func(cfg config.Observed) error {
fmt.Println("reloaded: port is now", cfg.GetInt("server.port"))
return nil
})
stop, err := store.Watch(context.Background())
if err != nil {
log.Fatal(err)
}
defer stop()
fmt.Println("watching; change app/server/port in another terminal…")
select {} // block forever
Run it, then in your second terminal:
The program prints, within moments and without a restart:
7. Write a value back¶
The store can write to Consul too. A write becomes one atomic compare-and-swap transaction, and
if Consul moved since the store loaded it, the write is refused with config.ErrConflict rather
than clobbering the change. Replace the select {} block with:
if _, err := store.Apply(context.Background(), config.Set("log.level", "debug")); err != nil {
log.Fatal(err)
}
fmt.Println("level is now", store.View().GetString("log.level"))
Run it, then confirm the value reached Consul itself:
8. Read a document-valued key¶
Some Consul deployments store a whole JSON document under one key rather than many flat keys. Put one:
By default that value is a single string. To decode it into a subtree, give the backend a value
codec: any config.Codec, such as the one from config-json:
import configjson "gitlab.com/phpboyscout/go/config-json"
config.WithBackend(configconsul.FromClient(client, "app/",
configconsul.WithValueCodec(configjson.Codec{})))
Now store.View().GetString("db.host") is "db.internal" and GetInt("db.port") is 5432.
A key whose value is a bare scalar (like log/level) still reads as a string, so a prefix mixing
both styles works.
Where to go next¶
- Read and write Consul: the task-focused reference for each of these operations
- How the Consul backend works: the data model, the conflict model and the watch mechanism, and why they are the way they are
- React to changes with hot-reload: the observer contract in depth