https://github.com/ssalmutairi/gw-proxy
A small, env-var-configured nginx reverse proxy that routes by URL path segment with per-segment x-api-key auth, upstream keepalive, rate limiting, and a hardened stateless Kubernetes deploy.
https://github.com/ssalmutairi/gw-proxy
api-gateway api-key docker gateway kubernetes nginx reverse-proxy
Last synced: 17 days ago
JSON representation
A small, env-var-configured nginx reverse proxy that routes by URL path segment with per-segment x-api-key auth, upstream keepalive, rate limiting, and a hardened stateless Kubernetes deploy.
- Host: GitHub
- URL: https://github.com/ssalmutairi/gw-proxy
- Owner: ssalmutairi
- License: mit
- Created: 2026-06-10T05:02:00.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-10T06:11:44.000Z (about 2 months ago)
- Last Synced: 2026-06-10T07:16:14.477Z (about 2 months ago)
- Topics: api-gateway, api-key, docker, gateway, kubernetes, nginx, reverse-proxy
- Language: Shell
- Size: 36.1 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# gw-proxy
A small, Dockerized nginx reverse proxy that routes requests to multiple upstream services
based on the first URL path segment, enforces an `x-api-key` allow-list per segment, and
strips the segment prefix and the API-key header before forwarding to the upstream.
Designed to be configured entirely through environment variables, so it can be driven by a
Kubernetes Deployment's `env:` (or ConfigMaps/Secrets if you prefer).
Prebuilt multi-arch image (linux/amd64 + linux/arm64):
`ghcr.io/ssalmutairi/gw-proxy:1.3.0`.
## Features
- **Path-segment routing** — `GET /app1/...` → upstream A, `GET /app2/...` → upstream B,
with the segment prefix stripped before forwarding.
- **Per-segment API-key auth** — independent `x-api-key` allow-list per segment; the key
header is stripped before the upstream call.
- **Optional rate limiting** — opt-in per-client-IP `limit_req` (off by default); returns
`429` over the limit.
- **Upstream keepalive** — pooled, reused backend connections for throughput.
- **Runtime DNS resolution** — a missing/not-yet-ready upstream returns `502` instead of
blocking startup; Service IP changes are picked up without a restart.
- **Hardened & stateless** — non-root, all capabilities dropped, no mounted volumes;
scales horizontally.
- **Zero-file config** — everything is driven by environment variables.
## Behavior
- `/` is forwarded to `/` (segment prefix stripped). Any
HTTP method is proxied, not just `GET`.
- `x-api-key` is required on every proxied request. Each segment has its own allow-list of
accepted keys. Missing or unknown key → `401`. The header is stripped before the upstream
call so backends never see the gateway-side secret.
- `GET /healthz` and `GET /readyz` always return `200` without auth (for K8s probes).
## Environment variable contract
Segments are defined by **contiguous** indexed env vars starting at `1`. The container
discovers segments by incrementing the index until a `SEGMENT_N_NAME` is missing.
For each segment `N`:
| Variable | Required | Example |
| -------------------- | -------- | ------------------------------------------ |
| `SEGMENT_N_NAME` | yes | `app1` (must match `[a-zA-Z0-9_]+`) |
| `SEGMENT_N_UPSTREAM` | yes | `http://app1-svc:8080` (`scheme://host[:port]`, no path) |
| `SEGMENT_N_API_KEYS` | yes | `key-abc,key-def` |
Global (optional):
| Variable | Default | Notes |
| ---------------------- | ------- | ------------------------------------------------------ |
| `LISTEN_PORT` | `8080` | Port nginx listens on. Default is unprivileged since the container runs as non-root. |
| `CLIENT_MAX_BODY_SIZE` | `10m` | Inbound request body limit. |
| `RATE_LIMIT` | `off` | Per-client-IP request rate, e.g. `10r/s` or `600r/m`. `off` (default) disables rate limiting. |
| `RATE_LIMIT_BURST` | `20` | Burst allowance above the rate (only when `RATE_LIMIT` is set); excess → `429`. |
| `UPSTREAM_KEEPALIVE` | `32` | Idle keepalive connections cached per upstream. |
| `RESOLVER` | _auto_ | DNS server for runtime upstream resolution. Defaults to the first `nameserver` in `/etc/resolv.conf` (CoreDNS in K8s). |
| `RESOLVER_VALID` | `30s` | How long resolved upstream IPs are cached before re-resolving. |
Rate limiting is **opt-in and off by default** — a proxy shouldn't throttle unless asked.
Set `RATE_LIMIT` (e.g. `100r/s`) to enable a per-client-IP limit (`$binary_remote_addr`),
shared across all segments and applied to proxied locations only (`/healthz` and `/readyz`
are never limited). Requests over the limit get `429`. Note: nginx sees the IP of whatever
connects to it, so behind a load balancer it limits per-LB-IP unless `X-Forwarded-For` is
handled (see "Heavy load" below).
Each upstream is wired through a named `upstream` block with a keepalive connection pool,
so proxied requests reuse TCP connections to the backend instead of opening a fresh one
per request — the main throughput win under load. Tune the pool size with
`UPSTREAM_KEEPALIVE`.
Upstream hostnames are resolved **at runtime** (the `resolve` parameter, backed by
`RESOLVER`) and re-resolved every `RESOLVER_VALID`. This means the gateway starts even
when an upstream name doesn't resolve yet — requests to that segment get `502` instead of
nginx failing to boot — and it picks up Service IP changes without a restart. Auth and
rate limiting still apply before the upstream is contacted, so a down backend never
bypasses the key check.
Access logs use nginx's default `combined` format on stdout; the error log goes to stderr.
The container exits non-zero with a clear error if `SEGMENT_1_NAME` is unset, if a
present segment is missing its upstream or keys, if a name fails the regex, if an upstream
ends with `/`, or if an upstream is not `scheme://host[:port]` (http/https, no path).
## Local Docker
```bash
# Local single-arch build for your host:
docker build -t gw-proxy:1.3.0 .
# Multi-arch publish (amd64 + arm64) to a registry — what the released image uses:
# docker buildx build --platform linux/amd64,linux/arm64 --provenance=false \
# -t ghcr.io//gw-proxy:1.3.0 --push .
# Two upstreams for testing
docker run --rm -d --name up1 -p 9001:80 kennethreitz/httpbin
docker run --rm -d --name up2 -p 9002:80 kennethreitz/httpbin
# The image runs as non-root and listens on 8080, so map host 8080 -> container 8080.
docker run --rm -p 8080:8080 \
-e SEGMENT_1_NAME=app1 \
-e SEGMENT_1_UPSTREAM=http://host.docker.internal:9001 \
-e SEGMENT_1_API_KEYS=k1a,k1b \
-e SEGMENT_2_NAME=app2 \
-e SEGMENT_2_UPSTREAM=http://host.docker.internal:9002 \
-e SEGMENT_2_API_KEYS=k2a \
gw-proxy:1.3.0
```
Verify:
```bash
curl -i localhost:8080/healthz # 200
curl -i localhost:8080/app1/get # 401
curl -i -H 'x-api-key: wrong' localhost:8080/app1/get # 401
curl -i -H 'x-api-key: k1a' localhost:8080/app1/get # 200 (upstream sees /get, no x-api-key)
curl -i -H 'x-api-key: k1a' localhost:8080/app2/get # 401 (key valid for app1 only)
curl -i -H 'x-api-key: k2a' localhost:8080/app2/get # 200
```
## Kubernetes
Only two manifests: `k8s/deployment.yaml` and `k8s/service.yaml`, both in the `gw-proxy`
namespace. All config — including the per-segment `SEGMENT_N_API_KEYS` — is inline in the
Deployment's `env:` for simplicity.
```bash
kubectl create namespace gw-proxy
kubectl apply -f k8s/deployment.yaml -f k8s/service.yaml
```
To add a segment: append `SEGMENT_N_NAME` / `SEGMENT_N_UPSTREAM` / `SEGMENT_N_API_KEYS` to
the Deployment's `env:`, then `kubectl rollout restart deploy/gw-proxy -n gw-proxy`.
> **Important:** `SEGMENT_N_API_KEYS` in `deployment.yaml` ship as non-functional
> placeholders (`CHANGE_ME_*`) — replace them before deploying, or the gateway only accepts
> a value that's public in this repo. For anything beyond testing, move the keys into a
> Kubernetes Secret (referenced via `envFrom: secretRef`) and keep them out of version
> control.
### Pod hardening
The deployment runs locked down: non-root (`runAsUser: 101`), all Linux capabilities
dropped, `allowPrivilegeEscalation: false`, and the `RuntimeDefault` seccomp profile.
Non-root means the container can't bind port 80, so it listens on `8080` and the Service
maps `80 → http(8080)`.
The pod is **stateless and mounts no volumes**. The image is built so the only paths
nginx writes to are owned by the `nginx` user (uid 101) — `/etc/nginx/conf.d` (rendered
config) and `/var/cache/nginx` (temp files) — and the pid file is relocated to `/tmp`.
Everything lands on the container's own ephemeral writable layer, so pods carry no state
and can be freely added, removed, or rescheduled. To scale under heavy load, raise
`replicas`:
```bash
kubectl scale deploy/gw-proxy --replicas=10
```
The trade-off of the no-volumes design is that `readOnlyRootFilesystem` is *not* enabled,
since a read-only root would require mounting writable volumes for the scratch paths
above.
### Heavy load
The proxy is built to scale horizontally for heavy traffic. The pieces that matter:
- **Upstream keepalive.** Each segment proxies through a named `upstream` block with a
keepalive pool (`proxy_http_version 1.1` + cleared `Connection` header), so backend TCP
connections are reused instead of reopened per request. Size the pool with
`UPSTREAM_KEEPALIVE`.
- **No CPU limit.** The deployment sets a CPU *request* (a guaranteed floor) but no CPU
*limit*, so the proxy isn't CFS-throttled and can burst into spare node capacity under
load. A memory limit is kept to bound a runaway pod.
- **Replica count.** The pod is stateless, so scaling is just `kubectl scale` (or a higher
`replicas` in the manifest). Size it to your peak RPS; a PodDisruptionBudget is worth
adding so rollouts/drains don't take all replicas at once.
- **Rate limit vs. shared source IPs.** Rate limiting is off by default. If you enable
`RATE_LIMIT`, it's per client IP — behind a NAT or L4 load balancer that hides the real
client, many users share one IP and can trip `429`. Terminate XFF correctly (or set the
limit high) in those topologies.
## How it works
`docker-entrypoint.sh` runs before nginx starts. It:
1. Iterates `SEGMENT_N_*` env vars until a gap is found.
2. Per segment, emits an `upstream backend_ { ... keepalive ...}` block and a
`map $http_x_api_key $valid_key_ { ... }`.
3. Emits one `location // { ... }` per segment that returns `401` unless the map
resolved to `1`, then forwards via `proxy_pass ://backend_/;` (trailing
slash strips the prefix) with `X-Api-Key` cleared and upstream keepalive enabled.
4. Runs `nginx -t` to validate the rendered config, then `exec nginx -g 'daemon off;'`.
## Limitations
- **No TLS termination.** The gateway listens on plain HTTP and is meant to sit behind an
ingress controller or load balancer that terminates TLS.
- **Routing is by first path segment only** — no host-based routing, header matching, or
regex paths.
- **API keys are static** — defined via env vars; no rotation, expiry, or per-key rate
limits. The header allow-list is a coarse gate, not a full auth system.
- **Upstreams must be `scheme://host[:port]`** (http/https, no path component).
- **Rate limiting (when enabled) is per source IP** — it's off by default; if you turn it
on behind a NAT/L4 LB that hides the client, requests share an IP, so terminate
`X-Forwarded-For` correctly or raise `RATE_LIMIT`.
## License
[MIT](LICENSE)