---
title: 'Reverse Proxy Configuration'
source: 'https://academia.sh/en/courses/backend-production/reverse-proxy-configuration'
course: 'Server Security and Going to Production'
language: en
updated: '2026-08-23T07:00:26+00:00'
license: 'CC BY-SA 4.0'
---

# Reverse Proxy Configuration

What the reverse proxy leaves out for the application: the client address and scheme information lost after TLS termination, restored through header forwarding; the rate limit collapsing into a single bucket while forwarding is off, and the audit log showing a single address; the wrong record returning successfully when the path is resolved once too often at the proxy; and how the body-size limit sitting separately at two layers changes the effective value and the log.

The previous topic built every defense inside the application: the validator, the allowlist,
the secrets store, the audit log, the rate limiter. Each one read the request and made a
decision, and all of them leaned on one assumption — **the request the application sees is the
request the client sent.**

In production this assumption does not hold. The application never stands alone: a reverse
proxy sits in front of it, a process manager sits underneath it. Both touch the request and
both are configured, and their configuration changes what the application sees. What a reverse
proxy is was established in the Load Balancer and Reverse Proxy lesson; this lesson measures
its **settings** instead.

## The Gap Termination Leaves

TLS termination happens at the proxy: the encrypted connection ends there, and the connection
between the proxy and the application carries no encryption. This takes two pieces of
information away from the application. The first is the **client address**: the other end of
the connection the application sees is no longer the client, it is the proxy. The second is the
**scheme**: the connection reaching the application is unencrypted, and nothing in it records
whether the original request arrived encrypted.

Neither is lost — it is **forwarded**: the proxy adds two headers to the request. Forwarding is
a setting, and off, the application raises no error; it just reads the wrong value.

- **SD1.** On a single machine, every client shares the same loopback address. At the proxy,
  the member address is derived from the source-port block; in a real deployment it comes
  directly from the connection.
- **SD2.** The lesson does not measure encryption itself, only the information loss
  termination leaves behind.
- **SD3.** The rate limit is five requests per identity in a single-run window; the audit log
  writes each request under the identity it resolved.

The loan application takes identity from the header first, and from the connection if there
is none.

```js
// topology/app.mjs — loan application: reads identity, scheme, and body limit from the request.
// Identity comes first from the "x-forwarded-for" header, otherwise from the connection itself.
import { createServer } from "node:http";

const [port, bodyLimit] = [Number(process.argv[2]), Number(process.argv[3] ?? 16384)];
const LIMIT = 5;                                   // requests per identity
const CATALOG = { "KTP/17": "Regional History", "KTP%2F17": "Regional History Supplement" };
const counter = new Map();
const stats = { requests: 0, passed: 0, stopped: 0, identity: new Set(), bytesRead: 0 };

function resolveIdentity(req) {
  const forwarded = req.headers["x-forwarded-for"];
  return forwarded ?? req.socket.remoteAddress;      // no header: the other end of the connection
}

if (Number.isInteger(port) === false) console.log("usage: node topology/app.mjs <port> [body-limit]");
else createServer((req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;
  if (path === "/measure") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ...stats, identity: stats.identity.size }));
    return;
  }
  const identity = resolveIdentity(req);
  stats.requests += 1;
  stats.identity.add(identity);
  const n = (counter.get(identity) ?? 0) + 1;
  counter.set(identity, n);
  if (n > LIMIT) {
    stats.stopped += 1;
    res.writeHead(429, { "x-identity": identity }).end("limit");
    return;
  }
  stats.passed += 1;
  if (req.headers["x-forwarded-proto"] !== "https") {
    res.writeHead(308, { location: path, "x-identity": identity }).end("to secure scheme");
    return;
  }
  if (path.startsWith("/book/")) {
    const bookId = decodeURIComponent(path.split("/")[2] ?? "");
    const title = CATALOG[bookId];
    if (title === undefined) res.writeHead(404, { "x-identity": identity }).end("not found");
    else res.writeHead(200, { "x-record": title, "x-identity": identity }).end(title);
    return;
  }
  if (req.method === "POST") {
    let bytes = 0;
    req.on("data", (p) => {
      bytes += p.length;
      stats.bytesRead += p.length;
      if (bytes > bodyLimit) { res.writeHead(413, { "x-layer": "app" }).end("too large"); req.destroy(); }
    });
    req.on("end", () => { if (res.writableEnded === false) res.writeHead(200, { "x-identity": identity }).end(String(bytes)); });
    return;
  }
  res.writeHead(200, { "x-identity": identity }).end("loan");
}).listen(port, "127.0.0.1");
```

