
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:
- One envoy proxy acting as an api gateway
- The first service (users-api) which will be exposed by the gateway
- 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