DevOpsil
Envoy
92%
Needs Review

Envoy Proxy Rate Limiting: Global and Local Strategies

Zara BlackwoodZara Blackwood6 min read

Envoy Proxy Rate Limiting: Global and Local Strategies

Rate limiting is one of those things that seems simple until you actually need it at scale. A single service can apply limits locally, but the moment you have multiple Envoy instances in a mesh or as edge proxies, "per-service" limits stop being meaningful — each instance only sees a fraction of the total traffic.

This guide covers both local rate limiting (fast, no dependencies) and global rate limiting (consistent across all instances, backed by a shared state store), with working Envoy configurations for each.

Local Rate Limiting: Token Bucket per Filter Chain

Local rate limiting runs entirely within the Envoy process. No external calls, no shared state. It's the fastest option and works well for per-connection or per-listener limits.

# envoy.yaml — Local rate limit via HTTP filter
static_resources:
  listeners:
    - name: main_listener
      address:
        socket_address:
          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
                http_filters:
                  # Local rate limit filter — applied before routing
                  - name: envoy.filters.http.local_ratelimit
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                      stat_prefix: http_local_rate_limiter
                      token_bucket:
                        max_tokens: 1000
                        tokens_per_fill: 1000
                        fill_interval: 1s
                      filter_enabled:
                        runtime_key: local_rate_limit_enabled
                        default_value:
                          numerator: 100
                          denominator: HUNDRED
                      filter_enforced:
                        runtime_key: local_rate_limit_enforced
                        default_value:
                          numerator: 100
                          denominator: HUNDRED
                      response_headers_to_add:
                        - append: false
                          header:
                            key: x-local-rate-limit
                            value: 'true'
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

The token_bucket here allows 1000 requests per second per Envoy instance. Clients that exceed it get a 429 Too Many Requests response immediately — no upstream service is touched.

Per-Route Local Limits

You can override the global limit on specific routes:

virtual_hosts:
  - name: app_service
    domains: ["*"]
    routes:
      - match:
          prefix: /api/public
        route:
          cluster: public_api_cluster
        typed_per_filter_config:
          envoy.filters.http.local_ratelimit:
            "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            stat_prefix: public_api_local_ratelimit
            token_bucket:
              max_tokens: 10000    # Higher limit for public endpoints
              tokens_per_fill: 10000
              fill_interval: 1s

      - match:
          prefix: /api/admin
        route:
          cluster: admin_api_cluster
        typed_per_filter_config:
          envoy.filters.http.local_ratelimit:
            "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            stat_prefix: admin_api_local_ratelimit
            token_bucket:
              max_tokens: 50       # Strict limit on admin endpoints
              tokens_per_fill: 50
              fill_interval: 1s

Global Rate Limiting: Redis-Backed External Service

For consistent limits across a fleet of Envoy proxies, you need a shared state store. The standard approach is the Envoy rate limit service backed by Redis.

Deploy the Rate Limit Service

# ratelimit-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ratelimit
  namespace: envoy-system
spec:
  replicas: 2
  selector:
    matchLabels:
      app: ratelimit
  template:
    metadata:
      labels:
        app: ratelimit
    spec:
      containers:
        - name: ratelimit
          image: envoyproxy/ratelimit:master
          command: ["/bin/ratelimit"]
          env:
            - name: LOG_LEVEL
              value: warn
            - name: REDIS_SOCKET_TYPE
              value: tcp
            - name: REDIS_URL
              value: redis:6379
            - name: USE_STATSD
              value: "false"
            - name: RUNTIME_ROOT
              value: /data
            - name: RUNTIME_SUBDIRECTORY
              value: ratelimit
            - name: RUNTIME_WATCH_ROOT
              value: "false"
            - name: RUNTIME_IGNOREDOTFILES
              value: "true"
          ports:
            - containerPort: 8080
              name: http
            - containerPort: 8081
              name: grpc
          volumeMounts:
            - name: config
              mountPath: /data/ratelimit/config
      volumes:
        - name: config
          configMap:
            name: ratelimit-config
---
apiVersion: v1
kind: Service
metadata:
  name: ratelimit
  namespace: envoy-system
spec:
  selector:
    app: ratelimit
  ports:
    - name: grpc
      port: 8081
      targetPort: 8081

Rate Limit Configuration

# ratelimit-config.yaml (ConfigMap data)
apiVersion: v1
kind: ConfigMap
metadata:
  name: ratelimit-config
  namespace: envoy-system
