A CLI does its job and gets out of the way. You run it, it prints something or writes a file, the process exits, done. Then one day you want the opposite: a thing that stays running. A server answering requests, a worker chewing through a queue, something that sits there until you tell it to stop. And the moment a process is long-lived, a pile of fiddly problems lands on your desk that a short-lived command never had to think about.
How does it shut down when Kubernetes sends it a SIGTERM, without dropping the requests it’s halfway through? How does anything outside it know it’s alive, or ready for traffic? When one part falls over at 3am, does the whole thing come down, or pick itself back up? None of that is your actual service. It’s the plumbing around it, and it’s the sort of plumbing that’s easy to write almost right and only notice the gap in during an incident.
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 runs a service that stays up until something
stops it. Ctrl-C or a SIGTERM shuts it down in order, and a service that keeps
failing ends the process with an exit code an orchestrator can act on. You
won’t write any signal handling yourself.
This is the first part of the web-service series. Later parts put gRPC, HTTP and a gateway on the same command, and every one of them relies on what you set up here.
Allow about twenty minutes.
Before you start
- The
mytoolproject from Build your first CLI. Any project generated by gtb works the same way. - The same gtb release. Everything below is verified against v0.50.0.
Add the command
A long-running process is still a command. Generate one:
gtb generate command --name serve --short "Run the service until stopped"
As with hello, that writes a generated pkg/cmd/serve/cmd.go and a
pkg/cmd/serve/main.go for you to fill in.
Run a service under a controller
The services run under a controller from the
go/controls module. You register each
service with three things: how to start it, how to stop it, and how to tell
whether it’s healthy. The controller starts them, stops them in order when the
command’s context ends, and keeps track of their health.
Replace pkg/cmd/serve/main.go with a controller running one service, a
heartbeat that logs a line every second:
package serve
import (
"context"
"sync/atomic"
"time"
"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"
)
const beatInterval = time.Second
func RunServe(ctx context.Context, props *props.Props, _ *ServeOptions, _ []string) error {
controller := controls.NewController(ctx,
controls.WithLogger(logger.ToSlog(props.Logger)),
controls.WithStopOnGiveUp(),
)
registerHeartbeat(controller, props)
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
}
func registerHeartbeat(controller *controls.Controller, props *props.Props) {
var beats atomic.Int64
controller.Register("heartbeat",
controls.WithStart(func(ctx context.Context) error {
ticker := time.NewTicker(beatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
props.Logger.Info("beat", "count", beats.Add(1))
}
}
}),
controls.WithStop(func(context.Context) {
props.Logger.Info("heartbeat stopping", "total_beats", beats.Load())
}),
controls.WithStatus(func() error { return nil }),
)
}
The three options are the service’s three verbs:
WithStartruns the heartbeat. It blocks for as long as the service runs, and returnsnilwhen its context ends.WithStopruns during shutdown, for cleanup.WithStatusanswers “is it healthy?”.nilmeans yes.
ctx is the command’s own context. The controller is built on it, so when the
command is told to stop, the controller stops too. controller.Done() closes
once everything has stopped, and Outcome() says why.
WithStopOnGiveUp comes into play in the last step.
main.go now imports go/controls directly, so tidy the module before you
build:
go mod tidy
just build
./bin/mytool serve
INFO beat count=1
INFO beat count=2
INFO beat count=3
It keeps going until you stop it.
Shut it down cleanly
Press Ctrl-C, or send the process a SIGTERM the way Kubernetes or systemd would:
WARN received signal — shutting down gracefully (press again to force quit) signal=terminated
INFO Stopping Services component=controller cause=parent
INFO heartbeat stopping total_beats=3
INFO Stopped component=controller
Nothing you wrote handles a signal. The root command gtb generated catches
SIGINT and SIGTERM and cancels the command’s context. The controller sees its
parent context end (cause=parent), cancels the context every service’s start
is watching, runs each WithStop, and returns. A second Ctrl-C forces the
process to quit straight away, for a service that’s stuck.
That order (cancel first, then stop) is what lets an HTTP server finish the requests it’s in the middle of when the pod rolls. You get it for the heartbeat now, and for every transport you add later.
Don’t add controls.WithSignals() to the controller. The root already owns the
signals, and a second handler would race it on a single Ctrl-C.
Check the exit code after a SIGTERM:
./bin/mytool serve &
kill -TERM $!
wait $!; echo $?
143
That’s 128 plus the signal’s number (15 for SIGTERM, 2 for SIGINT, so 130). It’s the code a shell or a process manager expects from a process a signal stopped.
Give up on a service that keeps failing
A service that fails is restarted, if its restart policy allows. Once the
restarts run out, the service gives up. WithStopOnGiveUp makes that stop the
controller, so the command ends and the tool exits non-zero. Without it, the
process would stay up but report itself not ready.
Make the heartbeat fail on every third beat, and allow two restarts. Add two constants:
const (
beatInterval = time.Second
missEvery = 3
maxRestarts = 2
)
Then return an error from the ticker case, and add a restart policy beside
WithStatus:
case <-ticker.C:
count := beats.Add(1)
if count%missEvery == 0 {
return errors.Newf("missed beat %d", count)
}
props.Logger.Info("beat", "count", count)
}
controls.WithStatus(func() error { return nil }),
controls.WithRestartPolicy(controls.RestartPolicy{MaxRestarts: maxRestarts}),
The scaffold’s linter rejects bare numbers like 3 in code, which is why
they’re named.
Build and run it, and leave it alone for about ten seconds:
just build
./bin/mytool serve; echo "exit $?"
INFO beat count=1
INFO beat count=2
ERROR service failed; restarting component=controller service_name=heartbeat kind=retrying restarts=1 error="missed beat 3"
INFO beat count=4
INFO beat count=5
ERROR service failed; restarting component=controller service_name=heartbeat kind=retrying restarts=2 error="missed beat 6"
INFO beat count=7
INFO beat count=8
ERROR a registered service will never start; the controller is now reporting unready component=controller service_name=heartbeat error="max restarts exceeded: missed beat 9"
ERROR service unable to start component=controller service_name=heartbeat kind=unable_to_start restarts=2 error="max restarts exceeded: missed beat 9"
INFO Stopping Services component=controller cause=gave_up
INFO heartbeat stopping total_beats=9
INFO Stopped component=controller
ERROR service heartbeat gave up: max restarts exceeded: missed beat 9
exit 1
Two restarts, then a give-up (cause=gave_up). RunServe turned that outcome
into an error, and the root reported it and exited 1. That’s the code systemd’s
Restart=on-failure, or a pod’s restart policy, acts on.
Take the failure back out before you carry on: delete the count%missEvery
check and the restart policy, and set the ticker case back to logging every
beat.
Where this leaves you
mytool serve runs a service until it’s told to stop, stops it in order, and
says through its exit code how it ended. The heartbeat is a placeholder. Every
service the next parts add registers on this same controller in the same way,
and gets the same shutdown order without any extra work.
The controller also tracks liveness and readiness separately from status, which is what health endpoints read. That gets used once there’s a transport to serve them from.
What to read next
- Manage background services: servers, workers, failure events and readiness, in one guide.
- Controls component: how gtb uses the module, and why the root owns the signals.
- controls.go.phpboyscout.uk: the full controller API.





