gRPC is an RPC framework that uses Protocol Buffers to define services and HTTP/2 to carry calls, which gives you typed contracts, compact messages and streaming between services. In this tutorial you will define a small user service in a .proto file, generate Go code, build a server with the standard gRPC health service, run two instances under systemd, and publish them behind Nginx with a Let's Encrypt certificate and load balancing on Ubuntu 24.04.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
- A non-root user with
sudoprivileges. - A domain name with an A record pointing to the server. This guide uses
grpc.your_domain; replace it with your own hostname everywhere. - Ports 80 and 443 open to the Internet. Port 80 is only used by Let's Encrypt to validate and renew the certificate.
Step 1 - Installing Go and the Protocol Buffers compiler
The current google.golang.org/grpc module requires a recent Go release, newer than the golang-go package in Ubuntu 24.04, so install Go from the official tarball. The first command reads the latest stable version number from go.dev:
GO_VERSION=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n1)
curl -fsSLO "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "${GO_VERSION}.linux-amd64.tar.gz"
On an arm64 server, replace linux-amd64 with linux-arm64. Add Go and the directory where go install places binaries to your PATH:
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.profile
source ~/.profile
Install the Protocol Buffers compiler (protoc) from the Ubuntu repositories, then the two Go plugins that generate message types and gRPC stubs:
sudo apt update
sudo apt install -y protobuf-compiler
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Confirm that all the tools are on your PATH:
go version
protoc --version
which protoc-gen-go protoc-gen-go-grpc
go version go1.27.1 linux/amd64
libprotoc 3.21.12
/home/your_user/go/bin/protoc-gen-go
/home/your_user/go/bin/protoc-gen-go-grpc
Your version numbers may be newer.
Step 2 - Defining the service
A gRPC API starts with a .proto file that describes the service methods and message types. Create a project directory with a folder for the definitions:
mkdir -p ~/usersvc/proto ~/usersvc/cmd/server
cd ~/usersvc
go mod init example.com/usersvc
Create the service definition:
nano proto/user.proto
syntax = "proto3";
package user.v1;
option go_package = "example.com/usersvc/gen/userpb;userpb";
service UserService {
// Unary RPC: one request, one response.
rpc GetUser(GetUserRequest) returns (User);
// Server streaming RPC: one request, a stream of responses.
rpc ListUsers(ListUsersRequest) returns (stream User);
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
}
The package user.v1 line becomes part of every method path (for example /user.v1.UserService/GetUser), which is what Nginx will route on later. Versioning the package lets you ship a user.v2 alongside it without breaking existing clients.
Step 3 - Generating the Go code
Run protoc with both plugins. paths=source_relative writes the files straight into gen/userpb instead of recreating the full import path:
mkdir -p gen/userpb
protoc -I proto \
--go_out=gen/userpb --go_opt=paths=source_relative \
--go-grpc_out=gen/userpb --go-grpc_opt=paths=source_relative \
proto/user.proto
Check that two files were generated:
ls gen/userpb
user.pb.go user_grpc.pb.go
user.pb.go contains the message types and user_grpc.pb.go the client and server interfaces. Regenerate them every time you change the .proto file.
Step 4 - Writing the server
The server implements the two methods, registers the standard grpc.health.v1.Health service used by load balancers and probes, and stops gracefully on SIGTERM so systemd restarts do not cut in-flight calls. Create the main file:
nano cmd/server/main.go
package main
import (
"context"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
userpb "example.com/usersvc/gen/userpb"
)
type userServer struct {
userpb.UnimplementedUserServiceServer
users map[string]*userpb.User
}
func (s *userServer) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*userpb.User, error) {
u, ok := s.users[req.GetId()]
if !ok {
return nil, status.Errorf(codes.NotFound, "user %q not found", req.GetId())
}
return u, nil
}
func (s *userServer) ListUsers(req *userpb.ListUsersRequest, stream userpb.UserService_ListUsersServer) error {
for _, u := range s.users {
if err := stream.Send(u); err != nil {
return err
}
}
return nil
}
func main() {
addr := os.Getenv("GRPC_ADDR")
if addr == "" {
addr = "127.0.0.1:50051"
}
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("failed to listen on %s: %v", addr, err)
}
srv := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute,
MaxConnectionAge: 30 * time.Minute,
MaxConnectionAgeGrace: 30 * time.Second,
}),
grpc.MaxConcurrentStreams(1000),
)
userpb.RegisterUserServiceServer(srv, &userServer{
users: map[string]*userpb.User{
"1": {Id: "1", Name: "Alice", Email: "[email protected]"},
"2": {Id: "2", Name: "Bob", Email: "[email protected]"},
},
})
hs := health.NewServer()
hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
hs.SetServingStatus("user.v1.UserService", healthpb.HealthCheckResponse_SERVING)
healthpb.RegisterHealthServer(srv, hs)
if os.Getenv("GRPC_REFLECTION") == "true" {
reflection.Register(srv)
}
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down")
hs.Shutdown()
srv.GracefulStop()
}()
log.Printf("gRPC server listening on %s", addr)
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
A few details matter in production:
- Embedding
UnimplementedUserServiceServerkeeps the server compiling when you add methods to the.protofile; unimplemented methods return theUnimplementedstatus. MaxConnectionAgemakes clients reconnect every 30 minutes. gRPC multiplexes all calls on one long-lived HTTP/2 connection, so without it new backend instances would receive little traffic.hs.Shutdown()marks the serviceNOT_SERVINGbeforeGracefulStop()waits for running calls to finish.- Server reflection lets tools discover the API without the
.protofile. It is only enabled whenGRPC_REFLECTION=true.
Download the dependencies and build a static binary:
go mod tidy
CGO_ENABLED=0 go build -o bin/usersvc ./cmd/server
The build prints nothing when it succeeds, and bin/usersvc appears in the project directory.
Step 5 - Testing the server with grpcurl
grpcurl is the gRPC equivalent of curl. Install it, along with grpc-health-probe, which you will use in Step 7:
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
go install github.com/grpc-ecosystem/grpc-health-probe@latest
Start the server in the background with reflection enabled:
GRPC_REFLECTION=true ./bin/usersvc &
List the services the server exposes:
grpcurl -plaintext 127.0.0.1:50051 list
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
user.v1.UserService
Call the unary method. Request fields are written as JSON:
grpcurl -plaintext -d '{"id": "1"}' 127.0.0.1:50051 user.v1.UserService/GetUser
{
"id": "1",
"name": "Alice",
"email": "[email protected]"
}
A missing user returns a proper gRPC status instead of an empty message:
grpcurl -plaintext -d '{"id": "9"}' 127.0.0.1:50051 user.v1.UserService/GetUser
ERROR:
Code: NotFound
Message: user "9" not found
Stop the test server:
kill %1
Step 6 - Running the server with systemd
Install the binary system-wide and create a dedicated system user without a shell or home directory:
sudo install -m 0755 bin/usersvc /usr/local/bin/usersvc
sudo useradd --system --no-create-home --shell /usr/sbin/nologin usersvc
To load balance on a single server you will run two instances, one on port 50051 and one on port 50052. A systemd template unit handles this: the text after @ in the unit name is available as %i. Create the template:
sudo nano /etc/systemd/system/[email protected]
[Unit]
Description=usersvc gRPC server on port %i
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=usersvc
Group=usersvc
Environment=GRPC_ADDR=127.0.0.1:%i
ExecStart=/usr/local/bin/usersvc
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
The instances listen on 127.0.0.1 only, so they are never reachable directly from the Internet. Reflection stays disabled because the unit does not set GRPC_REFLECTION. Start both instances and enable them at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now usersvc@50051 usersvc@50052
Confirm that both are running and listening:
systemctl is-active usersvc@50051 usersvc@50052
sudo ss -tlnp | grep usersvc
active
active
LISTEN 0 4096 127.0.0.1:50051 0.0.0.0:* users:(("usersvc",pid=4211,fd=3))
LISTEN 0 4096 127.0.0.1:50052 0.0.0.0:* users:(("usersvc",pid=4212,fd=3))
Logs go to the journal:
sudo journalctl -u usersvc@50051 -n 20
Step 7 - Checking health with grpc-health-probe
grpc-health-probe queries the standard health service and exits with a non-zero code when the service is not serving, which makes it usable from monitoring agents, scripts and Kubernetes probes. Check each instance, including the specific service name:
grpc-health-probe -addr=127.0.0.1:50051 -service=user.v1.UserService
grpc-health-probe -addr=127.0.0.1:50052 -service=user.v1.UserService
status: SERVING
status: SERVING
Check the exit code if you wire this into your own tooling:
grpc-health-probe -addr=127.0.0.1:50051; echo "exit code: $?"
status: SERVING
exit code: 0
Step 8 - Putting Nginx in front with TLS and load balancing
gRPC clients expect TLS on public endpoints, and a reverse proxy lets you spread calls across instances, restart them one at a time and add more later. Nginx supports gRPC natively through grpc_pass.
Install Nginx and Certbot, and allow HTTP, HTTPS and SSH through UFW:
sudo apt install -y nginx certbot
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Request a certificate using the webroot method. The default Nginx site already serves /var/www/html on port 80, which Let's Encrypt uses for validation. The deploy hook reloads Nginx after each automatic renewal:
sudo certbot certonly --webroot -w /var/www/html -d grpc.your_domain \
--deploy-hook "systemctl reload nginx"
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/grpc.your_domain/fullchain.pem
Key is saved at: /etc/letsencrypt/live/grpc.your_domain/privkey.pem
Create the Nginx site for the gRPC endpoint:
sudo nano /etc/nginx/sites-available/grpc.your_domain
upstream usersvc {
server 127.0.0.1:50051 max_fails=3 fail_timeout=10s;
server 127.0.0.1:50052 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name grpc.your_domain;
ssl_certificate /etc/letsencrypt/live/grpc.your_domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grpc.your_domain/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Keep long server streams open instead of cutting them after 60s
grpc_read_timeout 1h;
grpc_send_timeout 1h;
location /user.v1.UserService/ {
grpc_pass grpc://usersvc;
}
location /grpc.health.v1.Health/ {
grpc_pass grpc://usersvc;
}
location / {
return 404;
}
}
Each gRPC method is an HTTP/2 POST to /<package>.<Service>/<Method>, so routing by service is a plain location prefix. Anything not listed, including the reflection service, is refused. grpc:// means Nginx talks to the backends over plaintext HTTP/2 on the loopback interface. If a backend is down, Nginx retries the call on the other one (grpc_next_upstream defaults to error timeout) and stops sending it traffic for fail_timeout after max_fails failures.
NoteUbuntu 24.04 ships Nginx 1.24, where HTTP/2 is enabled with
listen 443 ssl http2. On Nginx 1.25.1 and later, uselisten 443 ssl;plus a separatehttp2 on;line instead.
Enable the site, test the configuration and reload:
sudo ln -s /etc/nginx/sites-available/grpc.your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 9 - Testing the public endpoint
Because reflection is disabled in production, give grpcurl the .proto file instead. Run this from the project directory, on the server or on any machine that has a copy of proto/user.proto. Note that there is no -plaintext flag: the call goes over TLS and the certificate is validated against the system trust store.
cd ~/usersvc
grpcurl -import-path proto -proto user.proto \
-d '{"id": "2"}' grpc.your_domain:443 user.v1.UserService/GetUser
{
"id": "2",
"name": "Bob",
"email": "[email protected]"
}
Test the server streaming method, which returns one JSON object per streamed message:
grpcurl -import-path proto -proto user.proto \
-d '{}' grpc.your_domain:443 user.v1.UserService/ListUsers
Check health through the proxy over TLS:
grpc-health-probe -addr=grpc.your_domain:443 -tls -service=user.v1.UserService
status: SERVING
Finally, verify failover. Stop one instance, repeat the call, then start it again:
sudo systemctl stop usersvc@50051
grpcurl -import-path proto -proto user.proto -d '{"id": "1"}' grpc.your_domain:443 user.v1.UserService/GetUser
sudo systemctl start usersvc@50051
The call still succeeds because Nginx sends it to the instance on port 50052. You can use the same stop and start sequence to roll out a new binary one instance at a time.
Troubleshooting
Code: Unavailable with unexpected HTTP status code received from server: 502: Nginx cannot reach any backend. Check that the instances are running and read their logs:
systemctl status usersvc@50051 usersvc@50052
sudo journalctl -u usersvc@50051 -n 50
first record does not look like a TLS handshake: the client uses TLS against a plaintext port. Use -plaintext when calling 127.0.0.1:50051 directly and omit it when calling Nginx on port 443.
Code: Unimplemented: the method path does not match what the server registered. The package, service and method names in the client's .proto file must match the server's exactly, including user.v1. Regenerate the code after any change to the file.
Server streams stop after about 60 seconds: the proxy timeout is closing them. Raise grpc_read_timeout and grpc_send_timeout in the Nginx server block, as shown in Step 8.
Nginx returns 404 for a new service: add a location /<package>.<Service>/ block for it and reload Nginx.
Conclusion
You built a gRPC service in Go, ran two instances under systemd with the standard health service, and exposed them through Nginx with a trusted TLS certificate, service-based routing and automatic failover. From here you can move the user data into a real database, add client authentication with a bearer token in gRPC metadata or with mutual TLS in Nginx (ssl_verify_client), and point your monitoring at grpc-health-probe or at the Nginx access log to track call rates and errors per method.