data:
  config.yaml: |
    domain: api_limits
    descriptors:
      # Global limit: 10,000 req/min across all instances
      - key: generic_key
        value: default
        rate_limit:
          unit: MINUTE
          requests_per_unit: 10000

      # Per-user limit: 100 req/min per user ID
      - key: user_id
        rate_limit:
          unit: MINUTE
          requests_per_unit: 100

      # Per-IP limit: 50 req/min for auth endpoints
      - key: remote_address
        descriptors:
          - key: path
            value: /auth/login
            rate_limit:
              unit: MINUTE
              requests_per_unit: 10

      # Tiered: premium users get 1000/min, free gets 100/min
      - key: plan_type
        value: premium
        rate_limit:
          unit: MINUTE
          requests_per_unit: 1000
      - key: plan_type
        value: free
        rate_limit:
          unit: MINUTE
          requests_per_unit: 100

Wire Envoy to the Global Rate Limit Service

# Envoy config — global rate limit integration
http_filters:
  - name: envoy.filters.http.ratelimit
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
      domain: api_limits
      request_type: external
      stage: 0
      rate_limited_as_resource_exhausted: true
      rate_limit_service:
        grpc_service:
          envoy_grpc:
            cluster_name: rate_limit_cluster
          timeout: 0.25s    # Fail open if rate limit service is slow
        transport_api_version: V3
      failure_mode_deny: false    # fail open — don't block traffic if RLS is down

# Add the rate limit cluster
clusters:
  - name: rate_limit_cluster
    type: STRICT_DNS
    connect_timeout: 1s
    lb_policy: ROUND_ROBIN
    http2_protocol_options: {}
    load_assignment:
      cluster_name: rate_limit_cluster
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: ratelimit.envoy-system.svc.cluster.local
                    port_value: 8081

Send Descriptors from Route Config

virtual_hosts:
  - name: api
    domains: ["api.example.com"]
    routes:
      - match:
          prefix: /
        route:
          cluster: backend_service
          rate_limits:
            - stage: 0
              actions:
                # Always send generic_key for global limit
                - generic_key:
                    descriptor_value: default
                # Send user ID from header for per-user limits
                - request_headers:
                    header_name: x-user-id
                    descriptor_key: user_id
                    skip_if_absent: true
                # Send client IP for IP-based limits
                - remote_address: {}

Combining Local and Global Limits

Use both together for defense in depth:

LayerMechanismPurpose
Local (per instance)Token bucketFast protection against burst traffic
Global (shared Redis)External rate limit serviceConsistent cross-instance limits

The local limit fires first — no network call needed. The global limit enforces aggregate quotas. A request only hits the upstream service if it passes both.

Testing Your Rate Limits

# Install hey for load testing
go install github.com/rakyll/hey@latest

# Test local rate limit (1000 req/sec configured)
hey -n 5000 -c 100 -q 2000 http://localhost:8080/api/test

# Watch Envoy rate limit stats
curl -s localhost:9901/stats | grep ratelimit

# Expected stats output:
# http.ingress_http.ratelimit.ok: 1000
# http.ingress_http.ratelimit.over_limit: 4000
# http.ingress_http.ratelimit.error: 0

Rate Limit Response Headers

Add informational headers so clients can implement backoff:

response_headers_to_add:
  - header:
      key: x-ratelimit-limit
      value: "%DYNAMIC_METADATA(envoy.ratelimit:limit)%"
    keep_empty_value: false
  - header:
      key: x-ratelimit-remaining
      value: "%DYNAMIC_METADATA(envoy.ratelimit:remaining)%"
    keep_empty_value: false
  - header:
      key: x-ratelimit-reset
      value: "%DYNAMIC_METADATA(envoy.ratelimit:reset)%"
    keep_empty_value: false
  - header:
      key: retry-after
      value: "1"
    response_code_details: rate_limit_response_code

These headers follow the IETF draft standard for rate limit headers, which well-behaved HTTP clients can use to implement exponential backoff automatically.

Key Takeaways

  • Local rate limiting is zero-latency and the right first line of defense for burst protection
  • Global rate limiting requires a rate limit service and Redis but provides consistent cross-fleet limits
  • failure_mode_deny: false (fail open) is the safer default — a Redis outage shouldn't take down your API
  • Set the rate limit service timeout low (250ms) to prevent slow Redis from adding latency to every request
  • Use descriptors to implement tiered limits per user tier, IP, or endpoint — the rate limit service config is just YAML, no code changes needed
Share:

Was this article helpful?

Zara Blackwood
Zara Blackwood

Platform Engineer

Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.

Related Articles

Discussion