The proxy takes four settings from one string: header forwarding, scheme forwarding, path
handling, body limit — each a configuration line in a real product, here a single string
field.

```js
// topology/proxy.mjs — reverse proxy. Terminates TLS itself, connects to the backend unencrypted.
// Setting string: headers=on|off,scheme=on|off,path=passthrough|resolve,body=<bytes>
import { createServer, request } from "node:http";

const [port, target, configText] = [Number(process.argv[2]), Number(process.argv[3]), process.argv[4] ?? ""];
const OPTS = Object.fromEntries(configText.split(",").filter(Boolean).map((p) => p.split("=")));
const counters = { received: 0, forwarded: 0, aborted: 0, bytesRead: 0 };
// SD1: on a single machine every client shares the same loopback address. The member address is
// derived from the source-port block; in a real deployment this value comes directly from the connection.
const MEMBER_IP = { 412: "10.4.0.11", 413: "10.4.0.12", 414: "10.4.0.13", 415: "10.4.0.14" };
const clientAddress = (s) => MEMBER_IP[Math.floor(s.remotePort / 100)] ?? s.remoteAddress;

if (Number.isInteger(port) === false || Number.isInteger(target) === false) {
  console.log("usage: node topology/proxy.mjs <port> <target> <config>");
} else createServer((clientReq, clientRes) => {
  clientRes.sendDate = false;
  if (clientReq.url === "/proxymeasure") { clientRes.writeHead(200).end(JSON.stringify(counters)); return; }
  counters.received += 1;
  const headers = { ...clientReq.headers };
  delete headers["x-forwarded-for"];                // discard any identity claim from outside
  delete headers["x-forwarded-proto"];
  if (OPTS.headers === "on") headers["x-forwarded-for"] = clientAddress(clientReq.socket);
  if (OPTS.scheme === "on") headers["x-forwarded-proto"] = "https";
  const bodyLimit = Number(OPTS.body ?? Infinity);
  const path = OPTS.path === "resolve" ? decodeURIComponent(clientReq.url) : clientReq.url;

  let bytes = 0;
  const upstreamReq = request({ port: target, path, method: clientReq.method, headers }, (upstreamRes) => {
    clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers);
    upstreamRes.pipe(clientRes);
  });
  counters.forwarded += 1;
  upstreamReq.on("error", () => {
    counters.aborted += 1;
    if (clientRes.headersSent === false) clientRes.writeHead(502, { "x-layer": "proxy" }).end("backend disconnected");
  });
  clientReq.on("data", (p) => {
    bytes += p.length;
    counters.bytesRead += p.length;
    if (bytes > bodyLimit) {
      if (clientRes.headersSent === false) clientRes.writeHead(413, { "x-layer": "proxy" }).end("too large");
      upstreamReq.destroy();
      clientReq.destroy();
      return;
    }
    upstreamReq.write(p);
  });
  clientReq.on("end", () => upstreamReq.end());
  clientReq.on("error", () => upstreamReq.destroy());
}).listen(port, "127.0.0.1");
```

Four members connect from separate source-port blocks: A sends ten requests, B, C, and D send
two each — sixteen total, against a per-identity limit of five.

