A reverse proxy accepts client requests and makes a separate upstream exchange with an application. It can centralize routing, transport, headers, and admission policy when those behaviors are configured. It does not automatically hide every backend interface, authenticate every caller, or terminate TLS. The useful boundary is the actual traffic path and policy applied there, not simply the proxy’s position in a diagram.

When it breaks

A backend reachable through another route can bypass controls implemented only at the proxy. Not publishing a container port reduces one exposure path, but peers on an attached network may still reach the application. If backend authorization relies entirely on the claim that all traffic passed through the public proxy, a direct internal request can invalidate that assumption.

URI mapping can create a subtler bypass. Blocking /admin at the proxy does not help if a broad /api/ mapping also exposes the same backend handler as /api/admin. Review the final upstream URI, not just the visible client path. Publishing a specific endpoint is different from exposing every handler beneath a convenient prefix and hoping names stay separate.

Forwarded metadata requires deliberate handling. A client may send a fabricated address or scheme header, and a proxy does not make that value trustworthy merely by forwarding it. The application needs to know which fields the trusted hop sets and which callers can reach it. A browser response header also does not replace request authorization or network reachability controls.

Check it quickly

Save this as check-proxy-entry.py and run it with Python, Docker, and the pinned images. The recorded setup uses nginx 1.28.0 with a disposable echo application on a private Docker network. Only the proxy has a host loopback port binding. This fixture uses HTTP to inspect routing and policy; it does not perform a TLS deployment:

from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid

NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def wait(predicate):
    deadline=time.monotonic()+15
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except OSError:pass
        time.sleep(.1)
    raise AssertionError('fixture did not become ready')

@contextmanager
def lab(configuration,files=None,backend=None,extra_ports=()):
    name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
    try:
        with TemporaryDirectory(prefix=name+'-') as directory:
            root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
            for path,content in (files or {}).items():
                target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
                target.write_bytes(content if isinstance(content,bytes) else content.encode())
            docker('network','create',name)
            if backend is not None:
                (root/'backend.py').write_text(backend)
                (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
                docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
                docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
                wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
            (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
            docker('build','--network','none','-t',tags[0],str(root))
            docker('run','--rm','--network',name,tags[0],'nginx','-t')
            published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))]
            docker('run','-d','--name',containers[0],'--network',name,*published,tags[0])
            details=json.loads(docker('inspect',containers[0]).stdout)[0]
            binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
            def ready():
                connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
            wait(ready)
            def request(path,method='GET',headers=None,body=None):
                connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
                try:
                    connection.request(method,path,body=body,headers=headers or {})
                    response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
                finally:connection.close()
            request.port=port;request.network=name
            request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value}
            yield containers[0],request
    finally:
        docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)

def config(locations,extra=''):
    return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json
class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.1'
    def log_message(self,*args):pass
    def respond(self,body=b''):
        data=json.dumps({'path':self.path,'method':self.command,'body':body.decode(),
            'host':self.headers.get('Host'),'forwarded_for':self.headers.get('X-Forwarded-For'),
            'forwarded_proto':self.headers.get('X-Forwarded-Proto')}).encode()
        self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)))
        self.end_headers();self.wfile.write(data)
    def do_GET(self):self.respond()
    def do_POST(self):self.respond(self.rfile.read(int(self.headers['Content-Length'])))
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location = /api/echo {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://backend:8081/echo;
}
location /admin { deny all; }
location / { return 404; }
'''
extra='client_max_body_size 1k; add_header X-Content-Type-Options nosniff always;'
bad='events {} http { server { listen not-a-port; } }'
with lab(config(locations,extra),{'invalid.conf':bad},backend) as (proxy,request):
    status,h,b=request('/api/echo',method='POST',body=b'hello',headers={'Host':'edge.example',
        'X-Forwarded-For':'192.0.2.123','X-Forwarded-Proto':'https'})
    data=json.loads(b);assert status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST'
    assert data['host']=='edge.example' and data['forwarded_for']!='192.0.2.123' and data['forwarded_proto']=='http'
    assert h['x-content-type-options']=='nosniff';results={'proxied_request':data}
    status,h,_=request('/admin');assert status==403 and h['x-content-type-options']=='nosniff';results['admin_at_proxy']=status
    assert request('/api/echo',method='POST',body=b'x'*2048)[0]==413
    assert request('/unknown')[0]==404 and request('/api/admin')[0]==404
    results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404
    details=json.loads(docker('inspect',request.network+'-backend').stdout)[0]
    assert not details['HostConfig']['PortBindings']
    code='import json,urllib.request;print(json.dumps(json.load(urllib.request.urlopen("http://backend:8081/admin"))))'
    internal=docker('run','--rm','--network',request.network,PYTHON,'python','-c',code)
    direct=json.loads(internal.stdout);assert direct['path']=='/admin'
    results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['path']
    probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False);assert probe.returncode!=0
    assert request('/api/echo')[0]==200;results['invalid_candidate_rejected_live_config_survives']=True
    print(json.dumps(results,sort_keys=True))

A POST to the exact public path /api/echo reaches backend /echo with method and body preserved. The supplied Host is forwarded deliberately. A fabricated X-Forwarded-For value is replaced by nginx’s observed socket peer, and a fabricated https scheme becomes the actual http scheme of this listener. The response reveals what the backend received rather than assuming a header directive had the intended effect.

At the proxy, /admin returns 403 and /api/admin returns 404. The latter control prevents the alternative URI mapping from reaching that backend handler. An oversized 2,048-byte submission exceeds the 1 KiB limit and returns 413; an unknown route returns 404. The configured nosniff header appears on both the successful response and the explicit access rejection.

The script checks Docker’s backend configuration and confirms that it has no host port binding. It then starts a separate disposable client on the same private network and requests backend /admin directly. That request succeeds. The result is intentional evidence of the boundary: the proxy’s denial does not apply to a path that never visits the proxy.

A final candidate configuration contains an invalid listener value. nginx rejects it during syntax validation, while the existing valid process continues answering /api/echo. This demonstrates that checking a separate candidate does not activate it. It does not claim that an arbitrary reload, certificate rotation, or full deployment workflow has thereby been validated.

Keep entry policy and application responsibility explicit

Inventory the ways clients can reach each backend, including internal peers and separately published listeners. Apply application authorization where the trust model requires it, and limit network access to the intended callers. A private bridge is a connectivity arrangement, not proof that every attached workload has the same privileges or that backend traffic is encrypted.

Choose URI exposure and forwarded fields as part of the application contract. An exact public route can prevent accidental handler exposure in this small example; a larger application needs a reviewed route family rather than an arbitrary list copied from a test. Verify near-miss and rewritten paths whenever several external URLs could name the same internal operation.

TLS can terminate at the proxy, continue to the backend, or follow another explicitly designed boundary. Certificates, upstream verification, renewal, and protocol choices need their own checks. Similarly, one proxy remains one capacity and failure component even when it fronts many healthy applications. Centralizing policy reduces duplication only when the resulting configuration and availability are actually managed.

Finally, validate through the entry point and through any other allowed path. Check what the application receives, what the client sees, and which requests never reach the application. The proxy is a useful place to enforce shared behavior, but correctness comes from those observable boundaries rather than an assumption that a single front door is the building’s only entrance.

Further reading

Sources