Envoy as an API Gateway

Envoy is a very powerful tool for routing requests to your infrastructure. Compared to Nginx also supports gRPC, more advanced request matching and load balancing. Both can be used as a reverse proxy, but envoy has a truck load of features and very naturally integrates many services across different hostnames and deployment setups.

The official envoy github organisation sports a plethora of great examples as well, which makes up for their very dry and counter-intuitive documentation: github.com/envoyproxy/examples/

Configuring an API Gateway with Docker Compose

In this example I want to build a simple setup of:

  1. One envoy proxy acting as an api gateway
  2. The first service (users-api) which will be exposed by the gateway
  3. The second service (photos-api), only called by the users-api service

Our directory structure looks as follows:

├── docker-compose.yaml
├── gateway
│   └── envoy.yaml
├── private-pictures
│   ├── Dockerfile
│   └── main.go
└── users-api
    ├── Dockerfile
    └── main.go

Here's what we want to happen when a user requests our envoy proxy:

Let's start with the docker compose file:

# docker-compose.yaml
services:
  gateway:
    image: envoyproxy/envoy:v1.31-latest
    entrypoint: "/usr/local/bin/envoy"
    command:
      - "--config-path /etc/envoy/envoy.yaml"
    volumes:
      - ./gateway/envoy.yaml:/etc/envoy/envoy.yaml
    ports:
      # this will be our externally exposed port
      - "3000:3000"
      # optional, envoy admin ui
      - "9901:9901"
  users-api:
    build: ./users-api/
    # ports:
    #   - "3001:3333"
  photos-api:
    build: ./photos-api/
    # ports:
    #   - "3002:3333"

The most important parts of the config are that we're defining our envoy image and mount the config file via these lines:

command:
  - "--config-path /etc/envoy/envoy.yaml"
volumes:
  - ./gateway/envoy.yaml:/etc/envoy/envoy.yaml

Also we're defining our services, which each consist of the tiniest HTTP API and their own docker-compose file.

users-api:
build: ./users-api/
# ports:
#   - "3001:3333" host_port:container_port

If you want to access the services individually, you can uncomment these lines and access the users api service at localhost:3001. Eventually we only want these services to be accessed VIA our envoy proxy though.

Public and Private Services

When architecting your infrastructure (past a certain comlexity) you will probably have some microservices that talk to each other but never have to respond to a user directly. This scenario is very common in a microservices architecture, but can occur in any.

In this example we only want to expose one of our services via envoy, which then calls the seconds service. users-api is allowed to ask photos-api, but there will be no directly exposed route to the photos-api in the envoy config.

Docker compose will not expose services hosted, but it will allow the containerss to access each other via their exposed ports and since they're all separte containers, they will have distinct IP addresses on the host machine.

The first service, our users-api contains the following code:

// path: ./users-api/main.go
package main

import (
  "fmt"
  "io"
  "log"
  "net/http"
)

func getUser(w http.ResponseWriter, r *http.Request) {
  debug := "service: user, request at /user"
  fmt.Println(debug)

  // calls other service, skips gateway
  // not a dynamic param, unimportant to the example
  resp, err := http.Get("http://photos-api:3333/pictures/user_id")	if err != nil {
    log.Fatalln(err)
  }
  body, err := io.ReadAll(resp.Body)
  if err != nil {
    log.Fatalln(err)
  }
  bodyString := string(body)
  log.Println(bodyString)

  io.WriteString(w, fmt.Sprintf("%v\n", debug))
  io.WriteString(w, bodyString)
}

func main() {
  http.HandleFunc("/user", getUser)

  err := http.ListenAndServe(":3333", nil)
  if err != nil {
    log.Fatal(err)
  }
}

The service we don't directly expose is almost identical, except that it handles HTTP requests at a different path:

// path: ./photos-api/main.go
func getPhoto(w http.ResponseWriter, r *http.Request) {
  debug := "service: private-pictures, request at /pictures/user_id"
  fmt.Println(debug)
  io.WriteString(w, "no photos found for user_id")
}

func main() {
  http.HandleFunc("/pictures/user_id", getPhoto)
  // ...
}

Again, note that both go services can serve on the same HTTP port, as they get individual IPs / host names for their docker containers.

Service Dockerfiles

We'll create some very minimalistic Dockerfiles for our services to have them run out GO backend services:

# same for users-api and photos-api
FROM golang:1.24-alpine
WORKDIR /app
COPY . .
RUN go build ./main.go
EXPOSE 3333
ENTRYPOINT ["./main"]

Envoy Configuration for Matching Routes

Note: The full envoy config will be available at the bottom of the post, because it's quite verbose.

Envoy lets you match routes in a LOT of different ways, in our config we're going to match all hostnames at the route prefix /user, so all requets where the path starts with /user will be proxied to the specified service.

routes:
  - match:
      # the incoming route of the request example.com/user
      prefix: "/user"
    route:
      # this is the reference to the service you want to route the request to
      cluster: users-api-envoy
  - match:
      # all other requets will result in an empty response with status code 400
      prefix: "/"
    direct_response:
      status: 400

To make envoy aware of the service on your network, you will need to add a cluster, we will only define one endpoint address as we're not trying to load balance or failover anything of the sort right now.

clusters:
  name: users-api-envoy
    # ...
  load_assignment:
    # this will be referenced in the route config
    cluster_name: users-api-envoy
      - endpoint:
        address:
        socket_address:
            # service name from your docker compose file
            address: users-api
            port_value: 3333

You can define as many of these services as you want, depending on how many applications you need to route through the envoy proxy.

Summary

When we run docker compose up we should see both the services and the envoy proxy start, when requesting the exposed endpoint with

curl -i localhost:3000/user

We should see the following output in the terminal executing the curl command:

HTTP/1.1 200 OK
date: Sat, 19 Apr 2025 17:14:53 GMT
content-length: 59
content-type: text/plain; charset=utf-8
x-envoy-upstream-service-time: 7
server: envoy

service: user, request at /user
no photos found for user_id

and this log in the docker compose output:

users-api-1   | service: user, request at /user
photos-api-1  | service: private-pictures, request at /pictures/user_id
users-api-1   | 2025/04/19 17:14:53 no photos found for user_id

This is handy for debugging routing issues and making sure that the users request is correctly handled 🙌.

Other requests should by default get a 400 status code.

Complete Envoy Config 🖨️

Here's the full envoy config for this example for the minimal api gateway example.

static_resources:
  listeners:
    - address:
        socket_address:
          address: 0.0.0.0
          port_value: 3000 # map this port in docker-compose file
      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
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains:
                        - "*"
                      routes:
                        - match:
                            prefix: "/user"
                          route:
                            cluster: users-api-envoy
                        - match:
                            prefix: "/"
                          direct_response:
                            status: 400
                http_filters:
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
    - name: users-api-envoy
      connect_timeout: 0.25s
      type: strict_dns
      lb_policy: round_robin
      load_assignment:
        cluster_name: users-api-envoy
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: users-api # as defined in your docker-compose file
                      port_value: 3333

admin:
  access_log_path: /dev/null
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901
Tagged with: #envoy #docker #docker compose

Thank you for reading! If you have any comments, additions or questions, please tweet or toot them at me!