Apache APISIX is an open source API gateway built on NGINX and OpenResty. It stores its routes, upstreams and plugin settings in etcd and applies changes in real time, without reloads. In this tutorial you will deploy APISIX 3.18 and etcd on Ubuntu 24.04 with Docker Compose, then use the Admin API to publish a load-balanced service with active health checks, API key authentication and rate limiting.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS.
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository. Check with docker compose version.
  • Port 9080 open for API traffic. The Admin API and etcd will not be exposed publicly.

Step 1 - Writing the APISIX configuration

APISIX reads its node settings from config.yaml, while routes and other objects live in etcd. The settings you must define are where etcd is and which key protects the Admin API.

Create a project directory:

mkdir -p ~/apisix/conf && cd ~/apisix

Generate a random Admin API key and note it down:

openssl rand -hex 16
3f9a1c7e5b2d48e6a0c4f1b7d9e2a6c3

Create the configuration file:

nano conf/config.yaml

Paste the following, replacing your_admin_key with the value you generated:

apisix:
  node_listen: 9080

deployment:
  role: traditional
  role_traditional:
    config_provider: etcd
  admin:
    admin_key_required: true
    admin_key:
      - name: admin
        key: your_admin_key
        role: admin
    allow_admin:
      - 0.0.0.0/0
  etcd:
    host:
      - "http://etcd:2379"
    prefix: "/apisix"
    timeout: 30
  • role: traditional runs the data plane and the Admin API in the same process, which is the usual setup for a single node.
  • allow_admin is an IP allowlist for the Admin API. Requests from the host arrive from the Docker network gateway, whose address varies, so the list is open here. The Admin API port will only be published on 127.0.0.1 in the next step, which is what keeps it private.

The file contains the Admin API key. It must stay readable by the unprivileged user inside the APISIX container, so leave its default mode (644) and rely on the home directory for protection: on Ubuntu 24.04, home directories are created with mode 750, so other users on the server cannot open files inside it. Confirm it:

ls -ld ~
drwxr-x--- 6 your_user your_user 4096 Sep 25 12:03 /home/your_user

Step 2 - Creating the Compose file

The stack has an etcd node, APISIX, and two traefik/whoami containers as demo backends. whoami is a tiny web server that prints its hostname and answers GET /health.

nano compose.yaml
services:
  etcd:
    image: quay.io/coreos/etcd:v3.6.15
    command:
      - /usr/local/bin/etcd
      - --name=etcd0
      - --data-dir=/etcd-data
      - --listen-client-urls=http://0.0.0.0:2379
      - --advertise-client-urls=http://etcd:2379
    volumes:
      - etcd-data:/etcd-data
    healthcheck:
      test: ["CMD", "/usr/local/bin/etcdctl", "endpoint", "health"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  apisix:
    image: apache/apisix:3.18.0-debian
    volumes:
      - ./conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
    ports:
      - "9080:9080"
      - "127.0.0.1:9180:9180"
    depends_on:
      etcd:
        condition: service_healthy
    restart: unless-stopped

  whoami1:
    image: traefik/whoami
    hostname: whoami1
    restart: unless-stopped

  whoami2:
    image: traefik/whoami
    hostname: whoami2
    restart: unless-stopped

volumes:
  etcd-data:

Only two ports are published: 9080 for client traffic on all interfaces, and the Admin API on 9180 bound to localhost. etcd and the backends are reachable only inside the Compose network.

Start the stack:

docker compose up -d
docker compose ps
NAME               IMAGE                         SERVICE   STATUS
apisix-apisix-1    apache/apisix:3.18.0-debian   apisix    Up 20 seconds
apisix-etcd-1      quay.io/coreos/etcd:v3.6.15   etcd      Up 26 seconds (healthy)
apisix-whoami1-1   traefik/whoami                whoami1   Up 26 seconds
apisix-whoami2-1   traefik/whoami                whoami2   Up 26 seconds

To save typing, store the Admin API address and key in shell variables for this session:

ADMIN=http://127.0.0.1:9180/apisix/admin
KEY=your_admin_key

Confirm the Admin API accepts your key. A new installation has no routes:

curl -s "$ADMIN/routes" -H "X-API-KEY: $KEY"
{"list":[],"total":0}

A request without the key, or with the wrong one, returns 401.

Step 3 - Creating an upstream with health checks

An upstream is a group of backend nodes with a load balancing algorithm. Defining it as its own object lets several routes share it. Create one with both whoami containers and an active health check on /health:

curl -s -X PUT "$ADMIN/upstreams/whoami" -H "X-API-KEY: $KEY" -d '{
  "type": "roundrobin",
  "nodes": {
    "whoami1:80": 1,
    "whoami2:80": 1
  },
  "checks": {
    "active": {
      "type": "http",
      "http_path": "/health",
      "healthy": { "interval": 2, "successes": 1 },
      "unhealthy": { "interval": 1, "http_failures": 2 }
    }
  }
}'

The numbers after each node are weights. The health checker probes each node and removes it after two failed checks, then adds it back after one success. APISIX starts the checker when the upstream receives its first request.

Using PUT with an ID in the URL (whoami) makes the call idempotent: running it again updates the same object instead of creating a new one.

Step 4 - Adding a route

