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 sudo privileges.
  • 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 bootstrap creates the database schema. It only has to succeed once, and Kong waits for it through service_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:8001 publishes 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 whoami containers 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

  • kong never starts and the migrations container keeps restarting: the database is not reachable or the password differs from the one PostgreSQL was initialized with. Check docker compose logs kong-migrations. If you changed KONG_PG_PASSWORD after 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 with curl -s http://127.0.0.1:8001/routes and check paths and hosts.
  • 502 Bad Gateway or An invalid response was received from the upstream server: Kong cannot reach the backend. Check that the backend containers are running with docker compose ps, and that the service's host and port (shown by curl -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:8001 on the server itself, or forward it over SSH with ssh -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.