Docs
API7 GatewayManage traffic and APIsProtocol ProxyConfigure TCP and UDP Proxying

Configure TCP and UDP Proxying

Proxy a MySQL TCP service and a UDP echo service through API7 Gateway stream routes.

Applies to:API7 Gateway 3.10.x

Roles:Platform engineer

Goal

Expose a MySQL server on TCP port 2000 and an echo server on UDP port 2001 through API7 Gateway. The two stream routes use different listeners and upstream schemes, so both Layer 4 protocols are exercised.

This guide also applies limit-conn (opens in Plugin Hub docs) to the TCP route by client address. The local upstreams are for evaluation only.

Before you begin

Set the control-plane and stream endpoint variables:

export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default"   # only the group the quickstart creates; otherwise copy the gateway group's UUID from Gateway Groups in the Dashboard
read -rp "Gateway group name for ADC (if used): " API7_GATEWAY_GROUP_NAME && export API7_GATEWAY_GROUP_NAME
read -rp "Gateway host for TCP and UDP clients: " STREAM_GATEWAY_HOST && export STREAM_GATEWAY_HOST
export TCP_STREAM_PORT="2000"
export UDP_STREAM_PORT="2001"
read -rp "Host or DNS name the gateway uses to reach the sample upstreams: " STREAM_UPSTREAM_HOST && export STREAM_UPSTREAM_HOST
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo

For a Docker Desktop gateway and upstreams published on the same host, STREAM_GATEWAY_HOST is usually 127.0.0.1 and STREAM_UPSTREAM_HOST is host.docker.internal. For another deployment, use addresses reachable from the client and gateway respectively.

  • Confirm that the control plane and group are ready:

    curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" -H "X-API-KEY: ${API7_ADMIN_KEY}"
    curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/gateway_groups/${API7_GATEWAY_GROUP_ID}/instances" -H "X-API-KEY: ${API7_ADMIN_KEY}" | grep -o '"status": *"Healthy"' | wc -l

    Expected: 200, then 1 or more.

  • Docker, Python 3, a MySQL client, curl, jq, and GNU envsubst are available. The gateway can reach ${STREAM_UPSTREAM_HOST}:3306 over TCP and ${STREAM_UPSTREAM_HOST}:3001 over UDP.

  • Ports 3306/TCP and 3001/UDP, the container name api7-stream-mysql, and the local files used below are free:

    if docker container inspect api7-stream-mysql >/dev/null 2>&1; then echo 'Stop: container api7-stream-mysql already exists'; exit 1; fi
    if docker container ls --format '{{.Ports}}' | grep -Eq '(^|, |:)3306->'; then echo 'Stop: a Docker container already publishes local port 3306'; exit 1; fi
    for file in udp-echo.py udp-echo.pid udp-echo.log stream-adc.yaml.tmpl stream-adc.yaml stream-cleanup.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done
    python3 - <<'PY'
    import socket
    
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
        probe.bind(("0.0.0.0", 3001))
    print("UDP port 3001 is free")
    PY
  • The gateway already listens on and exposes 2000/TCP and 2001/UDP. Admin API and ADC resources do not add runtime listeners. Merge this fragment into the existing gateway configuration through your deployment method, preserve every other setting, publish both protocols, and wait for the gateway rollout:

    config.yaml fragment
    apisix:
      stream_proxy:
        only: false
        tcp:
          - 2000
        udp:
          - 2001

    For Kubernetes installations using API7 Gateway chart gateway-3.10.3, the equivalent values are:

    values override for gateway-3.10.3
    gateway:
      stream:
        enabled: true
        only: false
        tcp:
          - addr: 2000
        udp:
          - addr: 2001

    Follow the backup, exact-version upgrade, rollout, and rendered ConfigMap checks in Helm Chart. If the installed chart has another version, inspect that version's values and templates first. For a container deployment, both container listeners also need host or load-balancer mappings; TCP and UDP mappings are distinct.

  • For ADC, confirm label-selector support:

    adc --version
    adc sync --help | grep -- '--label-selector'

    ADC resolves --gateway-group by display name. Enter the display name for the same group whose UUID is in API7_GATEWAY_GROUP_ID.

