Docs
Apache APISIXManage traffic and APIsService DiscoveryIntegrate with HashiCorp Consul

Integrate with HashiCorp Consul

Configure Apache APISIX to discover two controlled local services through HashiCorp Consul, validate routing, and restore the original configuration.

Applies to:Apache APISIX 3.18.0

Deployments:DockerRoles:Platform engineerTime:20 minutesImpact:Changes live traffic

Goal

Register two disposable web services in Consul and configure APISIX to discover them dynamically. The procedure keeps every container on the APISIX quickstart network, validates both discovered endpoints, and restores the exact pre-guide APISIX configuration.

Before you begin

  • Complete Get APISIX with the Docker quickstart.
  • Install Docker, cURL, and jq.
  • Use a shell that supports Bash or Zsh heredocs.
  • Use this single-node Consul dev agent only for local evaluation. Configure ACLs, TLS, persistence, and high availability for a production registry.

Set the endpoints and read the Admin API key without placing it in shell history:

export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
export GUIDE_DIR="$(pwd)"
read -rsp "Admin API key (press Enter for an unchanged quickstart): " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo

Protect the fixed Route, containers, and local files used by this guide:

test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/routes/consul-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 404 || { echo 'Stop: route consul-guide-route already exists'; exit 1; }
for container in consul-guide-registry consul-guide-web-1 consul-guide-web-2; do
  test -z "$(docker ps -aq --filter "name=^/${container}$")" || { echo "Stop: container ${container} already exists"; exit 1; }
done
for file in consul-guide-nginx-1.conf consul-guide-nginx-2.conf consul-guide-config.yaml.backup consul-guide-config.yaml.updated consul-guide-responses.txt; do
  test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }
done

Steps

Step 1: Start a controlled Consul registry

Start a single-node dev agent on the quickstart network. It is reachable by APISIX but is not published on a host port:

docker run -d --name consul-guide-registry \
  --network apisix-quickstart-net \
  hashicorp/consul:1.15.1 agent -dev -client=0.0.0.0 -log-level=info
for attempt in {1..30}; do
  docker run --rm --network apisix-quickstart-net curlimages/curl:8.16.0 \
    --fail --silent --show-error --connect-timeout 2 --max-time 5 \
    http://consul-guide-registry:8500/v1/status/leader | grep -Eq '".+"' && break
  sleep 1
done
docker run --rm --network apisix-quickstart-net curlimages/curl:8.16.0 \
  --fail --silent --show-error --connect-timeout 2 --max-time 5 \
  http://consul-guide-registry:8500/v1/status/leader | grep -Eq '".+"'

What changed: a disposable Consul dev agent is ready only on apisix-quickstart-net.

Step 2: Start and register two web services

Create two minimal NGINX configurations:

cat > consul-guide-nginx-1.conf <<'NGINX'
events {}
http {
  access_log off;
  server {
    listen 80;
    location = / { return 200 "consul-guide-web-1"; }
  }
}
NGINX
cat > consul-guide-nginx-2.conf <<'NGINX'
events {}
http {
  access_log off;
  server {
    listen 80;
    location = / { return 200 "consul-guide-web-2"; }
  }
}
NGINX

Start both services without publishing their ports:

docker run -d --name consul-guide-web-1 --network apisix-quickstart-net \
  -v "${GUIDE_DIR}/consul-guide-nginx-1.conf:/etc/nginx/nginx.conf:ro" nginx:1.29.1-alpine
docker run -d --name consul-guide-web-2 --network apisix-quickstart-net \
  -v "${GUIDE_DIR}/consul-guide-nginx-2.conf:/etc/nginx/nginx.conf:ro" nginx:1.29.1-alpine
for container in consul-guide-web-1 consul-guide-web-2; do
  for attempt in {1..15}; do
    docker exec apisix-quickstart bash -c "</dev/tcp/${container}/80" >/dev/null 2>&1 && break
    sleep 1
  done
  docker exec apisix-quickstart bash -c "</dev/tcp/${container}/80"
done

Capture each private address and register both endpoints under one Consul service name:

for number in 1 2; do
  jq -n --arg id "consul-guide-web-${number}" \
    --arg ip "$(docker inspect "consul-guide-web-${number}" | jq -r '.[0].NetworkSettings.Networks["apisix-quickstart-net"].IPAddress')" \
    '{ID:$id,Name:"consul-guide-web",Address:$ip,Port:80}' | \
  docker run --rm -i --network apisix-quickstart-net curlimages/curl:8.16.0 \
    --fail --silent --show-error --connect-timeout 2 --max-time 10 \
    -X PUT http://consul-guide-registry:8500/v1/agent/service/register \
    -H 'Content-Type: application/json' --data-binary @-
done
docker run --rm --network apisix-quickstart-net curlimages/curl:8.16.0 \
  --fail --silent --show-error --connect-timeout 2 --max-time 10 \
  http://consul-guide-registry:8500/v1/catalog/service/consul-guide-web | \
  jq -e 'length == 2 and ([.[].ServiceID] | sort) == ["consul-guide-web-1","consul-guide-web-2"]'