```js
// topology/client.mjs — members connect from separate source ports; identity is pinned to the connection.
// Scenarios: rate | scheme | path | body.   Usage: node topology/client.mjs <port> <scenario> <label>
import { request } from "node:http";

const [port, scenario, label] = [Number(process.argv[2]), process.argv[3], process.argv[4] ?? "-"];
const MEMBERS = [["A", 41200, 10], ["B", 41300, 2], ["C", 41400, 2], ["D", 41500, 2]];

function one({ path = "/loan", source, method = "GET", body }) {
  return new Promise((resolve) => {
    const r = request({ port, path, method, agent: false, localPort: source }, (y) => {
      y.resume();
      y.on("end", () => resolve({ code: y.statusCode, layer: y.headers["x-layer"] ?? "-", record: y.headers["x-record"] ?? "-", location: y.headers.location }));
    });
    r.on("error", (e) => resolve({ code: 0, layer: e.code, record: "-", location: undefined }));
    if (body !== undefined) r.end(body); else r.end();
  });
}

const measure = async (path) => JSON.parse(await new Promise((resolve) => {
  request({ port, path, agent: false }, (y) => { let b = ""; y.on("data", (d) => { b += d; }); y.on("end", () => resolve(b)); }).end();
}));

if (scenario === "rate") {
  let passed = 0, stopped = 0;
  const line = [];
  for (const [name, base, count] of MEMBERS) {
    let p = 0, s = 0;
    for (let i = 0; i < count; i += 1) {
      const r = await one({ source: base + i });
      if (r.code === 429) s += 1; else p += 1;
    }
    passed += p; stopped += s;
    line.push(`${name}:${p}/${p + s}`);
  }
  const o = await measure("/measure");
  console.log(`${label.padEnd(24)} identity ${String(o.identity).padStart(2)}  passed ${String(passed).padStart(2)}  stopped ${String(stopped).padStart(2)}  ${line.join(" ")}`);
} else if (scenario === "scheme") {
  let step = 0, code = 0, path = "/loan";
  while (step < 5) {
    const r = await one({ path, source: 41260 + step });
    code = r.code;
    if (r.location === undefined) break;
    path = r.location; step += 1;
  }
  console.log(`${label.padEnd(24)} redirect step ${step}/5  final code ${code}`);
} else if (scenario === "path") {
  const REQUESTS = [["KTP%2F17", "Regional History"], ["KTP%252F17", "Regional History Supplement"]];
  const results = [];
  for (const [i, [encoded, expected]] of REQUESTS.entries()) {
    const r = await one({ path: `/book/${encoded}`, source: 41270 + i });
    results.push(`${encoded} -> ${r.code} ${r.record}${r.code === 200 && r.record !== expected ? " (wrong record)" : ""}`);
  }
  console.log(`${label.padEnd(24)} ${results.join("  |  ")}`);
} else if (scenario === "body") {
  const r = await one({ method: "POST", source: 41280, body: "x".repeat(8192) });
  const v = await measure("/proxymeasure");
  const u = await measure("/measure");
  console.log(`${label.padEnd(24)} client ${r.code} (${r.layer})  proxy read ${v.bytesRead}  app read ${u.bytesRead}  app record ${u.requests}`);
} else console.log("usage: node topology/client.mjs <port> <scenario> <label>");
```

Four settings run in pairs. The application restarts each run, so counters start from zero;
only the proxy's setting string changes.

```bash
# measure.sh — the same job with four setting pairs. The app restarts each run (counters reset).
run() {                                   # run <config> <scenario> <label> [app-body-limit]
  node topology/app.mjs 8931 "${4:-16384}" & U=$!
  node topology/proxy.mjs 8930 8931 "$1" & V=$!
  sleep 0.4
  node topology/client.mjs 8930 "$2" "$3"
  kill $V $U 2>/dev/null; wait $V $U 2>/dev/null; sleep 0.2
}

run "headers=off,scheme=on" rate   "header off"
run "headers=on,scheme=on"  rate   "header on"
run "headers=on,scheme=off" scheme "scheme off"
run "headers=on,scheme=on"  scheme "scheme on"
run "headers=on,scheme=on,path=passthrough" path "path passed through"
run "headers=on,scheme=on,path=resolve"     path "path resolved at proxy"
run "headers=on,scheme=on,body=4096"  body "limit at proxy (4096)"  16384
run "headers=on,scheme=on,body=65536" body "limit at app (4096)" 4096
```

```
header off               identity  1  passed  5  stopped 11  A:5/10 B:0/2 C:0/2 D:0/2
header on                identity  4  passed 11  stopped  5  A:5/10 B:2/2 C:2/2 D:2/2
scheme off               redirect step 5/5  final code 308
scheme on                redirect step 0/5  final code 200
path passed through      KTP%2F17 -> 200 Regional History  |  KTP%252F17 -> 200 Regional History Supplement
path resolved at proxy   KTP%2F17 -> 404 -  |  KTP%252F17 -> 200 Regional History (wrong record)
limit at proxy (4096)    client 413 (proxy)  proxy read 8192  app read 0  app record 0
limit at app (4096)      client 413 (app)  proxy read 8192  app read 8192  app record 1
```

The numbers are deterministic: the request count, the limit, and the body size are constants,
run-independent; no duration is measured.

## One Bucket and Four Buckets

The first two rows run the same sixteen requests through the same rate limit. The only
difference is a single setting.

With forwarding off, the audit log holds **one** identity: every request was written under the
proxy's address, and the rate limit collapses into a single bucket. Whoever arrives first
drains it — A gets five requests through, the remaining five are stopped, and **all six
requests** from B, C, and D that follow are stopped too. Total: five passed, eleven stopped.

With forwarding on, identity count rises to four. A again gets five through and has five
stopped; B, C, and D get all six of theirs through. Total: eleven passed, five stopped.

Across both runs, **the abusive member's passed-request count is the same**: five. What changes
is the well-behaved members' outcome — six good requests stopped while forwarding was off,
zero while it was on. Six **false rejects** come from a single setting, none showing up as an
error; the client receives a perfectly valid 429.