This guide creates two services and two stream routes. Confirm that none of their fixed IDs already exists:

for id in stream-tcp-service stream-udp-service; do
  curl -ksS --connect-timeout 3 --max-time 10 -w "\nservice ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/services/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
for id in stream-tcp-route stream-udp-route; do
  curl -ksS --connect-timeout 3 --max-time 10 -w "\nroute ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/stream_routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done

Expected: every resource is absent. A 200 means the ID is in use; stop or change every occurrence because PUT replaces a resource at that ID. For ADC, continue only if the diff creates exactly these labeled resources without updating or deleting existing objects.

Steps

Step 1: Start the sample upstreams

Start MySQL and wait up to 60 seconds for it to accept connections:

docker run -d --name api7-stream-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mysql:8.4 --mysql-native-password=ON
for attempt in {1..30}; do
  docker exec -e MYSQL_PWD=password api7-stream-mysql mysqladmin ping --connect-timeout=2 -h 127.0.0.1 -uroot --silent && break
  sleep 2
done
docker exec -e MYSQL_PWD=password api7-stream-mysql mysqladmin ping --connect-timeout=2 -h 127.0.0.1 -uroot --silent

Create and start a UDP echo server:

cat > udp-echo.py <<'PY'
import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
    server.bind(("0.0.0.0", 3001))
    while True:
        data, address = server.recvfrom(65535)
        server.sendto(data, address)
PY

python3 udp-echo.py > udp-echo.log 2>&1 &
echo $! > udp-echo.pid
kill -0 "$(cat udp-echo.pid)"

Verify both upstreams directly from the host:

export MYSQL_PWD="password"
mysql --connect-timeout=5 --protocol=TCP -h 127.0.0.1 -P 3306 -u root -Nse "SELECT 'mysql upstream ready';"
unset MYSQL_PWD

python3 - <<'PY'
import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    client.settimeout(5)
    client.sendto(b"udp upstream ready", ("127.0.0.1", 3001))
    data, _ = client.recvfrom(65535)
print(data.decode())
PY

Expected: mysql upstream ready, then udp upstream ready.

What changed: two local servers now provide separate TCP and UDP targets.

Step 2: Create the stream services and routes with the Admin API

Choose this step or step 3; do not apply both.

jq -n --arg host "${STREAM_UPSTREAM_HOST}" '{name:"stream-tcp-service",type:"stream",labels:{guide:"tcp-udp-proxy"},upstream:{scheme:"tcp",nodes:[{host:$host,port:3306,weight:100}]}}' \
  | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/stream-tcp-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
      -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/stream_routes/stream-tcp-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{"name":"stream-tcp-route","labels":{"guide":"tcp-udp-proxy"},"server_port":2000,"service_id":"stream-tcp-service","plugins":{"limit-conn":{"conn":100,"burst":50,"default_conn_delay":0.1,"key":"remote_addr","key_type":"var"}}}'

jq -n --arg host "${STREAM_UPSTREAM_HOST}" '{name:"stream-udp-service",type:"stream",labels:{guide:"tcp-udp-proxy"},upstream:{scheme:"udp",nodes:[{host:$host,port:3001,weight:100}]}}' \
  | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/stream-udp-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
      -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/stream_routes/stream-udp-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{"name":"stream-udp-route","labels":{"guide":"tcp-udp-proxy"},"server_port":2001,"service_id":"stream-udp-service"}'

server_port selects the gateway listener. The service's upstream scheme selects the protocol used from the gateway to its upstream. With key_type: var, limit-conn.key is the variable name remote_addr without a $ prefix.

What changed: the two listeners now select different labeled services and upstream protocols.

Step 3: Apply both services and routes with ADC

Skip this alternative if you completed step 2. Save one complete desired-state file so that a later sync does not replace one protocol's resources with the other:

stream-adc.yaml.tmpl
services:
  - name: stream-tcp-service
    labels: {guide: tcp-udp-proxy}
    upstream:
      scheme: tcp
      nodes:
        - {host: "${STREAM_UPSTREAM_HOST}", port: 3306, weight: 100}
    stream_routes:
      - name: stream-tcp-route
        labels: {guide: tcp-udp-proxy}
        server_port: 2000
        plugins:
          limit-conn:
            conn: 100
            burst: 50
            default_conn_delay: 0.1
            key: remote_addr
            key_type: var
  - name: stream-udp-service
    labels: {guide: tcp-udp-proxy}
    upstream:
      scheme: udp
      nodes:
        - {host: "${STREAM_UPSTREAM_HOST}", port: 3001, weight: 100}
    stream_routes:
      - name: stream-udp-route
        labels: {guide: tcp-udp-proxy}
        server_port: 2001

Render only the upstream host, then lint, validate, and inspect the selected diff:

envsubst '${STREAM_UPSTREAM_HOST}' < stream-adc.yaml.tmpl > stream-adc.yaml
adc lint -f stream-adc.yaml
adc validate -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy
adc diff -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy

Expected: validation succeeds and the diff creates two labeled services with one nested stream route each. Apply only that reviewed diff:

adc sync -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy

What changed: ADC reconciled the TCP and UDP resources together under one label selector.

Validate

Query MySQL through the gateway TCP listener:

export MYSQL_PWD="password"
mysql --connect-timeout=5 --protocol=TCP -h "${STREAM_GATEWAY_HOST}" -P "${TCP_STREAM_PORT}" -u root -Nse "SELECT 'mysql through API7';"
unset MYSQL_PWD

Expected: mysql through API7.

Send a datagram through the gateway UDP listener and require the same payload back within five seconds:

python3 - <<'PY'
import os
import socket

address = (os.environ["STREAM_GATEWAY_HOST"], int(os.environ["UDP_STREAM_PORT"]))
payload = b"udp through API7"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    client.settimeout(5)
    client.sendto(payload, address)
    data, _ = client.recvfrom(65535)
assert data == payload, (data, payload)
print(data.decode())
PY

Expected: udp through API7.

Roll back / Clean up

For the Admin API path, remove the routes before their services:

for id in stream-udp-route stream-tcp-route; do
  curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/stream_routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
for id in stream-udp-service stream-tcp-service; do
  curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done

For ADC, reconcile an empty selected state after reviewing the diff:

stream-cleanup.yaml
services: []
adc diff -f stream-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy
adc sync -f stream-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy

Stop the sample upstreams and remove their local files:

docker rm -f api7-stream-mysql
kill "$(cat udp-echo.pid)"
rm udp-echo.py udp-echo.pid udp-echo.log stream-adc.yaml.tmpl stream-adc.yaml stream-cleanup.yaml
unset MYSQL_PWD

Remove the two stream listener entries and their TCP/UDP exposure only if you added them solely for this guide. Apply that rollback through the same deployment method and verify its rollout.

Troubleshoot

  • A client cannot connect to either port: confirm the running gateway configuration contains both listeners and that the container, Kubernetes Service, load balancer, and firewall expose the correct protocol for each port. Publishing 2001/TCP does not expose 2001/UDP.
  • The gateway logs an upstream connection error: test 3306/TCP and 3001/UDP from the gateway network namespace. 127.0.0.1 refers to the gateway itself there; use a gateway-reachable address.
  • MySQL fails but UDP succeeds: inspect docker logs api7-stream-mysql, then repeat the direct MySQL check. Keep --connect-timeout=5 so a network failure does not leave the client waiting indefinitely.
  • UDP times out: confirm the echo process is alive with kill -0 "$(cat udp-echo.pid)" and that every network policy and port mapping uses UDP.
  • ADC proposes unrelated changes: stop. Confirm the gateway group display name and label selector, and make sure both protocols remain in the same desired-state file.
  • A TLS stream needs SNI routing: configure a TCP listener with tls: true, install the matching certificate, and use the stream route sni field. Follow Stream Routes; the plaintext samples in this guide do not validate TLS termination.