The most surprising thing about Envoy’s control plane is that it doesn’t actually need Envoy.

Let’s see this in action. We’ll spin up a minimal Envoy instance and then use go-control-plane to push configuration to it.

First, a quick Envoy configuration. This envoy.yaml sets up a basic listener on port 8080 that forwards to a dummy cluster named local_backend.

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: local_backend
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: local_backend
    connect_timeout: 0.25s
    type: LOGICAL_DNS
    lb_policy: ROUND_ROBIN
    dns_lookup_family: V4_ONLY
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http3_protocol_options: {}
    load_assignment:
      cluster_name: local_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: 127.0.0.1
                port_value: 8081 # This port doesn't actually need to be running

Now, let’s build our Go control plane. We’ll use go-control-plane/pkg/server to manage the xDS (Discovery Service) APIs. This server will push a simple route configuration to our Envoy instance.

Here’s the Go code:

package main

import (
	"context"
	"log"
	"net"
	"time"

	discoveryv3 "github.com/envoyproxy/go-control-plane/envoy/api/v2/discovery"
	routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
	envoy_config_v3 "github.com/envoyproxy/go-control-plane/envoy/config/v3"
	"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
	"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
	"github.com/envoyproxy/go-control-plane/pkg/server/v3"
	"google.golang.org/protobuf/types/known/anypb"
	"google.golang.org/protobuf/types/known/durationpb"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

const (
	// Control plane port for xDS APIs
	controlPlanePort = 8080
	// Envoy's xDS port (default)
	envoyXdsPort = 8080
)

var (
	// Set up the cache for xDS resources
	snapshotCache cache.SnapshotCache
)

func main() {
	ctx := context.Background()

	// Create a new snapshot cache
	snapshotCache = cache.NewSnapshotCache(false, cache.NewKeys(resource.ClusterType, resource.RouteType, resource.ListenerType, resource.SecretType))

	// Create the xDS server
	xdsServer := server.NewServer(ctx, snapshotCache, &server.Callbacks{})

	// Start the gRPC server for xDS
	go runXdsServer(ctx, xdsServer)

	// Wait for a bit to ensure the server starts
	time.Sleep(2 * time.Second)

	// Define the desired state for Envoy
	snapshot := cache.Snapshot{}

	// Add a route configuration
	routeConfig := &routev3.RouteConfiguration{
		Name: "local_route",
		VirtualHosts: []*routev3.VirtualHost{
			{
				Name:    "local_service",
				Domains: []string{"*"},
				Routes: []*routev3.Route{
					{
						Match: &routev3.RouteMatch{
							PathSpecifier: &routev3.RouteMatch_Prefix{Prefix: "/"},
						},
						Action: &routev3.Route_Route{
							Route: &routev3.RouteAction{
								ClusterSpecifier: &routev3.RouteAction_Cluster{Cluster: "local_backend"},
							},
						},
					},
				},
			},
		},
	}
	routeAny, err := anypb.New(routeConfig)
	if err != nil {
		log.Fatalf("Failed to create Any for route: %v", err)
	}
	snapshot.Resources[resource.RouteType] = cache.NewResources(0, map[string]cache.Resource{
		routeConfig.Name: routeAny,
	})

	// Add a cluster configuration
	clusterConfig := &envoy_config_v3.Cluster{
		Name: "local_backend",
		ConnectTimeout: &durationpb.Duration{
			Seconds: 5,
			Nanos:   0,
		},
		LbPolicy: envoy_config_v3.Cluster_ROUND_ROBIN,
		ClusterDiscoveryType: &envoy_config_v3.Cluster_Type{
			Type: envoy_config_v3.Cluster_STATIC, // Use STATIC for simplicity, though LOGICAL_DNS is common
		},
		LoadAssignment: &envoy_config_v3.ClusterLoadAssignment{
			ClusterName: "local_backend",
			Endpoints: []envoy_config_v3.ClusterLoadAssignment_Endpoint{
				{
					LbEndpoints: []envoy_config_v3.LbEndpoint{
						{
							HostIdentifier: &envoy_config_v3.LbEndpoint_Endpoint{
								Endpoint: &envoy_config_v3.Endpoint{
									Address: &envoy_config_v3.Address{
										Address: &envoy_config_v3.Address_SocketAddress{
											SocketAddress: &envoy_config_v3.SocketAddress{
												Address: "127.0.0.1",
												PortSpecifier: &envoy_config_v3.SocketAddress_PortValue{PortValue: 8081},
											},
										},
									},
								},
							},
						},
					},
				},
			},
		},
	}
	clusterAny, err := anypb.New(clusterConfig)
	if err != nil {
		log.Fatalf("Failed to create Any for cluster: %v", err)
	}
	snapshot.Resources[resource.ClusterType] = cache.NewResources(0, map[string]cache.Resource{
		clusterConfig.Name: clusterAny,
	})

	// Add a listener configuration
	listenerConfig := &envoy_config_v3.Listener{
		Name: "listener_0",
		Address: &envoy_config_v3.Address{
			Address: &envoy_config_v3.Address_SocketAddress{
				SocketAddress: &envoy_config_v3.SocketAddress{
					Address: "0.0.0.0",
					PortSpecifier: &envoy_config_v3.SocketAddress_PortValue{PortValue: 8080},
				},
			},
		},
		FilterChains: []*envoy_config_v3.FilterChain{
			{
				Filters: []*envoy_config_v3.Filter{
					{
						Name: "envoy.filters.network.http_connection_manager",
						TypedConfig: &anypb.Any{
							TypeUrl: "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager",
							Value: marshalOrPanic(&envoy_config_v3.HttpConnectionManager{
								StatPrefix: "ingress_http",
								CodecType:  envoy_config_v3.HttpConnectionManager_AUTO,
								RouteSpecifier: &envoy_config_v3.HttpConnectionManager_RouteConfig{
									RouteConfig: routeConfig, // Use the route config we defined earlier
								},
								HttpFilters: []*envoy_config_v3.HttpFilter{
									{
										Name: "envoy.filters.http.router",
										TypedConfig: &anypb.Any{
											TypeUrl: "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router",
										},
									},
								},
							}),
						},
					},
				},
			},
		},
	}
	listenerAny, err := anypb.New(listenerConfig)
	if err != nil {
		log.Fatalf("Failed to create Any for listener: %v", err)
	}
	snapshot.Resources[resource.ListenerType] = cache.NewResources(0, map[string]cache.Resource{
		listenerConfig.Name: listenerAny,
	})

	// Apply the snapshot to the cache. Envoy will fetch it.
	log.Println("Applying snapshot to cache...")
	if err := snapshotCache.SetSnapshot(ctx, "test-node", snapshot); err != nil {
		log.Fatalf("Failed to set snapshot: %v", err)
	}
	log.Println("Snapshot applied. Envoy should now be configured.")

	// Keep the main goroutine alive
	select {}
}

// Helper to run the gRPC server
func runXdsServer(ctx context.Context, xdsServer server.Server) {
	lis, err := net.Listen("tcp", ":9901") // Port for xDS API
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	log.Printf("xDS server listening on %v", lis.Addr())

	grpcServer := grpc.NewServer()
	discoveryv3.RegisterAggregatedDiscoveryServiceServer(grpcServer, xdsServer)

	if err := grpcServer.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

// Helper to marshal protobuf to Any, panicking on error
func marshalOrPanic(pb proto.Message) []byte {
	data, err := proto.Marshal(pb)
	if err != nil {
		panic(err)
	}
	return data
}

To run this:

  1. Save the Envoy config as envoy.yaml.
  2. Save the Go code as main.go.
  3. Run go mod init example.com/controlplane (or your module name).
  4. Run go mod tidy to fetch dependencies.
  5. Start Envoy: envoy -c envoy.yaml --service-cluster my-cluster --service-node my-node
  6. Run the Go control plane: go run main.go

Now, if you curl http://localhost:8080/, you’ll see a 503 Service Unavailable. This is because Envoy is configured to forward to local_backend on port 8081, which isn’t actually running. But the configuration has been pushed from our Go control plane.

The go-control-plane library provides the gRPC server implementation for the xDS APIs (Discovery Service). Envoy clients connect to this server to request configuration updates. The cache.SnapshotCache is the core component where you define the desired state of your Envoy deployment (listeners, routes, clusters, endpoints, secrets). When you call SetSnapshot, the cache serializes these resources into the formats Envoy expects and makes them available for Envoy to fetch.

The server.Server struct orchestrates the gRPC endpoints for each xDS API (/v3/discovery:load_balancing_configs, /v3/discovery:clusters, etc.). It interacts with the SnapshotCache to retrieve the current desired state for each resource type requested by an Envoy instance. The resource.Type constants (resource.ClusterType, resource.RouteType, etc.) are crucial for organizing and managing these different types of configurations within the cache.

The trickiest part for newcomers is often understanding the resource types and how they relate. Envoy’s configuration is highly modular. A Listener defines how Envoy accepts traffic, and it contains FilterChains. A FilterChain can contain NetworkFilters, and the HttpConnectionManager is a common network filter that handles HTTP traffic. This HttpConnectionManager then uses RouteConfiguration to decide where to send requests. The RouteConfiguration itself refers to Clusters, which define upstream hosts.

The most powerful aspect of go-control-plane is its ability to dynamically update Envoy’s configuration without restarting Envoy. You can change routes, add new clusters, or update TLS certificates on the fly by simply updating the Snapshot in the SnapshotCache and calling SetSnapshot. Envoy, upon receiving the update from the control plane, will reconfigure itself accordingly. This dynamic nature is what enables advanced features like canary deployments, A/B testing, and fine-grained traffic management.

The control plane itself doesn’t need Envoy to run; it is the source of truth for Envoy’s configuration. Envoy is just the agent that fetches and applies this configuration. This separation of concerns allows you to manage a fleet of Envoy proxies from a single, centralized control plane.

The next challenge is managing endpoint discovery. You’ve seen how to push static cluster configurations, but in a real-world scenario, you’d want your control plane to dynamically discover healthy backend instances.

Want structured learning?

Take the full Envoy course →