Create a Custom Plugin in Lua
Create, load, validate, and remove a minimal Apache APISIX Lua plugin without exposing local files or discarding the existing plugin list.
Applies to:Apache APISIX 3.18.0
Deployments:DockerRoles:API developerPlatform engineerTime:20 minutesImpact:Changes live traffic
Goal
Create a custom Lua plugin named guide-response-header, load it into an isolated APISIX quickstart, and prove that it adds a configured response header while traffic still reaches the upstream. Restore the exact original APISIX configuration when the test is complete.
The example deliberately exposes no filesystem path, command, or arbitrary Lua expression through Route configuration. Treat every custom plugin as gateway code: review it, test it outside production, and restrict who can deploy or configure it.
Before you begin
- Complete Get APISIX on a machine with Docker.
- Install cURL and jq.
- Use a disposable quickstart. Loading a plugin changes the gateway process configuration and requires a reload.
Set the endpoints and Admin API key:
export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Admin API key (press Enter for an unchanged quickstart): " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echoConfirm the Admin API and quickstart container are available, then protect every fixed resource and local artifact:
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/plugins/list" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 200
docker exec apisix-quickstart apisix version
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/routes/lua-plugin-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 404 || { echo 'Stop: route lua-plugin-guide-route already exists'; exit 1; }
ROUTE_COLLISIONS=$(curl -sS --connect-timeout 3 --max-time 10 "${APISIX_ADMIN_URL}/apisix/admin/routes" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | jq -r --arg uri '/lua-plugin-guide' '.list[] | select(.value.uri == $uri or ((.value.uris // []) | index($uri))) | .key')
test -z "${ROUTE_COLLISIONS}" || { printf 'Stop: URI is already used by %s\n' "${ROUTE_COLLISIONS}"; exit 1; }
test -z "$(docker ps -aq --filter name=^/lua-plugin-guide-upstream$)" || { echo 'Stop: container lua-plugin-guide-upstream already exists'; exit 1; }
for file in guide-response-header.lua lua-guide-config.yaml.backup lua-guide-config.yaml.updated lua-guide-plugins.json lua-guide-headers.txt lua-guide-body.txt; do
test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }
doneThe procedure generates the explicit plugins list from the running gateway. It supports the unchanged quickstart configuration, where neither a top-level plugins list nor apisix.extra_lua_path is already customized. If either setting exists, stop and merge the plugin with the deployment's source-controlled configuration instead of using the quickstart patch below.
Steps
Step 1: Write the Lua plugin
Create a plugin with a constrained string setting. Its header_filter handler adds the configured value to X-Guide-Plugin:
cat > guide-response-header.lua <<'LUA'
local core = require("apisix.core")
local ngx = ngx
local plugin_name = "guide-response-header"
local schema = {
type = "object",
properties = {
value = {
type = "string",
minLength = 1,
maxLength = 64,
},
},
required = {"value"},
}
local _M = {
version = 0.1,
priority = 0,
name = plugin_name,
schema = schema,
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
function _M.header_filter(conf, ctx)
ngx.header["X-Guide-Plugin"] = conf.value
end
return _M
LUAcheck_schema rejects missing, empty, or oversized values before the plugin runs. The header_filter phase changes response headers after the upstream response headers are available. Priority 0 is sufficient because this isolated example has no ordering dependency; select and test an intentional priority when another plugin must run before or after custom logic.
Step 2: Back up and extend the quickstart configuration
Copy the exact current configuration to a permission-restricted local backup. The backup contains deployment settings and can contain secrets, so do not commit or retain it:
umask 077
docker cp apisix-quickstart:/usr/local/apisix/conf/config.yaml lua-guide-config.yaml.backup
test "$(grep -Ec '^apisix:[[:space:]]*$' lua-guide-config.yaml.backup)" = 1
test "$(grep -Ec '^plugins:[[:space:]]*$' lua-guide-config.yaml.backup)" = 0 || { echo 'Stop: merge with the existing top-level plugins list'; exit 1; }
test "$(grep -Ec '^[[:space:]]+extra_lua_path:' lua-guide-config.yaml.backup)" = 0 || { echo 'Stop: merge with the existing extra_lua_path'; exit 1; }Read the active HTTP plugin names, verify that the custom name is free, and produce an updated configuration containing every active plugin plus the new one:
curl -sS --connect-timeout 3 --max-time 10 "${APISIX_ADMIN_URL}/apisix/admin/plugins/list" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" > lua-guide-plugins.json
jq -e 'type == "array" and length > 0 and all(.[]; type == "string")' lua-guide-plugins.json
test "$(jq -r 'index("guide-response-header")' lua-guide-plugins.json)" = null || { echo 'Stop: plugin guide-response-header is already enabled'; exit 1; }
awk '1; /^apisix:[[:space:]]*$/ {print " extra_lua_path: /usr/local/apisix/custom-plugin/?.lua"}' lua-guide-config.yaml.backup > lua-guide-config.yaml.updated
printf '\nplugins:\n' >> lua-guide-config.yaml.updated
jq -r '.[] | " - " + .' lua-guide-plugins.json >> lua-guide-config.yaml.updated
printf ' - guide-response-header\n' >> lua-guide-config.yaml.updatedCreate the custom module path, copy the plugin and updated configuration, then reload APISIX:
docker exec apisix-quickstart mkdir -p /usr/local/apisix/custom-plugin/apisix/plugins
docker cp guide-response-header.lua apisix-quickstart:/usr/local/apisix/custom-plugin/apisix/plugins/guide-response-header.lua
docker cp lua-guide-config.yaml.updated apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reloadIf the reload fails, restore immediately before troubleshooting:
docker cp lua-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reloadConfirm the new worker configuration exposes the custom plugin schema:
for attempt in {1..15}; do
test "$(curl -sS --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/plugins/guide-response-header" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 200 && break
sleep 1
done
curl -sS --connect-timeout 3 --max-time 10 "${APISIX_ADMIN_URL}/apisix/admin/plugins/guide-response-header" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | jq -e '.type == "object" and .properties.value.type == "string" and (.required | index("value") != null)'Step 3: Create an upstream and Route
Start a deterministic upstream on the quickstart network:
docker run -d --name lua-plugin-guide-upstream \
--network apisix-quickstart-net \
hashicorp/http-echo:1.0.0 -listen=:5678 -text=lua-plugin-upstreamWait for its listener from the APISIX container:
for attempt in {1..15}; do
docker exec apisix-quickstart bash -c '</dev/tcp/lua-plugin-guide-upstream/5678' >/dev/null 2>&1 && break
sleep 1
done
docker exec apisix-quickstart bash -c '</dev/tcp/lua-plugin-guide-upstream/5678'Create the fixed-ID Route with valid JSON and Admin API authentication:
curl -sS --connect-timeout 3 --max-time 15 "${APISIX_ADMIN_URL}/apisix/admin/routes/lua-plugin-guide-route" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H "Content-Type: application/json" \
-d '{
"uri": "/lua-plugin-guide",
"plugins": {
"guide-response-header": {
"value": "enabled"
}
},
"upstream": {
"type": "roundrobin",
"nodes": {"lua-plugin-guide-upstream:5678": 1}
}
}' | jq -e '.value.id == "lua-plugin-guide-route" and .value.plugins["guide-response-header"].value == "enabled"'Validate
Capture the response headers and body, then require both the custom header and upstream body:
curl --fail-with-body -sS --connect-timeout 3 --max-time 15 \
-D lua-guide-headers.txt -o lua-guide-body.txt \
"${GATEWAY_URL}/lua-plugin-guide"
tr -d '\r' < lua-guide-headers.txt | grep -Fqx 'X-Guide-Plugin: enabled'
test "$(cat lua-guide-body.txt)" = "lua-plugin-upstream"Expected: both commands exit successfully. The header proves that guide-response-header ran; the exact body proves that the request still reached the configured upstream.
Test schema rejection without replacing the valid Route. The validation endpoint checks the proposed Route body but does not persist it:
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/configs/validate" -X POST \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H "Content-Type: application/json" \
-d '{"routes":[{"id":"lua-plugin-guide-invalid","uri":"/lua-plugin-guide-invalid","plugins":{"guide-response-header":{"value":""}}}]}')" = 400Roll back / Clean up
Delete the Route first. Restore the exact configuration backup, reload APISIX, remove the custom code and upstream, then delete the sensitive local artifacts:
curl -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/lua-plugin-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
docker cp lua-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reload
docker exec apisix-quickstart rm -f /usr/local/apisix/custom-plugin/apisix/plugins/guide-response-header.lua
docker rm -f lua-plugin-guide-upstream
rm -f guide-response-header.lua lua-guide-config.yaml.backup lua-guide-config.yaml.updated lua-guide-plugins.json lua-guide-headers.txt lua-guide-body.txt
unset APISIX_ADMIN_URL APISIX_ADMIN_KEY GATEWAY_URL ROUTE_COLLISIONSExpected: the Route and sample upstream are absent, the original config.yaml is restored byte-for-byte, and no local copy of that configuration remains.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
apisix reload rejects the configuration. | The insertion did not target an unchanged quickstart file, or the generated plugin list is malformed. | Restore the backup immediately, reload, and compare both files before trying a source-controlled merge. |
The custom plugin endpoint returns 404. | The module path, filename, extra_lua_path, or plugin-list entry does not match guide-response-header. | Check all four names, inspect the APISIX error log, and restore the backup if the worker did not load cleanly. |
The Route write rejects guide-response-header. | The gateway has not loaded the plugin schema, or value violates its length constraints. | Require the schema endpoint to return 200, then use a string from 1 through 64 characters. |
| The response has no custom header. | Another Route matched, or the plugin was not attached to the fixed Route. | Read back lua-plugin-guide-route, confirm its URI and plugin configuration, and inspect response headers without -i output truncation. |
| Cleanup reload fails. | The backup is missing or does not belong to this container. | Do not remove the plugin file; recover the correct pre-test configuration and reload it first. |