A route matches incoming requests and sends them to an upstream, optionally through plugins. Create a route for every path under /whoami/, and use the proxy-rewrite plugin to strip that prefix before forwarding:

curl -s -X PUT "$ADMIN/routes/whoami" -H "X-API-KEY: $KEY" -d '{
  "uris": ["/whoami", "/whoami/*"],
  "methods": ["GET"],
  "upstream_id": "whoami",
  "plugins": {
    "proxy-rewrite": {
      "regex_uri": ["^/whoami/?(.*)", "/$1"]
    }
  }
}'

The change applies immediately. Send a few requests through the gateway port and show only the hostname:

for i in 1 2 3 4; do curl -s http://127.0.0.1:9080/whoami | grep Hostname; done
Hostname: whoami1
Hostname: whoami2
Hostname: whoami1
Hostname: whoami2

Requests alternate between the two backends.

Step 5 - Testing failover

Stop one backend to simulate a failure:

docker compose stop whoami2

Wait a few seconds for the health checker to mark it down, then send more requests:

for i in 1 2 3 4; do curl -s http://127.0.0.1:9080/whoami | grep Hostname; done
Hostname: whoami1
Hostname: whoami1
Hostname: whoami1
Hostname: whoami1

All traffic goes to the healthy node. Start the second backend again, and after the next successful check it rejoins the rotation:

docker compose start whoami2

Step 6 - Requiring an API key

APISIX identifies clients as consumers. Create a consumer with a key for the key-auth plugin. Replace your_api_key with a long random value, for example from openssl rand -hex 32:

curl -s -X PUT "$ADMIN/consumers" -H "X-API-KEY: $KEY" -d '{
  "username": "mobile_app",
  "plugins": {
    "key-auth": { "key": "your_api_key" }
  }
}'

Enable key-auth on the route. PATCH merges the new plugin into the existing ones, so proxy-rewrite is kept:

curl -s -X PATCH "$ADMIN/routes/whoami" -H "X-API-KEY: $KEY" -d '{
  "plugins": {
    "key-auth": {}
  }
}'

A request without a key is rejected:

curl -i http://127.0.0.1:9080/whoami
HTTP/1.1 401 Unauthorized
...
{"message":"Missing API key in request"}

Send the key in the apikey header, the plugin's default:

curl -s -H "apikey: your_api_key" http://127.0.0.1:9080/whoami | grep Hostname
Hostname: whoami1

Step 7 - Adding rate limiting

The limit-count plugin counts requests per key over a fixed time window. Allow 5 requests per minute per client IP address:

curl -s -X PATCH "$ADMIN/routes/whoami" -H "X-API-KEY: $KEY" -d '{
  "plugins": {
    "limit-count": {
      "count": 5,
      "time_window": 60,
      "key_type": "var",
      "key": "remote_addr",
      "rejected_code": 429
    }
  }
}'

Send seven requests and print the status codes:

for i in $(seq 1 7); do curl -s -o /dev/null -w '%{http_code}\n' -H "apikey: your_api_key" http://127.0.0.1:9080/whoami; done
200
200
200
200
200
429
429

Accepted responses include X-RateLimit-Limit and X-RateLimit-Remaining headers. The counters are kept in each APISIX node's memory; with several nodes, set "policy": "redis" and the Redis connection fields so they share the count.

Step 8 - Reviewing the configuration

Read back the route with all its plugins:

curl -s "$ADMIN/routes/whoami" -H "X-API-KEY: $KEY" | python3 -m json.tool

Everything you created lives in etcd, in the etcd-data volume, and survives container restarts. Back it up with an etcd snapshot:

docker compose exec etcd /usr/local/bin/etcdctl snapshot save /etcd-data/backup.db
docker compose cp etcd:/etcd-data/backup.db ./etcd-backup-$(date +%F).db

APISIX logs every request to standard output, so you can follow traffic with:

docker compose logs -f apisix

If you use UFW, allow the gateway port for clients:

sudo ufw allow 9080/tcp

Docker publishes ports through its own iptables rules, so a published port is reachable even without a UFW rule. Keep the Admin API bound to 127.0.0.1 as configured above.

Troubleshooting

  • The APISIX container restarts with failed to fetch data from etcd: APISIX cannot reach etcd. Check docker compose logs etcd and make sure deployment.etcd.host in config.yaml matches the service name etcd.
  • The container exits with a permission error on config.yaml: the file is not readable by the user inside the image. Make sure it is world-readable with chmod 644 conf/config.yaml.
  • The Admin API returns 403: the request's source address is not in allow_admin. Check the setting and restart with docker compose restart apisix after changing config.yaml.
  • Requests return 404 Route Not Found: no route matches the path, method or host. List routes with curl -s "$ADMIN/routes" -H "X-API-KEY: $KEY" and compare uris and methods.
  • Requests return 502 or 503: no upstream node is reachable or healthy. Confirm the backend containers are running with docker compose ps.

Conclusion

APISIX is now running with etcd, routing traffic to a load-balanced upstream with active health checks, protected by API keys and a per-client rate limit, all configured live through the Admin API. From here you can add TLS by uploading certificates through the /apisix/admin/ssls endpoint and publishing port 9443, expose Prometheus metrics with the prometheus plugin, or keep your routes in version control with the adc declarative CLI.