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
sudoprivileges. - 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: traditionalruns the data plane and the Admin API in the same process, which is the usual setup for a single node.allow_adminis 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 on127.0.0.1in 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. Checkdocker compose logs etcdand make suredeployment.etcd.hostinconfig.yamlmatches the service nameetcd. - 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 withchmod 644 conf/config.yaml. - The Admin API returns
403: the request's source address is not inallow_admin. Check the setting and restart withdocker compose restart apisixafter changingconfig.yaml. - Requests return
404 Route Not Found: no route matches the path, method or host. List routes withcurl -s "$ADMIN/routes" -H "X-API-KEY: $KEY"and compareurisandmethods. - Requests return
502or503: no upstream node is reachable or healthy. Confirm the backend containers are running withdocker 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.