What changed: Consul resolves consul-guide-web to exactly the two private container addresses.

Step 3: Enable Consul discovery in APISIX

Back up the complete APISIX configuration. Stop if it already contains a top-level discovery block, because that configuration requires a reviewed merge:

umask 077
docker cp apisix-quickstart:/usr/local/apisix/conf/config.yaml consul-guide-config.yaml.backup
test "$(grep -Ec '^discovery:[[:space:]]*$' consul-guide-config.yaml.backup)" = 0 || { echo 'Stop: merge with the existing top-level discovery configuration'; exit 1; }
cp consul-guide-config.yaml.backup consul-guide-config.yaml.updated
cat >> consul-guide-config.yaml.updated <<'YAML'

discovery:
  consul:
    servers:
      - "http://consul-guide-registry:8500"
    dump:
      path: "logs/consul-guide.dump"
      expire: 3600
YAML
docker cp consul-guide-config.yaml.updated apisix-quickstart:/usr/local/apisix/conf/config.yaml
if ! docker exec apisix-quickstart apisix test; then
  docker cp consul-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
  docker exec apisix-quickstart apisix reload
  exit 1
fi
docker exec apisix-quickstart apisix reload

What changed: APISIX uses the local Consul endpoint while retaining every pre-existing configuration field.

Step 4: Create the discovery Route

Create one authenticated fixed-ID Route and assert the stored discovery settings:

curl -sS --connect-timeout 3 --max-time 15 \
  "${APISIX_ADMIN_URL}/apisix/admin/routes/consul-guide-route" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H 'Content-Type: application/json' \
  -d '{
    "name":"consul-guide-route",
    "uri":"/consul-guide",
    "methods":["GET"],
    "upstream":{
      "service_name":"consul-guide-web",
      "discovery_type":"consul",
      "type":"roundrobin"
    }
  }' | jq -e '.value.id == "consul-guide-route" and .value.upstream.service_name == "consul-guide-web" and .value.upstream.discovery_type == "consul"'

What changed: GET /consul-guide selects nodes discovered from the controlled Consul service.

Validate

Allow discovery to converge, then require successful responses from both registered endpoints:

for attempt in {1..30}; do
  : > consul-guide-responses.txt
  for request in {1..8}; do
    curl --fail-with-body -sS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/consul-guide"
    echo
  done > consul-guide-responses.txt
  test "$(sort -u consul-guide-responses.txt | tr '\n' ' ')" = 'consul-guide-web-1 consul-guide-web-2 ' && break
  sleep 1
done
test "$(sort -u consul-guide-responses.txt | tr '\n' ' ')" = 'consul-guide-web-1 consul-guide-web-2 '

The final assertion proves that APISIX reached both nodes returned for the same Consul service.

Roll back / Clean up

Delete the Route, deregister the services, restore the exact APISIX configuration, and remove all disposable resources:

curl --fail-with-body -sS --connect-timeout 3 --max-time 15 -X DELETE \
  "${APISIX_ADMIN_URL}/apisix/admin/routes/consul-guide-route" \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
for number in 1 2; do
  docker run --rm --network apisix-quickstart-net curlimages/curl:8.16.0 \
    --fail --silent --show-error --connect-timeout 2 --max-time 10 \
    -X PUT "http://consul-guide-registry:8500/v1/agent/service/deregister/consul-guide-web-${number}"
done
docker cp consul-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix test
docker exec apisix-quickstart apisix reload
docker exec apisix-quickstart rm -f /usr/local/apisix/logs/consul-guide.dump
docker rm -f consul-guide-web-1 consul-guide-web-2 consul-guide-registry
rm -f consul-guide-nginx-1.conf consul-guide-nginx-2.conf consul-guide-config.yaml.backup \
  consul-guide-config.yaml.updated consul-guide-responses.txt
unset APISIX_ADMIN_URL APISIX_ADMIN_KEY GATEWAY_URL GUIDE_DIR

Expected: the fixed Route, three containers, dump, and local files are absent, and APISIX is running with the byte-for-byte pre-guide configuration.

Troubleshoot

SymptomCauseFix
The configuration guard stops at discovery.APISIX already has service discovery configuration.Do not append another top-level key. Merge consul into the existing discovery mapping in a reviewed change and retain the exact backup.
apisix test fails.The updated YAML is invalid or incompatible with the running APISIX image.Keep the automatic restoration, inspect the test output, and do not reload the rejected file.
The Route returns 503 or 502.Discovery has not converged, a registration is absent, or a private container address changed.Re-register both current addresses and verify that all containers share apisix-quickstart-net.
Validation sees only one response.One NGINX container is unavailable or the registry contains only one node.Check both TCP readiness tests and the exact ServiceID list before increasing the retry loop.