The heartbeat from part 1 runs, ticks along, and shuts down politely when you ask it to. It also talks to absolutely no one. A service people can actually call needs an API, and for a typed, fast, streaming-capable one, gRPC is the obvious first move.
The catch is that a production-grade gRPC server is rather more than
grpc.NewServer(). You want health checks an orchestrator understands,
reflection so you can poke at it without the .proto file in hand, a graceful
shutdown that doesn’t guillotine calls that are still in flight, and TLS, which
is where most people’s first attempt quietly goes wrong. The good news: part 1
already gave us the thing that carries all of it. A gRPC server is just another
service to register on the controller.
About this copy. This tutorial lives in the go-tool-base docs, and what follows is a snapshot of that page as it stood on 1 November 2026, verified there against go-tool-base v0.50.0. The docs keep pace with the framework and this copy doesn’t, so if a step here stops matching the release you’re on, the docs page has the current version.
Updated 1 November 2026. This part first went out in May against v0.6.0, and the framework has moved a long way since, so the code that was here no longer ran as written. What follows replaces it.
By the end of this, mytool serve answers a gRPC API over TLS, with a health
service an orchestrator understands and reflection for poking at it. The only
code you write is the service itself. The server, its health checks, its
shutdown and its TLS come from the framework.
This is the second part of the web-service series. It builds on the serve
command from Run a long-lived service.
Allow about forty minutes. The go install steps download and build each tool
the first time, so they take a minute or two.
Before you start
- The
servecommand from Run a long-lived service, in themytoolproject. - The same gtb release. Everything below is verified against v0.50.0.
- mkcert, for the TLS step.
Install the protobuf tools
gRPC starts from a schema, a .proto file, and the Go code comes from that.
You don’t write it. buf runs the generators, and it
needs two plugins: protoc-gen-go for the message types, and
protoc-gen-go-grpc for the client and server stubs.
go install github.com/bufbuild/buf/cmd/buf@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
They land in $(go env GOPATH)/bin, so make sure that’s on your $PATH, and
check they run:
buf --version
protoc-gen-go --version
protoc-gen-go-grpc --version
1.75.0
protoc-gen-go v1.36.12
protoc-gen-go-grpc 1.6.2
Newer versions are fine.
Define the contract
The service looks after macguffins: named things with a quantity. Create
proto/macguffin/v1/macguffin.proto:
syntax = "proto3";
package macguffin.v1;
option go_package = "github.com/myorg/mytool/internal/gen/macguffin/v1;macguffinv1";
service MacguffinService {
rpc GetMacguffin(GetMacguffinRequest) returns (Macguffin);
rpc ListMacguffins(ListMacguffinsRequest) returns (ListMacguffinsResponse);
rpc CreateMacguffin(CreateMacguffinRequest) returns (Macguffin);
}
message Macguffin {
string id = 1;
string name = 2;
int32 quantity = 3;
}
message GetMacguffinRequest { string id = 1; }
message ListMacguffinsRequest { int32 page_size = 1; }
message ListMacguffinsResponse { repeated Macguffin macguffins = 1; }
message CreateMacguffinRequest { string name = 1; int32 quantity = 2; }
The go_package path starts with your module path, from the first line of
go.mod. If yours isn’t github.com/myorg/mytool, change it here and in the
imports below.
Generate the Go code
buf needs two small files at the project root. buf.yaml says where the
protos live:
version: v2
modules:
- path: proto
buf.gen.yaml says what to generate, and where:
version: v2
plugins:
- local: protoc-gen-go
out: internal/gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: internal/gen
opt: paths=source_relative
Don’t leave buf.yaml out. Without it, buf treats the whole project as the
module, and the code lands in internal/gen/proto/macguffin/v1 instead of
matching the go_package line.
Then hook buf into go generate, so the code can’t drift from the proto. Create
gen.go at the project root:
package mytool
//go:generate buf generate
just build already runs go generate ./... before it compiles, so from now on
every build regenerates the code from the proto. That means buf has to be on
your $PATH whenever you build. Run it once by hand to check:
go generate ./...
find internal/gen -type f
internal/gen/macguffin/v1/macguffin_grpc.pb.go
internal/gen/macguffin/v1/macguffin.pb.go
macguffin.pb.go holds the message types. macguffin_grpc.pb.go holds the
MacguffinServiceServer interface you implement next, with one method per RPC.
Keep the data out of the transport
The gRPC server is how requests arrive, not where the data lives. Later parts
put an HTTP API on the same data, so keep it in a type of its own that knows
nothing about gRPC. Create internal/macguffin/store.go, an in-memory store
standing in for a real database:
package macguffin
import (
"fmt"
"sort"
"sync"
)
type Macguffin struct {
ID string `json:"id"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
}
type Store struct {
mu sync.Mutex
items map[string]Macguffin
seq int
}
func NewStore() *Store {
s := &Store{items: map[string]Macguffin{}}
s.Create("maltese-falcon", 1)
return s
}
func (s *Store) Get(id string) (Macguffin, bool) {
s.mu.Lock()
defer s.mu.Unlock()
m, ok := s.items[id]
return m, ok
}
func (s *Store) List() []Macguffin {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Macguffin, 0, len(s.items))
for _, m := range s.items {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *Store) Create(name string, quantity int32) Macguffin {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
m := Macguffin{ID: fmt.Sprintf("m-%d", s.seq), Name: name, Quantity: quantity}
s.items[m.ID] = m
return m
}
It starts with one macguffin, m-1.
Implement the service
Now the gRPC side, kept thin: each method calls the store and converts the
result to the generated type. Create internal/grpcsvc/server.go:
package grpcsvc
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
macguffinv1 "github.com/myorg/mytool/internal/gen/macguffin/v1"
"github.com/myorg/mytool/internal/macguffin"
)
type Server struct {
macguffinv1.UnimplementedMacguffinServiceServer
store *macguffin.Store
}
func New(store *macguffin.Store) *Server {
return &Server{store: store}
}
func (s *Server) GetMacguffin(_ context.Context, req *macguffinv1.GetMacguffinRequest) (*macguffinv1.Macguffin, error) {
m, ok := s.store.Get(req.GetId())
if !ok {
return nil, status.Errorf(codes.NotFound, "macguffin %q not found", req.GetId())
}
return toProto(m), nil
}
func (s *Server) ListMacguffins(_ context.Context, _ *macguffinv1.ListMacguffinsRequest) (*macguffinv1.ListMacguffinsResponse, error) {
items := s.store.List()
resp := &macguffinv1.ListMacguffinsResponse{Macguffins: make([]*macguffinv1.Macguffin, 0, len(items))}
for _, m := range items {
resp.Macguffins = append(resp.Macguffins, toProto(m))
}
return resp, nil
}
func (s *Server) CreateMacguffin(_ context.Context, req *macguffinv1.CreateMacguffinRequest) (*macguffinv1.Macguffin, error) {
if req.GetName() == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
return toProto(s.store.Create(req.GetName(), req.GetQuantity())), nil
}
func toProto(m macguffin.Macguffin) *macguffinv1.Macguffin {
return &macguffinv1.Macguffin{Id: m.ID, Name: m.Name, Quantity: m.Quantity}
}
Embedding UnimplementedMacguffinServiceServer is required by the generated
interface. It also means that if you add an RPC to the proto later, the server
still compiles, and the new call answers “unimplemented” until you write it.
Return gRPC status codes such as codes.NotFound rather than plain errors, so a
caller gets something it can branch on.
Run it on the controller
Replace pkg/cmd/serve/main.go. The heartbeat from part 1 goes, and the gRPC
server takes its place on the same controller:
package serve
import (
"context"
gtbgrpc "gitlab.com/phpboyscout/go-tool-base/pkg/grpc"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
"gitlab.com/phpboyscout/go-tool-base/pkg/props"
"gitlab.com/phpboyscout/go/controls"
"gitlab.com/phpboyscout/go/errors"
macguffinv1 "github.com/myorg/mytool/internal/gen/macguffin/v1"
"github.com/myorg/mytool/internal/grpcsvc"
"github.com/myorg/mytool/internal/macguffin"
)
func RunServe(ctx context.Context, props *props.Props, _ *ServeOptions, _ []string) error {
controller := controls.NewController(ctx,
controls.WithLogger(logger.ToSlog(props.Logger)),
controls.WithStopOnGiveUp(),
)
store := macguffin.NewStore()
grpcSrv, err := gtbgrpc.RegisterFromReader(ctx, "grpc", controller, props.Config.View(), props.Logger)
if err != nil {
return err
}
macguffinv1.RegisterMacguffinServiceServer(grpcSrv, grpcsvc.New(store))
controller.Start()
<-controller.Done()
outcome, _ := controller.Outcome()
if outcome.Cause == controls.CauseGaveUp {
return errors.Wrapf(outcome.Err, "service %s gave up", outcome.Service)
}
return nil
}
RegisterFromReader builds the gRPC server from the server.grpc config block.
It registers the standard gRPC health service, backed by the controller’s health
reports, and registers the server’s start, stop and status on the controller as
a service called grpc. Then it hands the server back, for you to register your
own service on.
The project’s imports go in a group of their own, after the framework’s. The scaffold’s linter enforces that grouping.
Give it a port
With no config, the server listens on a random free port. Give serve shipped
defaults, the way hello got them in Configure your CLI:
gtb generate command --name serve --short "Run the service until stopped" --assets
Then put this in pkg/cmd/serve/assets/config.yaml:
server:
grpc:
port: 50051
reflection: true
Reflection lets a client ask the server what it offers, so you don’t need the
.proto to hand. Turn it off in production, because it publishes your whole API
surface.
Build and run it:
go mod tidy
just build
./bin/mytool serve
INFO starting gRPC server tls=false addr=[::]:50051
Call it with grpcurl
Leave the server running and use a second terminal. grpcurl is curl for gRPC:
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
grpcurl -plaintext localhost:50051 list
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
macguffin.v1.MacguffinService
That’s your service, plus health and reflection, which you didn’t register. Call it:
grpcurl -plaintext localhost:50051 macguffin.v1.MacguffinService/ListMacguffins
grpcurl -plaintext -d '{"name":"sankara-stone","quantity":5}' localhost:50051 macguffin.v1.MacguffinService/CreateMacguffin
grpcurl -plaintext -d '{"id":"m-9"}' localhost:50051 macguffin.v1.MacguffinService/GetMacguffin
{
"macguffins": [
{
"id": "m-1",
"name": "maltese-falcon",
"quantity": 1
}
]
}
{
"id": "m-2",
"name": "sankara-stone",
"quantity": 5
}
ERROR:
Code: NotFound
Message: macguffin "m-9" not found
And the health service:
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
{
"status": "SERVING"
}
That answer comes from the controller. A SIGTERM still drains and stops the server, the same way it stopped the heartbeat:
INFO Stopping Services component=controller cause=parent
INFO Stopping gRPC server
INFO Stopped component=controller
Switch to TLS
Plaintext is fine on your laptop and nowhere else. Turning on TLS is a config change.
You need a certificate a client trusts. mkcert creates a local certificate authority and signs certificates from it. Run this once to create the authority and add it to your system’s trust store (it may ask for your password):
mkcert -install
Then, in the project directory, make a certificate for the names the server answers on:
mkcert localhost 127.0.0.1 ::1
That writes localhost+2.pem, the certificate, and localhost+2-key.pem, its
key. Don’t commit either of them.
Point the server at them in your own config file, ~/.mytool/config.yaml.
init already wrote a server: key there, so add tls: under it rather than
starting a second server: block, which isn’t valid YAML:
server:
grpc:
reflection: true
tls:
enabled: true
cert: ./localhost+2.pem
key: ./localhost+2-key.pem
The paths are relative to where you run the tool, so run it from the project directory. Restart it:
./bin/mytool serve
INFO starting gRPC server tls=true addr=[::]:50051
Call it over TLS. -cacert names mkcert’s authority explicitly, which works
whether or not your system trust store picked it up:
grpcurl -cacert "$(mkcert -CAROOT)/rootCA.pem" -d '{"id":"m-1"}' localhost:50051 macguffin.v1.MacguffinService/GetMacguffin
{
"id": "m-1",
"name": "maltese-falcon",
"quantity": 1
}
A plaintext client is now turned away:
grpcurl -plaintext localhost:50051 list
Failed to dial target host "localhost:50051": read tcp 127.0.0.1:35754->127.0.0.1:50051: read: connection reset by peer
So is a client that doesn’t trust the certificate’s authority, with
x509: certificate signed by unknown authority.
The certificate sits under server.tls, not server.grpc.tls, on purpose. Every
transport falls back to the shared block, so the HTTP server in the next part
uses the same certificate. A transport gets its own only when you set one under
its own block.
The server also advertises HTTP/2 over ALPN (the h2 protocol), and current
gRPC clients refuse a TLS connection that doesn’t offer it. You can see it with
openssl s_client -connect localhost:50051 -alpn h2, which reports
ALPN protocol: h2.
Where this leaves you
mytool serve is a real gRPC service: a typed contract, a thin implementation
over a store, health that an orchestrator understands, reflection for
development, a clean shutdown, and TLS. The part that was yours to write was the
service logic.
What to read next
- Add a gRPC service: custom server options, interceptors, and wiring the lifecycle by hand.
- gRPC component: what the config adapter reads, and how TLS resolves.
- TLS component: the hardened defaults and the per-transport overrides.





