Kong Gateway is an API gateway built on NGINX and OpenResty. It sits in front of your APIs and handles routing, authentication, rate limiting and load balancing through plugins, so each service does not have to implement them. In this tutorial you will run Kong Gateway with a PostgreSQL database on Ubuntu 24.04 using Docker Compose, publish a sample service, and protect it with API key authentication and rate limiting through the Admin API.
This guide uses the open source image kong:3.9, the last open source release published in the Docker Official Images library. Newer versions are distributed as Kong Gateway Enterprise (kong/kong-gateway) and follow the same Admin API shown here.
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 8000 (or 80, if you change the mapping) open for API traffic. The Admin API will only listen on localhost.
Step 1 - Creating the Compose project
Kong stores services, routes, consumers and plugins in PostgreSQL. The Compose project will run three containers: the database, a one-time job that creates Kong's schema, and Kong itself. It also starts two copies of traefik/whoami, a tiny web server that prints the container's hostname, to act as the backend API.
Create a project directory:
mkdir -p ~/kong && cd ~/kong
Store the database password in an .env file that Compose reads automatically. Generate a random one:
echo "KONG_PG_PASSWORD=$(openssl rand -hex 24)" > .env
chmod 600 .env
Create the Compose file:
nano compose.yaml
services:
kong-db:
image: postgres:17
environment:
POSTGRES_USER: kong
POSTGRES_DB: kong
POSTGRES_PASSWORD: ${KONG_PG_PASSWORD}
volumes:
- kong-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "kong", "-d", "kong"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
kong-migrations:
image: kong:3.9
command: kong migrations bootstrap
environment: &kong-env
KONG_DATABASE: postgres
KONG_PG_HOST: kong-db
KONG_PG_USER: kong
KONG_PG_PASSWORD: ${KONG_PG_PASSWORD}
KONG_PG_DATABASE: kong
depends_on:
kong-db:
condition: service_healthy
restart: on-failure
kong:
image: kong:3.9
environment:
<<: *kong-env
KONG_PROXY_LISTEN: 0.0.0.0:8000
KONG_ADMIN_LISTEN: 0.0.0.0:8001
KONG_PROXY_ACCESS_LOG: /dev/stdout
KONG_PROXY_ERROR_LOG: /dev/stderr
KONG_ADMIN_ACCESS_LOG: /dev/stdout
KONG_ADMIN_ERROR_LOG: /dev/stderr
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
depends_on:
kong-migrations:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "kong", "health"]
interval: 10s
timeout: 10s
retries: 10
restart: unless-stopped
whoami1:
image: traefik/whoami
hostname: whoami1
restart: unless-stopped
whoami2:
image: traefik/whoami
hostname: whoami2
restart: unless-stopped
volumes:
kong-db-data:
A few details matter here:
kong migrations bootstrapcreates the database schema. It only has to succeed once, and Kong waits for it throughservice_completed_successfully. On later starts the job detects the existing schema and exits immediately.- Inside the container the Admin API listens on all interfaces, but the port mapping
127.0.0.1:8001:8001publishes it only on the server's loopback address. The Admin API has no authentication in the open source edition, so anyone who can reach it controls the gateway. - The
whoamicontainers are not published at all. Kong reaches them over the Compose network by service name.
Start the stack:
docker compose up -d
Check that every container is up and Kong reports healthy. The migrations job shows as exited, which is expected:
docker compose ps -a
NAME IMAGE SERVICE STATUS
kong-kong-1 kong:3.9 kong Up 40 seconds (healthy)
kong-kong-db-1 postgres:17 kong-db Up 52 seconds (healthy)
kong-kong-migrations-1 kong:3.9 kong-migrations Exited (0) 42 seconds ago
kong-whoami1-1 traefik/whoami whoami1 Up 52 seconds
kong-whoami2-1 traefik/whoami whoami2 Up 52 seconds
Query the Admin API to confirm Kong is connected to the database:
curl -s http://127.0.0.1:8001/status
{"database":{"reachable":true},"memory":{...},"server":{"connections_accepted":1,"connections_active":1,...}}
Step 2 - Adding a service and a route
Kong's configuration has two core objects:
- A service represents an upstream API: its protocol, host, port and path.
- A route defines which incoming requests go to that service, matched by path, host, method or header.
Create a service that points to the first whoami container:
curl -s -X POST http://127.0.0.1:8001/services \
--data name=whoami \
--data url=http://whoami1:80
Add a route that sends every request under /whoami to it:
curl -s -X POST http://127.0.0.1:8001/services/whoami/routes \
--data name=whoami-route \
--data 'paths[]=/whoami'
By default Kong strips the matched path prefix (strip_path=true), so a request to /whoami/test reaches the backend as /test.
Test the route through the proxy port:
curl -s http://127.0.0.1:8000/whoami
Hostname: whoami1
IP: 127.0.0.1
IP: 172.18.0.4
RemoteAddr: 172.18.0.6:48312
GET / HTTP/1.1
Host: whoami1
User-Agent: curl/8.5.0
Via: 1.1 kong/3.9.3
X-Forwarded-For: 172.18.0.1
X-Forwarded-Host: 127.0.0.1
X-Forwarded-Path: /whoami
X-Forwarded-Port: 8000
X-Forwarded-Prefix: /whoami
X-Forwarded-Proto: http
...
The response shows that Kong forwarded the request to whoami1 and added the X-Forwarded-* headers, including the original path.
Step 3 - Load balancing across two backends
To spread traffic across several instances, create an upstream (a virtual hostname) and add targets to it:
curl -s -X POST http://127.0.0.1:8001/upstreams --data name=whoami-upstream
curl -s -X POST http://127.0.0.1:8001/upstreams/whoami-upstream/targets --data target=whoami1:80
curl -s -X POST http://127.0.0.1:8001/upstreams/whoami-upstream/targets --data target=whoami2:80
Point the service at the upstream name instead of a single container:
curl -s -X PATCH http://127.0.0.1:8001/services/whoami --data host=whoami-upstream
Kong balances between targets with a weighted round robin by default. Send a few requests and look only at the hostname:
for i in 1 2 3 4; do curl -s http://127.0.0.1:8000/whoami | grep Hostname; done
Hostname: whoami1
Hostname: whoami2
Hostname: whoami1
Hostname: whoami2
Upstreams also support active and passive health checks through the healthchecks.* fields, which remove a target that stops responding.
Step 4 - Requiring an API key
Plugins add behavior to a service, a route, a consumer or the whole gateway. Enable the key-auth plugin on the service so that every request needs a valid key:
curl -s -X POST http://127.0.0.1:8001/services/whoami/plugins --data name=key-auth
A request without a key is now rejected:
curl -i http://127.0.0.1:8000/whoami
HTTP/1.1 401 Unauthorized
...
{
"message":"No API key found in request",
...
}
Keys belong to consumers, which represent the applications or users calling your API. Create a consumer and give it a key. Replace your_api_key with a long random value, for example the output of openssl rand -hex 32:
curl -s -X POST http://127.0.0.1:8001/consumers --data username=mobile-app
curl -s -X POST http://127.0.0.1:8001/consumers/mobile-app/key-auth --data key=your_api_key
If you omit key, Kong generates one and returns it in the response.
Send the key in the apikey header, the plugin's default:
curl -s -H "apikey: your_api_key" http://127.0.0.1:8000/whoami | grep -E 'Hostname|X-Consumer'
Hostname: whoami1
X-Consumer-Id: 8f5d7e61-...
X-Consumer-Username: mobile-app
Kong tells the backend which consumer made the request through the X-Consumer-* headers, so your API can use them without handling keys itself.
Step 5 - Adding rate limiting
Enable the rate-limiting plugin on the service to allow 5 requests per minute per consumer:
curl -s -X POST http://127.0.0.1:8001/services/whoami/plugins \
--data name=rate-limiting \
--data config.minute=5 \
--data config.limit_by=consumer \
--data config.policy=local
policy=local keeps the counters in each Kong node's memory, which is the fastest option and correct for a single node. With several Kong nodes, use policy=redis so all nodes share the counters.
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:8000/whoami; done
200
200
200
200
200
429
429
Successful responses carry headers such as X-RateLimit-Remaining-Minute so clients can slow down before they hit the limit.
Step 6 - Reviewing and exporting the configuration
The Admin API lists every object you created:
curl -s http://127.0.0.1:8001/services | python3 -m json.tool
curl -s http://127.0.0.1:8001/routes | python3 -m json.tool
curl -s http://127.0.0.1:8001/plugins | python3 -m json.tool
Configuration changes made through the Admin API are stored in PostgreSQL and survive container restarts. For version control, Kong's deck CLI can export the whole configuration to a YAML file and apply it back, which lets you review gateway changes like code. Back up the database itself with pg_dump:
docker compose exec -T kong-db pg_dump -U kong kong | gzip > kong-$(date +%F).sql.gz
Step 7 - Exposing Kong to clients
Clients currently reach Kong on port 8000 over plain HTTP. If you use UFW, open the port:
sudo ufw allow 8000/tcp
Docker publishes ports by editing iptables directly, so a published port is reachable even when UFW has no rule for it. This is one more reason to publish the Admin API only on 127.0.0.1.
For production traffic, terminate TLS in front of Kong with a reverse proxy such as NGINX, Caddy or HAProxy and forward to 127.0.0.1:8000, or configure Kong's own SSL listener (KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl") and upload certificates through the /certificates endpoint.
Troubleshooting
kongnever starts and the migrations container keeps restarting: the database is not reachable or the password differs from the one PostgreSQL was initialized with. Checkdocker compose logs kong-migrations. If you changedKONG_PG_PASSWORDafter the first start, the old password is still stored in the volume.{"message":"no Route matched with those values"}: the request path or host does not match any route. List the routes withcurl -s http://127.0.0.1:8001/routesand checkpathsandhosts.502 Bad GatewayorAn invalid response was received from the upstream server: Kong cannot reach the backend. Check that the backend containers are running withdocker compose ps, and that the service'shostandport(shown bycurl -s http://127.0.0.1:8001/services/whoami) match an existing upstream or container name.- Admin API is unreachable: make sure you are calling
127.0.0.1:8001on the server itself, or forward it over SSH withssh -L 8001:127.0.0.1:8001 your_user@your_server_ip.
Conclusion
Kong Gateway is now running with PostgreSQL, routing traffic to a load-balanced service that requires an API key and enforces a rate limit per consumer. From here you can put TLS in front of the proxy port, manage the configuration declaratively with deck, and explore other bundled plugins such as cors, jwt, ip-restriction and prometheus for metrics.
