A bounded Docker lab for testing rate limiting without exposing a target

I wanted a small lab for one narrow question: when a public endpoint receives more requests than it should pass to an origin, can the edge shed the excess while the health signal stays useful?

The answer in this lab was yes. The interesting part was not making requests fail. It was making the rejection deliberate, measurable, and contained.

The boundary mattered before the test did

This is a Docker-only experiment. The target is an Nginx container on a Docker Compose network marked internal: true. It has no published port, so it is unreachable from the host network and from the Internet. The k6 container can only reach the Compose service name target.

I also added a small validator that refuses to run if the lab grows a public port mapping, an external HTTP target, privileged mode, a Docker socket mount, or if its bounded workload settings disappear. A negative test copies the lab to a temporary directory, injects a ports: mapping into the copy, and checks that the validator rejects it.

That may feel fussy for a two-container demo. It is the point. A test harness should make the safe path boring and make accidental retargeting difficult.

The experiment

The target has two routes:

  • / is the protected public route. Nginx applies a per-client request limit of 5 requests per second with a burst of 2.
  • /healthz returns 200 ok and is not part of that public-route limit.

k6 runs two fixed scenarios for 20 seconds:

  • 10 requests per second against /.
  • 1 request per second against /healthz.

The protected-request scenario is intentionally higher than the configured limit. The test treats 200 as an accepted request and 503 as deliberate load shedding. Any other response is a failure. The health scenario must remain 200 throughout.

limit_req_zone $binary_remote_addr zone=per_client:10m rate=5r/s;

location / {
  limit_req zone=per_client burst=2 nodelay;
  root /usr/share/nginx/html;
  index index.html;
  try_files $uri $uri/ =404;
}

location = /healthz {
  add_header Content-Type text/plain;
  return 200 "ok\n";
}

Nginx documents limit_req_zone and limit_req as request-rate controls keyed by a chosen request characteristic. In this lab, the key is the client address. Its module documentation is worth reading before carrying the pattern into a service.

What the run showed

This is the output from the final Docker run:

checks.........................: 100.00% 221 out of 221
http_req_duration..............: p(95)=382.47µs
lab_allowed_responses..........: 102     5.099586/s
lab_shed_rate..................: 49.00%  98 out of 200
lab_shed_responses.............: 98      4.899602/s
lab_health_failures............: 0       0/s
lab_unexpected_responses.......: 0       0/s

The result matches the configuration closely. Of 200 protected-route requests, 102 were served and 98 were rejected before they could make the origin do more work. The health probe completed with no failures.

I used k6's constant-arrival-rate executor because it starts a fixed number of iterations over a period rather than tying the next iteration to completion of the prior one. That makes it useful for a bounded admission-control exercise. See the k6 executor documentation.

What this does and does not demonstrate

The lab demonstrates an application-layer control close to an origin. It does not demonstrate protection against a real distributed denial-of-service event.

A real event might saturate the upstream link before Nginx sees a request. It might spread traffic across a large set of addresses, which changes the usefulness of an address-keyed limit. TLS termination, CDN/WAF rules, caching, connection limits, provider mitigation, and origin isolation all matter outside this tiny setup.

It also would be wrong to expose a broadly reachable health endpoint just because it bypasses a public-route limit. In a deployed service, liveness and readiness checks should come from the orchestrator or internal load balancer and should have their own access boundary. A green liveness signal does not prove that the application can serve users.

The practical lesson

Rate limiting is not a magic DDoS switch. It is a decision about where to reject work and what should remain available when capacity is constrained.

For a production service, I would start with these questions instead of copying the numbers from this lab:

  1. Which endpoints are expensive enough to need their own budget?
  2. What identity is safe to key on: IP address, authenticated principal, API token, tenant, or a combination?
  3. Which failures should be shed early, and which internal health or control paths must still work?
  4. Where will the decision be observed: proxy logs, request metrics, WAF analytics, and origin saturation dashboards?
  5. What is the approved stop condition for an authorized staging test?

The small lab is available locally with a pre-flight validator, a negative guardrail test, and a wrapper that validates before it runs:

cd isolated-load-lab
./lab.sh validate
python3 test_guardrail.py
./lab.sh run
./lab.sh clean

The test is intentionally not configurable with a public URL. If I want to test a real staging service later, that should be a separate reviewed plan with the service owner, a bounded rate, monitoring, and a clear stop condition.

Source code

The complete, bounded lab source is available on GitHub: heyimusa/isolated-docker-rate-limit-lab.

Sources