Extending Consul API Gateway with Lua: A Practical Guide
Introduction
Extending Consul API Gateway with Lua: A Practical Guide
Introduction
Consul API Gateway provides a powerful abstraction for managing north–south traffic in modern service‑oriented architectures. While the gateway has a large feature set out of the box, many organizations need additional request and response processing, including executing custom logic, enriching headers, validating tokens, or transforming payloads.
This is where Lua filters come in. With the Consul API Gateway’s filter chain model, you can inject Lua scripts directly into the request path to shape traffic before it reaches upstream services.
In this post, we’ll assume you have Consul and Nomad deployed and a running API gateway:
- All configurations will be with Terraform
- Writing and attaching Lua filters
- Testing with fake-service
Architecture Overview
For this walkthrough, the environment consists of:
- Nomad — orchestrates the API Gateway and fake-service jobs
- Consul — holds gateway configuration, service registrations, and the filter chain
- Terraform — provisions both Nomad jobs and Consul config entries
- Consul API Gateway — routes traffic and executes Lua scripts
- Fake service — accepts traffic and returns useful information in the response
The traffic path will look like this:
Client → Consul API Gateway → (Lua Filter) → fake-service
Using Terraform to Deploy the Gateway and Lua Filters
The Consul API Gateway uses Consul configuration entries for routes and filter chains. Terraform keeps the configuration declarative and consistent.
Request and Response rules
In this case we are deploying two rules in the service defaults configuration for the api-gateway, one each on the request and response. Of note the name must match the registered service name of the deployed api-gateway. We are using the envoy_extensions block to deploy the lua script, we have to be specific about the ProxyType to differentiate it from sidecar proxies.
The specific rules are managing routing based on regex rules and changing the response.
On the request we are stripping “/public” from the path that the API gateway uses to call the upstream service while maintaining the rest of the URI.
On the response we are returning custom output when we see a 404 from the upstream.
Other examples could let us validate the request, configure authorization using an external service or investigate a JWT token.
resource "consul_config_entry_service_defaults" "api-gateway" {
name = "api-gateway"
expose {
checks = false
}
protocol = "http"
envoy_extensions {
name = "builtin/lua"
arguments = {
ProxyType = "api-gateway"
Listener = "outbound"
Script = <<EOF
local function strip_public(path)
local out = path:gsub("^/public", "")
if out == "" then out = "/" end
return out
end
function envoy_on_request(request_handle)
if request_handle:headers():get("x-remove-prefix") == "1" then
local original_path = request_handle:headers():get(":path")
if string.find(original_path, "/public/.+") then
local new_path = strip_public(original_path)
request_handle:headers():replace(":path", new_path)
end
end
end
function envoy_on_response(response_handle)
if response_handle:headers():get(":status") == "404" then
local json = '{"message":"Modified by Lua script","status":"success"}'
response_handle:body():setBytes(json)
response_handle:headers():remove("content-length")
response_handle:headers():replace("content-encoding", "identity")
response_handle:headers():replace("content-type", "application/json")
end
end
EOF
}
}
}
Deploying Nomad Jobs
You can deploy both the gateway service and fake-service with Nomad. The gateway deployment is well documented in this project so I will not cover it here.
Deploying the fake-service
The fake service is deployed in the mesh, there are a couple of application specific environment variables we’re using to test the response. Setting the ERROR_CODE to 404 and the ERROR_RATE to 20%
job "frontend" {
datacenters = ["dc1"]
type = "service"
namespace = "test"
group "frontend" {
count = 1
network {
mode = "bridge"
port "expose" {}
}
service {
name = "frontend"
port = "9090"
check {
expose = true
type = "http"
path = "/health"
interval = "30s"
timeout = "5s"
}
connect {
sidecar_service {
proxy {
transparent_proxy {}
}
}
}
}
task "frontend" {
driver = "docker"
config {
image = "nicholasjackson/fake-service:v0.26.2"
}
env {
NAME = "frontend"
ERROR_CODE = "404"
ERROR_RATE = "0.2"
}
}
}
}
With the gateway, configuration and the upstream service deployed as show in the UI we can now use curl to run our tests:

Testing the rewrite
Here we request /public/service/endpoint and can see that the uri the upstream service is responding to is /service/endpoint. We’re passing the x-remove-prefix header to make this happen otherwise the lua script will not execute.
curl -H 'x-remove-prefix: 1' localhost:8090/public/service/endpoint
{
"name": "frontend",
"uri": "/service/endpoint",
"type": "HTTP",
"ip_addresses": [
"172.26.64.21"
],
"start_time": "2025-12-05T16:37:18.123124",
"end_time": "2025-12-05T16:37:18.123365",
"duration": "241.405µs",
"body": "Hello World",
"code": 200
}
Error handling
Every fifth response will be a 404, with a verbose curl call we can see the headers and body have been altered as we have configured.
❯ curl --verbose -H 'x-remove-prefix: 1' localhost:8090/public/service/endpoint
...
> GET /public/service/endpoint HTTP/1.1
> Host: localhost:8090
> User-Agent: curl/8.7.1
> Accept: */*
> x-remove-prefix: 1
>
* Request completely sent off
< HTTP/1.1 404 Not Found
< date: Fri, 05 Dec 2025 16:41:36 GMT
< x-envoy-upstream-service-time: 3
< server: envoy
< content-encoding: identity
< content-type: application/json
< transfer-encoding: chunked
<
* Connection #0 to host localhost left intact
{"message":"Modified by Lua script","status":"success"}%
Layer 7 Intentions
We can also test the validity of the requests using intentions. In this case we are allowing access only to the rewritten path and specifically denying the original.
resource "consul_config_entry_service_intentions" "frontend" {
name = "frontend"
sources {
name = "api-gateway"
type = "consul"
namespace = "default"
partition = "default"
precedence = 9
permissions {
action = "allow"
http {
path_exact = "/service/endpoint"
}
}
permissions {
action = "deny"
http {
path_exact = "/public/service/endpoint"
}
}
}
}
Now requests that are not rewritten get blocked by the intention.
❯ curl --verbose -H 'x-remove-prefix: 0' localhost:8090/public/service/endpoint
> GET /public/service/endpoint HTTP/1.1
> Host: localhost:8090
> User-Agent: curl/8.7.1
> Accept: */*
> x-remove-prefix: 0
>
* Request completely sent off
< HTTP/1.1 403 Forbidden
< content-length: 19
< content-type: text/plain
< date: Fri, 05 Dec 2025 19:12:54 GMT
< server: envoy
< x-envoy-upstream-service-time: 1
<
* Connection #0 to host localhost left intact
RBAC: access denied
Conclusion
This example shows how Consul API Gateway, Nomad, and Terraform form a clean and powerful workflow for building a programmable edge.
If you’re a platform or DevOps team evaluating how to adopt Consul as a traffic management layer, this approach provides:
- repeatable deployments
- consistent API governance
- customizable request processing
- tight integration with existing HashiCorp tooling
메타데이터
- post_id
- d3d15c4e3e20
- slug
- extending-consul-api-gateway-with-lua-a-practical-guide-d3d15c4e3e20
- url
- https://medium.com/@nickjrwales/extending-consul-api-gateway-with-lua-a-practical-guide-d3d15c4e3e20
- canonical_url
- https://medium.com/@nickjrwales/extending-consul-api-gateway-with-lua-a-practical-guide-d3d15c4e3e20
- author_url
- https://medium.com/@nickjrwales
- status
- ok
- fetched_at
- 2026-07-14 12:23:01