{"id":51577855,"url":"https://github.com/ssalmutairi/gw-proxy","last_synced_at":"2026-07-11T02:30:28.502Z","repository":{"id":363735141,"uuid":"1264679019","full_name":"ssalmutairi/gw-proxy","owner":"ssalmutairi","description":"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.","archived":false,"fork":false,"pushed_at":"2026-06-10T06:11:44.000Z","size":37,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T07:16:14.477Z","etag":null,"topics":["api-gateway","api-key","docker","gateway","kubernetes","nginx","reverse-proxy"],"latest_commit_sha":null,"homepage":null,"language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ssalmutairi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-10T05:02:00.000Z","updated_at":"2026-06-10T06:11:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ssalmutairi/gw-proxy","commit_stats":null,"previous_names":["ssalmutairi/gw-proxy"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/ssalmutairi/gw-proxy","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ssalmutairi%2Fgw-proxy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ssalmutairi%2Fgw-proxy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ssalmutairi%2Fgw-proxy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ssalmutairi%2Fgw-proxy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ssalmutairi","download_url":"https://codeload.github.com/ssalmutairi/gw-proxy/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ssalmutairi%2Fgw-proxy/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35349259,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-11T02:00:05.354Z","response_time":104,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api-gateway","api-key","docker","gateway","kubernetes","nginx","reverse-proxy"],"created_at":"2026-07-11T02:30:27.802Z","updated_at":"2026-07-11T02:30:28.481Z","avatar_url":"https://github.com/ssalmutairi.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# gw-proxy\n\nA small, Dockerized nginx reverse proxy that routes requests to multiple upstream services\nbased on the first URL path segment, enforces an `x-api-key` allow-list per segment, and\nstrips the segment prefix and the API-key header before forwarding to the upstream.\n\nDesigned to be configured entirely through environment variables, so it can be driven by a\nKubernetes Deployment's `env:` (or ConfigMaps/Secrets if you prefer).\n\nPrebuilt multi-arch image (linux/amd64 + linux/arm64):\n`ghcr.io/ssalmutairi/gw-proxy:1.3.0`.\n\n## Features\n\n- **Path-segment routing** — `GET /app1/...` → upstream A, `GET /app2/...` → upstream B,\n  with the segment prefix stripped before forwarding.\n- **Per-segment API-key auth** — independent `x-api-key` allow-list per segment; the key\n  header is stripped before the upstream call.\n- **Optional rate limiting** — opt-in per-client-IP `limit_req` (off by default); returns\n  `429` over the limit.\n- **Upstream keepalive** — pooled, reused backend connections for throughput.\n- **Runtime DNS resolution** — a missing/not-yet-ready upstream returns `502` instead of\n  blocking startup; Service IP changes are picked up without a restart.\n- **Hardened \u0026 stateless** — non-root, all capabilities dropped, no mounted volumes;\n  scales horizontally.\n- **Zero-file config** — everything is driven by environment variables.\n\n## Behavior\n\n- `\u003csegment\u003e/\u003cpath\u003e` is forwarded to `\u003cupstream\u003e/\u003cpath\u003e` (segment prefix stripped). Any\n  HTTP method is proxied, not just `GET`.\n- `x-api-key` is required on every proxied request. Each segment has its own allow-list of\n  accepted keys. Missing or unknown key → `401`. The header is stripped before the upstream\n  call so backends never see the gateway-side secret.\n- `GET /healthz` and `GET /readyz` always return `200` without auth (for K8s probes).\n\n## Environment variable contract\n\nSegments are defined by **contiguous** indexed env vars starting at `1`. The container\ndiscovers segments by incrementing the index until a `SEGMENT_N_NAME` is missing.\n\nFor each segment `N`:\n\n| Variable             | Required | Example                                    |\n| -------------------- | -------- | ------------------------------------------ |\n| `SEGMENT_N_NAME`     | yes      | `app1` (must match `[a-zA-Z0-9_]+`)        |\n| `SEGMENT_N_UPSTREAM` | yes      | `http://app1-svc:8080` (`scheme://host[:port]`, no path) |\n| `SEGMENT_N_API_KEYS` | yes      | `key-abc,key-def`                          |\n\nGlobal (optional):\n\n| Variable               | Default | Notes                                                  |\n| ---------------------- | ------- | ------------------------------------------------------ |\n| `LISTEN_PORT`          | `8080`  | Port nginx listens on. Default is unprivileged since the container runs as non-root. |\n| `CLIENT_MAX_BODY_SIZE` | `10m`   | Inbound request body limit.                            |\n| `RATE_LIMIT`           | `off`   | Per-client-IP request rate, e.g. `10r/s` or `600r/m`. `off` (default) disables rate limiting. |\n| `RATE_LIMIT_BURST`     | `20`    | Burst allowance above the rate (only when `RATE_LIMIT` is set); excess → `429`. |\n| `UPSTREAM_KEEPALIVE`   | `32`    | Idle keepalive connections cached per upstream.        |\n| `RESOLVER`             | _auto_  | DNS server for runtime upstream resolution. Defaults to the first `nameserver` in `/etc/resolv.conf` (CoreDNS in K8s). |\n| `RESOLVER_VALID`       | `30s`   | How long resolved upstream IPs are cached before re-resolving. |\n\nRate limiting is **opt-in and off by default** — a proxy shouldn't throttle unless asked.\nSet `RATE_LIMIT` (e.g. `100r/s`) to enable a per-client-IP limit (`$binary_remote_addr`),\nshared across all segments and applied to proxied locations only (`/healthz` and `/readyz`\nare never limited). Requests over the limit get `429`. Note: nginx sees the IP of whatever\nconnects to it, so behind a load balancer it limits per-LB-IP unless `X-Forwarded-For` is\nhandled (see \"Heavy load\" below).\n\nEach upstream is wired through a named `upstream` block with a keepalive connection pool,\nso proxied requests reuse TCP connections to the backend instead of opening a fresh one\nper request — the main throughput win under load. Tune the pool size with\n`UPSTREAM_KEEPALIVE`.\n\nUpstream hostnames are resolved **at runtime** (the `resolve` parameter, backed by\n`RESOLVER`) and re-resolved every `RESOLVER_VALID`. This means the gateway starts even\nwhen an upstream name doesn't resolve yet — requests to that segment get `502` instead of\nnginx failing to boot — and it picks up Service IP changes without a restart. Auth and\nrate limiting still apply before the upstream is contacted, so a down backend never\nbypasses the key check.\n\nAccess logs use nginx's default `combined` format on stdout; the error log goes to stderr.\n\nThe container exits non-zero with a clear error if `SEGMENT_1_NAME` is unset, if a\npresent segment is missing its upstream or keys, if a name fails the regex, if an upstream\nends with `/`, or if an upstream is not `scheme://host[:port]` (http/https, no path).\n\n## Local Docker\n\n```bash\n# Local single-arch build for your host:\ndocker build -t gw-proxy:1.3.0 .\n\n# Multi-arch publish (amd64 + arm64) to a registry — what the released image uses:\n#   docker buildx build --platform linux/amd64,linux/arm64 --provenance=false \\\n#     -t ghcr.io/\u003cuser\u003e/gw-proxy:1.3.0 --push .\n\n# Two upstreams for testing\ndocker run --rm -d --name up1 -p 9001:80 kennethreitz/httpbin\ndocker run --rm -d --name up2 -p 9002:80 kennethreitz/httpbin\n\n# The image runs as non-root and listens on 8080, so map host 8080 -\u003e container 8080.\ndocker run --rm -p 8080:8080 \\\n  -e SEGMENT_1_NAME=app1 \\\n  -e SEGMENT_1_UPSTREAM=http://host.docker.internal:9001 \\\n  -e SEGMENT_1_API_KEYS=k1a,k1b \\\n  -e SEGMENT_2_NAME=app2 \\\n  -e SEGMENT_2_UPSTREAM=http://host.docker.internal:9002 \\\n  -e SEGMENT_2_API_KEYS=k2a \\\n  gw-proxy:1.3.0\n```\n\nVerify:\n\n```bash\ncurl -i localhost:8080/healthz                                      # 200\ncurl -i localhost:8080/app1/get                                     # 401\ncurl -i -H 'x-api-key: wrong' localhost:8080/app1/get               # 401\ncurl -i -H 'x-api-key: k1a'   localhost:8080/app1/get               # 200 (upstream sees /get, no x-api-key)\ncurl -i -H 'x-api-key: k1a'   localhost:8080/app2/get               # 401 (key valid for app1 only)\ncurl -i -H 'x-api-key: k2a'   localhost:8080/app2/get               # 200\n```\n\n## Kubernetes\n\nOnly two manifests: `k8s/deployment.yaml` and `k8s/service.yaml`, both in the `gw-proxy`\nnamespace. All config — including the per-segment `SEGMENT_N_API_KEYS` — is inline in the\nDeployment's `env:` for simplicity.\n\n```bash\nkubectl create namespace gw-proxy\nkubectl apply -f k8s/deployment.yaml -f k8s/service.yaml\n```\n\nTo add a segment: append `SEGMENT_N_NAME` / `SEGMENT_N_UPSTREAM` / `SEGMENT_N_API_KEYS` to\nthe Deployment's `env:`, then `kubectl rollout restart deploy/gw-proxy -n gw-proxy`.\n\n\u003e **Important:** `SEGMENT_N_API_KEYS` in `deployment.yaml` ship as non-functional\n\u003e placeholders (`CHANGE_ME_*`) — replace them before deploying, or the gateway only accepts\n\u003e a value that's public in this repo. For anything beyond testing, move the keys into a\n\u003e Kubernetes Secret (referenced via `envFrom: secretRef`) and keep them out of version\n\u003e control.\n\n### Pod hardening\n\nThe deployment runs locked down: non-root (`runAsUser: 101`), all Linux capabilities\ndropped, `allowPrivilegeEscalation: false`, and the `RuntimeDefault` seccomp profile.\nNon-root means the container can't bind port 80, so it listens on `8080` and the Service\nmaps `80 → http(8080)`.\n\nThe pod is **stateless and mounts no volumes**. The image is built so the only paths\nnginx writes to are owned by the `nginx` user (uid 101) — `/etc/nginx/conf.d` (rendered\nconfig) and `/var/cache/nginx` (temp files) — and the pid file is relocated to `/tmp`.\nEverything lands on the container's own ephemeral writable layer, so pods carry no state\nand can be freely added, removed, or rescheduled. To scale under heavy load, raise\n`replicas`:\n\n```bash\nkubectl scale deploy/gw-proxy --replicas=10\n```\n\nThe trade-off of the no-volumes design is that `readOnlyRootFilesystem` is *not* enabled,\nsince a read-only root would require mounting writable volumes for the scratch paths\nabove.\n\n### Heavy load\n\nThe proxy is built to scale horizontally for heavy traffic. The pieces that matter:\n\n- **Upstream keepalive.** Each segment proxies through a named `upstream` block with a\n  keepalive pool (`proxy_http_version 1.1` + cleared `Connection` header), so backend TCP\n  connections are reused instead of reopened per request. Size the pool with\n  `UPSTREAM_KEEPALIVE`.\n- **No CPU limit.** The deployment sets a CPU *request* (a guaranteed floor) but no CPU\n  *limit*, so the proxy isn't CFS-throttled and can burst into spare node capacity under\n  load. A memory limit is kept to bound a runaway pod.\n- **Replica count.** The pod is stateless, so scaling is just `kubectl scale` (or a higher\n  `replicas` in the manifest). Size it to your peak RPS; a PodDisruptionBudget is worth\n  adding so rollouts/drains don't take all replicas at once.\n- **Rate limit vs. shared source IPs.** Rate limiting is off by default. If you enable\n  `RATE_LIMIT`, it's per client IP — behind a NAT or L4 load balancer that hides the real\n  client, many users share one IP and can trip `429`. Terminate XFF correctly (or set the\n  limit high) in those topologies.\n\n## How it works\n\n`docker-entrypoint.sh` runs before nginx starts. It:\n\n1. Iterates `SEGMENT_N_*` env vars until a gap is found.\n2. Per segment, emits an `upstream backend_\u003csegment\u003e { ... keepalive ...}` block and a\n   `map $http_x_api_key $valid_key_\u003csegment\u003e { ... }`.\n3. Emits one `location /\u003csegment\u003e/ { ... }` per segment that returns `401` unless the map\n   resolved to `1`, then forwards via `proxy_pass \u003cscheme\u003e://backend_\u003csegment\u003e/;` (trailing\n   slash strips the prefix) with `X-Api-Key` cleared and upstream keepalive enabled.\n4. Runs `nginx -t` to validate the rendered config, then `exec nginx -g 'daemon off;'`.\n\n## Limitations\n\n- **No TLS termination.** The gateway listens on plain HTTP and is meant to sit behind an\n  ingress controller or load balancer that terminates TLS.\n- **Routing is by first path segment only** — no host-based routing, header matching, or\n  regex paths.\n- **API keys are static** — defined via env vars; no rotation, expiry, or per-key rate\n  limits. The header allow-list is a coarse gate, not a full auth system.\n- **Upstreams must be `scheme://host[:port]`** (http/https, no path component).\n- **Rate limiting (when enabled) is per source IP** — it's off by default; if you turn it\n  on behind a NAT/L4 LB that hides the client, requests share an IP, so terminate\n  `X-Forwarded-For` correctly or raise `RATE_LIMIT`.\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fssalmutairi%2Fgw-proxy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fssalmutairi%2Fgw-proxy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fssalmutairi%2Fgw-proxy/lists"}