gRPC is one of those technologies that sounds simple until you actually try to wire it up. Strongly typed contracts, HTTP/2 multiplexing, streaming — it all looks great on the tin. Then you sit down to implement it and discover the ecosystem is a mess of half-maintained libraries, outdated examples, and docs that assume you already know what you’re doing.
In the Go world this is solved. In Python it’s painful but survivable. In Rust, for a long time, it was genuinely rough. That changed when Tonic landed and reached maturity. It’s the definitive gRPC library for Rust — async-native, built on Tokio, and the closest thing the ecosystem has to a blessed solution.
This article walks you through a real service: proto definition, code generation, server implementation, client usage, interceptors for auth and logging, and TLS with self-signed certs. By the end you’ll have a skeleton you can actually ship.
Project layout
Before anything else, here’s what we’re building:
grpc-demo/
├── build.rs
├── Cargo.toml
├── certs/ # TLS certs live here
├── proto/
│ └── hello.proto
└── src/
├── bin/
│ ├── server.rs
│ └── client.rs
└── interceptors.rs
Two binaries — one server, one client. Clean separation, no main.rs juggling both roles.
Dependencies
# Cargo.toml
[package]
name = "grpc-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
tonic = { version = "0.12", features = ["tls"] }
prost = "0.13"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = "0.5"
# For our auth interceptor example
http = "1"
[build-dependencies]
tonic-build = "0.12"
[[bin]]
name = "server"
path = "src/bin/server.rs"
[[bin]]
name = "client"
path = "src/bin/client.rs"
The tls feature on tonic pulls in rustls under the hood. No OpenSSL dependency, no system library headaches — that matters more than people admit when you’re building Docker images.
The proto file
// proto/hello.proto
syntax = "proto3";
package hello;
service Greeter {
// Unary RPC
rpc SayHello (HelloRequest) returns (HelloReply);
// Server streaming — client sends one message, server streams responses
rpc SayHelloStream (HelloRequest) returns (stream HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Nothing exotic here. One unary call, one server-streaming call. Enough to demonstrate both shapes without noise.
Codegen with tonic-build
This is where most tutorials skip over the important bits.
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
// Generate server-side code
.build_server(true)
// Generate client-side code
.build_client(true)
// Include the file descriptor set — needed if you use gRPC reflection
.file_descriptor_set_path(
std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap())
.join("hello_descriptor.bin"),
)
.compile_protos(&["proto/hello.proto"], &["proto"])?;
Ok(())
}
tonic_build::compile_protos is the one-liner shortcut, but configure() gives you control. The file_descriptor_set_path line is optional, but if you ever want gRPC server reflection (so tools like grpcurl can discover your services without the proto file), you’ll need it. Add it now; removing it later is trivial.
Gotcha: tonic-build requires protoc on your PATH. It’s not bundled. On Debian/Ubuntu: apt install protobuf-compiler. On macOS: brew install protobuf. If you want a zero-system-dependency build, look at the prost-build protoc_from_env approach or the protoc-bin-vendored crate — but for most teams, just install protoc and document it.
Generated code lands in $OUT_DIR. You reference it with the include_proto! macro.
The server
// src/bin/server.rs
use tonic::{transport::Server, Request, Response, Status};
use tonic::transport::{Identity, ServerTlsConfig};
// Pull in the generated code
pub mod hello {
tonic::include_proto!("hello");
}
use hello::greeter_server::{Greeter, GreeterServer};
use hello::{HelloReply, HelloRequest};
// Import our interceptors
mod interceptors {
pub use grpc_demo::interceptors::*;
}
#[derive(Debug, Default)]
struct GreeterService;
#[tonic::async_trait]
impl Greeter for GreeterService {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
// Extensions set by interceptors are available here
let caller = request
.extensions()
.get::<CallerInfo>()
.map(|c| c.0.as_str())
.unwrap_or("unknown");
println!("SayHello from caller: {caller}");
let reply = HelloReply {
message: format!("Hello, {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
type SayHelloStreamStream = tokio_stream::wrappers::ReceiverStream<
Result<HelloReply, Status>
>;
async fn say_hello_stream(
&self,
request: Request<HelloRequest>,
) -> Result<Response<Self::SayHelloStreamStream>, Status> {
let name = request.into_inner().name;
let (tx, rx) = tokio::sync::mpsc::channel(4);
tokio::spawn(async move {
for i in 0..5 {
let msg = HelloReply {
message: format!("Hello #{i}, {name}!"),
};
if tx.send(Ok(msg)).await.is_err() {
break; // Client disconnected
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
// Load TLS identity (cert + key)
let cert = std::fs::read_to_string("certs/server.crt")?;
let key = std::fs::read_to_string("certs/server.key")?;
let identity = Identity::from_pem(cert, key);
let greeter = GreeterService::default();
println!("Server listening on {addr}");
Server::builder()
.tls_config(ServerTlsConfig::new().identity(identity))?
// Attach the auth interceptor to every service on this server
.layer(tonic::service::interceptor(interceptors::auth_interceptor))
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
A few things worth calling out:
The type SayHelloStreamStream associated type is mandatory — Tonic needs to know the concrete stream type at compile time. ReceiverStream from tokio-stream is the easiest fit for a channel-backed stream. If you’re adapting a futures stream or a database cursor, you’ll use Pin<Box<dyn Stream<Item = ...> + Send>> instead — which is what the macro hides from you. Know what’s underneath.
Gotcha: #[tonic::async_trait] is re-exported from the async-trait crate. Don’t mix it with a separately imported async_trait::async_trait — you’ll get confusing lifetime errors.
Interceptors
This is where Tonic trips up a lot of people. The Tower middleware model is powerful but the types are… expressive.
Tonic exposes two flavors: tonic::service::interceptor for simple function-based interceptors (good enough for 90% of cases), and full Tower Layer + Service impls for anything that needs state, async operations, or response mutation.
// src/interceptors.rs
use tonic::{Request, Status};
/// Typed data we extract in the interceptor and make available to handlers
#[derive(Clone, Debug)]
pub struct CallerInfo(pub String);
/// Simple auth interceptor — checks the `authorization` metadata header.
/// Returns Unauthenticated if missing or wrong, otherwise injects CallerInfo.
pub fn auth_interceptor(mut req: Request<()>) -> Result<Request<()>, Status> {
let token = match req.metadata().get("authorization") {
Some(t) => t.to_str().map_err(|_| {
Status::unauthenticated("authorization header is not valid ASCII")
})?,
None => return Err(Status::unauthenticated("missing authorization header")),
};
// In production: validate a JWT here, not a hardcoded string
if token != "Bearer secret-token" {
return Err(Status::unauthenticated("invalid token"));
}
// Inject caller identity so handlers don't need to re-parse the token
req.extensions_mut().insert(CallerInfo("alice".to_string()));
Ok(req)
}
The function-based interceptor receives Request<()> — the body is always unit because the interceptor runs before deserialization. You get metadata access (headers), you can mutate extensions, and you return either the enriched request or an error status.
Gotcha: Extensions inserted in a server interceptor are available in the handler via request.extensions(). But they’re not available on the response path — for that you need a full Tower layer that wraps the service and can observe both directions. Don’t fight the types trying to make a function interceptor do something it doesn’t support.
For a logging interceptor that also records response latency, here’s the Tower approach:
use std::task::{Context, Poll};
use std::time::Instant;
use tonic::body::BoxBody;
use tower::{Layer, Service};
use http::{Request, Response};
#[derive(Clone)]
pub struct LoggingLayer;
impl<S> Layer<S> for LoggingLayer {
type Service = LoggingService<S>;
fn layer(&self, inner: S) -> Self::Service {
LoggingService { inner }
}
}
#[derive(Clone)]
pub struct LoggingService<S> {
inner: S,
}
impl<S, B> Service<Request<B>> for LoggingService<S>
where
S: Service<Request<B>, Response = Response<BoxBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
let path = req.uri().path().to_string();
let mut inner = self.inner.clone();
Box::pin(async move {
let start = Instant::now();
let response = inner.call(req).await;
println!("{} — {}ms", path, start.elapsed().as_millis());
response
})
}
}
Chain it on the server alongside the auth interceptor:
Server::builder()
.tls_config(ServerTlsConfig::new().identity(identity))?
.layer(LoggingLayer)
.layer(tonic::service::interceptor(interceptors::auth_interceptor))
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Layers stack in the order they’re added — outermost first. So the logging layer here wraps the auth interceptor, meaning you log every request including unauthenticated ones that get rejected. Flip the order if you want to log only authenticated traffic.
The client
// src/bin/client.rs
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
use tonic::metadata::MetadataValue;
use tonic::Request;
use tokio_stream::StreamExt;
pub mod hello {
tonic::include_proto!("hello");
}
use hello::greeter_client::GreeterClient;
use hello::HelloRequest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load our CA cert to verify the server's certificate
let ca_cert = std::fs::read_to_string("certs/ca.crt")?;
let ca = Certificate::from_pem(ca_cert);
let tls = ClientTlsConfig::new()
.ca_certificate(ca)
// Must match the CN/SAN in the server cert
.domain_name("localhost");
let channel = Channel::from_static("https://[::1]:50051")
.tls_config(tls)?
.connect()
.await?;
let mut client = GreeterClient::new(channel);
// Attach auth token to a specific request
let mut request = Request::new(HelloRequest {
name: "World".into(),
});
request
.metadata_mut()
.insert("authorization", MetadataValue::try_from("Bearer secret-token")?);
let response = client.say_hello(request).await?;
println!("Unary response: {}", response.into_inner().message);
// Streaming call
let mut stream_req = Request::new(HelloRequest {
name: "Streamer".into(),
});
stream_req
.metadata_mut()
.insert("authorization", MetadataValue::try_from("Bearer secret-token")?);
let mut stream = client.say_hello_stream(stream_req).await?.into_inner();
while let Some(msg) = stream.next().await {
println!("Stream: {}", msg?.message);
}
Ok(())
}
Gotcha: Channel::from_static takes a &'static str. If your address comes from a config file at runtime, use Channel::from_shared(uri) with an owned Uri. Mixing these up gives you a lifetime error that’s confusing if you don’t know the distinction exists.
TLS: generating self-signed certs
For local dev and internal services, self-signed with a local CA is the right move. Don’t use a single self-signed cert without a CA — you’ll fight rustls refusing to accept it.
mkdir certs && cd certs
# Generate CA key and cert
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/CN=Local Dev CA/O=Dev"
# Generate server key and CSR
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
-subj "/CN=localhost/O=Dev"
# Sign the server cert with our CA
# The SAN extension is critical — rustls validates SANs, not just CN
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt \
-extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1")
The subjectAltName line is non-negotiable with rustls. If you skip it, the client will reject the server cert even though it was signed by your CA. This bites everyone once.
For mutual TLS (the server also verifies the client), add ClientAuth::Required to ServerTlsConfig and generate a client cert the same way, signing it with the same CA. The client then loads both the CA cert and its own identity. mTLS is the right default for internal service-to-service communication — skip it only at your own risk.
Production-ready considerations
Connection pooling and load balancing. Tonic’s Channel handles connection pooling internally, but when you’re behind a load balancer you often want to hit multiple backends. Look at tonic::transport::channel::Balance or pair with a service mesh like Linkerd/Envoy that handles gRPC-aware L7 load balancing. Round-robin at the TCP level doesn’t play well with HTTP/2 multiplexing.
Deadlines and cancellation. Always set a timeout on the channel or per-request. Channel::timeout(Duration) sets a global deadline. In the interceptor you can also read request.metadata().get("grpc-timeout") if you want to propagate deadlines from upstream callers. Without deadlines, a slow handler holds an HTTP/2 stream open indefinitely.
Error mapping. Status::internal("something broke") leaks nothing. Map your domain errors to appropriate gRPC status codes (NOT_FOUND, INVALID_ARGUMENT, ALREADY_EXISTS) — it makes client error handling sane. Create a thin From<MyError> for Status impl and use ? everywhere.
gRPC reflection. Wire up tonic-reflection with the file descriptor set you generated in build.rs. This lets grpcurl and Postman discover your services at runtime without the proto file — essential for debugging in staging.
Keep proto files in a shared repo or registry. If your client and server are separate services, don’t copy-paste proto files. A Git submodule pointing to a proto/ repo, or a Buf Schema Registry, is the way. Schema drift between client and server is a painful class of bug.
Gotcha: tonic::async_trait and Send bounds. If your service implementation holds a non-Send type (like a Rc<> or a raw pointer), Tokio’s multi-threaded runtime will refuse to compile. Either switch to Arc<Mutex<>>, or use tokio::task::LocalSet — but the latter doesn’t scale. Default to Send-safe types from the start.
Running it
# Terminal 1
cargo run --bin server
# Terminal 2
cargo run --bin client
# Or with grpcurl (needs reflection wired up):
grpcurl -insecure -H 'authorization: Bearer secret-token' \
-d '{"name": "grpcurl"}' \
'[::1]:50051' hello.Greeter/SayHello
Use -insecure with grpcurl only if you’re not providing your CA cert. For production-like testing, pass -cacert certs/ca.crt instead.
Tonic is genuinely good software. The Tower integration means your gRPC service speaks the same middleware language as Axum, Hyper, and the rest of the Tokio ecosystem — one mental model, consistent observable behavior. The codegen is fast, the types are correct, and when something goes wrong the error messages are, by Rust standards, actually helpful.
The rough edges are real: the Tower layer types are verbose, the streaming associated types require ceremony, and the TLS cert story has sharp corners if you’re new to rustls. None of that is a reason to avoid it. It’s a reason to read this article before you start, not while you’re debugging at midnight.