The audit-log side outlives the request itself. Asked which member an event came from, the log
cannot answer — and does not say so, because it is not missing anything, it is **wrongly
full**.

## A Five-Step Loop

The third and fourth rows measure scheme forwarding. When the application sees a request did
not arrive encrypted, it redirects to the secure scheme — a common rule at the application
layer.

With scheme forwarding off, the client burns through all five redirect steps and ends up
holding nothing but a 308. The loop is closed: the client goes to the secure scheme, the proxy
terminates TLS again, the application again gets an unencrypted connection, and it redirects
again. With forwarding on, the step count is zero and the code is 200.

This is the **not-silent** end of the wrong setting: the user sees an error. Yet the logs show
none; at every step the application behaved correctly and produced a correct redirect.

## Resolving Once Too Often

The fifth and sixth rows measure path handling. The catalog holds two separate records — the
book `KTP/17` and its supplement `KTP%2F17` — encoded in the request as `KTP%2F17` and
`KTP%252F17` respectively.

When the proxy passes the path through unchanged, both requests reach the correct record. When
the proxy resolves the path once, both requests slip. The first becomes `/book/KTP/17`; the
application splits the path into segments, the book identifier becomes `KTP`, and the response
is 404. The second becomes `/book/KTP%2F17`; the application resolves this once more on its
own side, finds `KTP/17`, and returns **a different book** with a 200.

The difference matters. The 404 is a visible failure; the wrong record returning with a 200 is
invisible — the client gets a valid response, a valid title, just not the one it asked for.
**How many times** resolution happens is a setting, split across two layers.

## Two Limits, One Effective Value

The last two rows push an eight-kilobyte body through two different limit placements. In both,
the client gets a 413; the effective limit is always the smaller of the two. What changes is
where the cost and the record land.

With the limit at the proxy, it reads 8192 bytes, **zero** bytes pass through to the
application, and **zero** requests show up in its audit log. The application's 16384 allowance
is a dead setting, never applied — the rejected request never happened for it.

With the limit at the application, the same 8192 bytes are read **twice**, once at each layer,
and one request shows up in the log. The rejection is the same, the cost doubled, the log
full.

The two rows answer the same question from two directions: the layer the limit sits in does
not decide whether the request is rejected — it decides **who does the work and who sees it.**

## Where the Setting Lives

All four settings showed up in one measurement table, but in the code they sit in four
separate places.

| Setting | Location | How many places | Silent result of a wrong value |
|---|---|---|---|
| Header forwarding | proxy | 1 (each proxy copy) | single identity, six false rejects, wrongly full audit log |
| Scheme forwarding | proxy + app rule | 2 | five-step redirect loop |
| Path handling | proxy + app resolution | 2 | wrong record returning with 200 |
| Body limit | proxy + app | 2 | dead setting, or body read twice |

In three of the four rows, the setting **is split across two layers**, and correct behavior
comes from the two agreeing; change one layer alone and the application keeps running, only
the measurement changes. As proxy copies grow, the first row's place count grows with them: a
single copy left with forwarding off writes every request through it under one identity.

## Summary

- TLS termination takes the client address and scheme away from the application; both are
  forwarded through headers, and forwarding is a setting.
- With header forwarding off, the audit log showed one identity, four with it on; of the same
  sixteen requests, five passed and eleven stopped, versus eleven passed and five stopped on.
- The abusive member's passed-request count was five in both runs; well-behaved members paid
  the cost, with six requests wrongly stopped.
- With scheme forwarding off, the client burned through all five redirect steps and never got
  a 200; on, it got one in zero steps.
- With the path resolved once at the proxy, one of two requests got a 404 and the other got
  **a different book** with a 200 — an outcome that looks error-free client-side.
- With the body limit at the proxy, zero bytes reached the application and zero requests
  showed up in the log; at the application, the same 8192 bytes were read twice and one
  request landed in the log.

## Next Step

This lesson configured the layer **in front of** the application. Every measurement assumed one
thing: the process was up, started by hand, stopped by hand once the job finished, and never
crashed in between. In production, another component starts the process, restarts it when it
crashes, and decides how much memory it may use. Its settings are just as silent, and harsher
in their consequences: a wrong restart policy tries to bring a crashing process back up dozens
of times a second, and a wrong memory limit cuts a working process off mid-request. The next
lesson writes the process manager and measures the dropped-request count of three restart
policies, the gap between killing and slowing a process at its memory limit, and how a graceful
shutdown's wait time affects how many requests it drains.
