NGINX Reverse Proxy & TestβPro
Quick Commands
# edit + deploy
git status
git add -A
git commit -m "docs: update"
git push
# rebuild static blog output (local)
cd site
npm ci --no-audit --no-fund
npm run build
# VPS: pull only
# (on server)
git pull --ff-only
NGINX Reverse Proxy & Sidecar β Final State (derived strictly from your files)
1) High-level overview
Traffic path (todayβs working chain):
[Internet]
βββ HTTPS :443 (gateway on host)
βββ proxy_pass β http://127.0.0.1:7777
(host loopback β published port on test-proxy container)
βββ [Test-Proxy container]
listen 7777
proxy_pass β http://10.0.2.2:2001 # host loopback via slirp gateway
βββ [Sidecar (shares netns with app)]
listen 0.0.0.0:2001
proxy_pass β http://127.0.0.1:4001
βββ [App]
listen 127.0.0.1:4001
Health endpoints present:
- Test-proxy:
GET /__nginx_ok(port 7777) - Sidecar:
GET /__gw_ok(port 2001) - App:
GET /__app_ok(port 4001)
Why this works in your rootless, per-user, isolated pods setup:
- The app stays bound to container loopback (
127.0.0.1:4001). - The sidecar shares the same network namespace (
network_mode: "service:app88"), so it can reach127.0.0.1:4001and expose only :2001 outward. - The test-proxy runs in slirp4netns with
allow_host_loopback=true, so it can call the host loopback via**10.0.2.2**; you pointproxy_passtohttp://10.0.2.2:2001. - The host gateway forwards HTTPS β
127.0.0.1:7777(test-proxy publish).
No shared docker/podman network between pods, no service-name coupling.
2) Component inventory (only whatβs in your files)
A) Main gateway (host NGINX)
- Files (from
proxyuserdump):nginx/nginx.conf(includesconf.d/*.confandsites-enabled/*.conf)nginx/sites-enabled/00-blackhole.conf- Drop HTTP 80 default (444), reject unknown HTTPS default (ssl_reject_handshake).
nginx/sites-enabled/test-proxy-gateway.confserver:80 for*.test.privsec.chβ ACME + redirect to HTTPS.server:443 with LE certs at/etc/letsencrypt/live/test.privsec.ch/....- proxy_pass β
**http://127.0.0.1:7777**(preserves Host, XFwd headers; upgrade ready).
nginx/sites-enabled/n1c1t3st.conf(separate host; also proxies to127.0.0.1:7777, contains optional auth_request & rate limits).nginx/conf.d/*.conf(proxy defaults, logging options, gates map, limits, an example upstream).- Certbot:
createCertWild.yml(manual DNS challenge for*.test.privsec.ch,test.privsec.ch)letsencrypt/renewal/*.conf(LE renewal configs)
B) Test-proxy (container)
- Compose (
docker-compose.ymlinproxytest):image: nginx:stable**network_mode: "slirp4netns:allow_host_loopback=true"**ports: "127.0.0.1:7777:7777"- Mounts:
nginx/nginx.conf,nginx/sites-enabled,nginx/logs
- NGINX global (
nginx/nginx.conf):- single
log_format main,error_log ... info - proxy header defaults,
limit_conn_zone,limit_req_zone include /etc/nginx/sites-enabled/*.conf
- single
- vHost (
nginx/sites-enabled/test-wildcard.conf):listen 7777;server_name ~^nctest[^.]*\.test\.privsec\.ch$ test.privsec.ch;/__nginx_okreturns 200**proxy_pass http://10.0.2.2:2001;**(explicit slirp host-loopback IP)- Per-IP connection & rate limiting (
limit_conn perip 50; limit_req zone=reqs ...)
C) App + sidecar pod
- Compose (
docker-compose.ymlintest):app88:image: nginx:stable-alpineports: "127.0.0.1:2001:2001"(publish sidecarβs port to host loopback)- mounts:
nginx.conf(app),html,logs - healthcheck:
nginx -t
app88-gw(sidecar):image: nginx:stable-alpine**network_mode: "service:app88"**(shares netns; sees appβs 127.0.0.1)- mounts:
gw.conf,logs
- Sidecar NGINX (
gw.conf):listen 0.0.0.0:2001;/__gw_okreturns 200proxy_pass http://127.0.0.1:4001;
- App NGINX (
nginx.confintest):listen 127.0.0.1:4001;- serves
/usr/share/nginx/html(+index) /__app_okreturns 200- split access logs + warn/notice error logs
3) Effective directory trees (from your dumps)
# testproxy (containerized test proxy)
.
ββ docker-compose.yml
ββ nginx/
ββ nginx.conf
ββ sites-enabled/
ββ test-wildcard.conf
# app pod (app + sidecar share netns)
.
ββ docker-compose.yml
ββ nginx.conf # app (127.0.0.1:4001)
ββ gw.conf # sidecar (0.0.0.0:2001 β 127.0.0.1:4001)
ββ html/ # static content (mounted)
ββ logs/ # access/error logs (mounted)
# gateway on host
.
ββ nginx/
β ββ nginx.conf
β ββ conf.d/
β β ββ gates.conf
β β ββ limits.conf
β β ββ logging.conf
β β ββ proxy.conf
β β ββ upstreams.conf
β ββ sites-enabled/
β ββ 00-blackhole.conf
β ββ n1c1t3st.conf
β ββ test-proxy-gateway.conf
ββ letsencrypt/
β ββ renewal/
β ββ test.privsec.ch.conf
β ββ n1c1t3st.privsec.ch.conf
ββ createCertWild.yml
4) Comms diagram (ports & IPs exactly as configured)
Client (any)
β HTTPS :443 SNI: nctest.test.privsec.ch
βΌ
[Host: Main NGINX gateway]
- 80: ACME + redirect
- 443: TLS (LE cert *.test.privsec.ch)
- proxy_pass http://127.0.0.1:7777
β
βΌ
Host loopback 127.0.0.1:7777 (published from testproxy)
β
βΌ
[Test-Proxy container (slirp4netns)]
- listen 7777
- server_name ~^nctest... test.privsec.ch
- proxy_pass http://10.0.2.2:2001 β slirp host-loopback
β
βΌ
Host (as seen from container): 10.0.2.2:2001
β
βΌ
[Sidecar (shares netns with app)]
- listen 0.0.0.0:2001
- proxy_pass http://127.0.0.1:4001
β
βΌ
[App]
- listen 127.0.0.1:4001
Health path examples:
GET https://nctest.test.privsec.ch/__app_okβ 443β7777β10.0.2.2:2001β127.0.0.1:4001curl -H 'Host: nctest.test.privsec.ch' http://127.0.0.1:7777/__nginx_ok(test-proxy)curl http://127.0.0.1:2001/__gw_ok(sidecar)curl http://127.0.0.1:4001/__app_ok(app, from inside pod)
5) Logging, limits, and headers (as-is)
- Logging
- Test-proxy:
access.logwithmain;error_log ... info(will show upstream errors). - App pod: split access logs (
access.2xx.log,access.4xx.log,access.5xx.log,access.log) and warn/notice error logs. - Gateway: multiple formats in
conf.d/logging.conf+ per-site behaviors.
- Test-proxy:
- Limits
- Test-proxy
limit_conn perip 50; limit_req zone=reqs burst=50 nodelay; - Global zones defined in test-proxyβs
nginx/nginx.conf. - Gateway has additional limit zones (
limits.conf) and optional auth gate (gates.conf) used byn1c1t3st.conf.
- Test-proxy
- Proxy headers
- All three layers set:
Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto. - Upgrade headers enabled globally where applicable.
- All three layers set:
6) Operational runbooks (from your current state)
-
Restart test-proxy (reload config)
podman exec -it testproxy nginx -t && podman exec -it testproxy nginx -s reload -
Recreate test-proxy from compose
podman-compose down && podman-compose up -d -
End-to-end quick tests
curl -sSI http://127.0.0.1:2001/__gw_ok podman exec -it testproxy sh -lc 'curl -sSI http://10.0.2.2:2001/__app_ok' curl -i -H 'Host: nctest.test.privsec.ch' http://127.0.0.1:7777/__app_ok curl -sv --http1.1 https://nctest.test.privsec.ch/__app_ok
7) Decision log (why each choice)
- Separate pods / users; no shared networks β reduce cross-tenant blast radius; avoid container-to-container DNS coupling.
- Sidecar pattern (0.0.0.0:2001 β 127.0.0.1:4001) β app remains local to its namespace; only the sidecar is addressable.
- Publish only host loopback (
127.0.0.1:2001,127.0.0.1:7777) β nothing exposed publicly. - Test-proxy on slirp with
**allow_host_loopback=true**β in rootless mode, lets the proxy reach host loopback via10.0.2.2, keeping pods otherwise isolated. - Explicit
**10.0.2.2**in**proxy_pass**β avoidshost.containers.internalresolution ambiguity; matches your working tests. - Gateway blackholes for unknown names on :80/:443 β quiets noise and tightens surface.
8) Known constraints & donβts (from what broke earlier)
- Do not point test-proxy upstream to
127.0.0.1:2001(thatβs the test-proxyβs own loopback). - Do not point upstream to your WAN IP (109.*) β sidecar is never bound there.
- Rootless + netavark: you cannot publish directly to
10.0.3.1with-p 10.0.3.1:...(rootlessport: cannot assign requested address). - If you switch back to a hostname upstream, add
extra_hosts: ["host.containers.internal:10.0.2.2"]on the test-proxy service.
If you want this as a printable Markdown doc, say the word and Iβll format it 1:1 into a clean βRunbook + Architectureβ file using exactly the content above.
Post-fix plan (so it stays stable)
Everything is green now. Hereβs a short βpost-fixβ plan so you can add new apps quickly without falling into the 502 trap again.
Recap (so nobody forgets)
- Root cause:
ports:was on the sidecar, but the NetNS belongs to the app service (network_mode: service:APP). - Fix: move
ports:to the app service (TEST β127.0.0.1:22288:2001, PROD β127.0.0.1:23288:2001). - Resulting flow: proxymain β proxy{test|prod} β 127.0.0.1:22x88 β sidecar:2001 β app:4001 β
Lock it in
- Commit + tag: commit the working Compose + NGINX files and tag it (e.g.
env-host-loopback-v1). - Maintain
PORTS.md: keep the reserved ranges (TEST 22000β22999, PROD 23000β23999) and document each app. - Keep health checks:
- proxymain/proxy{test|prod}:
location = /__nginx_ok { return 200; } - sidecar:
__gw_okβ app:__app_ok
- proxymain/proxy{test|prod}:
- Donβt break ACME: on the :80 server, serve
/.well-known/acme-challenge/first, then redirect to HTTPS.
Guardrails / security
- Host-only: keep all env proxies on
listen 127.0.0.1:77xx;(donβt expose them publicly). - Limit loopback ports: optionally firewall-guard it so only
locan access them; if someone binds0.0.0.0:22x88by mistake, it still wonβt be reachable externally. - No-priv-esc / read-only: keep your proxymain hardening (
no-new-privileges,read_only,tmpfs). - Separate logs:
/home/{proxymain,proxytest,proxyprod}/logs/stays cleanly separated.
Monitoring & quick diagnosis
-
Tail everything (root):
sudo bash -lc 'shopt -s nullglob; tail -F -n0 -v /home/{proxymain,proxytest,proxyprod}/logs/*.log' -
Smoke test (host):
# TEST curl -sSI http://127.0.0.1:22288/__gw_ok curl -sSI http://127.0.0.1:22288/__app_ok curl -sSI -H 'Host: app88.test.privsec.ch' http://127.0.0.1:7701/__nginx_ok curl -sSI -H 'Host: app88.test.privsec.ch' http://127.0.0.1:7701/__app_ok # PROD curl -sSI http://127.0.0.1:23288/__gw_ok curl -sSI http://127.0.0.1:23288/__app_ok curl -sSI -H 'Host: app88.prod.privsec.ch' http://127.0.0.1:7700/__nginx_ok curl -sSI -H 'Host: app88.prod.privsec.ch' http://127.0.0.1:7700/__app_ok -
End-to-end (TLS):
curl -sSI https://app88.test.privsec.ch/__app_ok curl -sSI https://app88.prod.privsec.ch/__app_ok
Runbook: add a new app in 6 steps
- Pick a port (TEST β 22xxx / PROD β 23xxx) and register it in
PORTS.md. - Copy the app folder (
apps/{test|prod}/appNN) and add your HTML/runtime. - Adjust Compose:
- App service:
ports: ["127.0.0.1:<PORT>:2001"] - Sidecar:
network_mode: "service:<app-service>", noports:
- App service:
- proxy{test|prod} vhost:
server_name+proxy_pass http://127.0.0.1:<PORT>; - Reload env proxies:
podman exec -it proxytest nginx -t && nginx -s reload(same for prod). - Run curls/health checks: host first, then env proxy, then TLS.
Why it works (rule of thumb)
Whoever owns the NetNS publishes the ports.
Withnetwork_mode: "service:APP", the NetNS belongs to the APP service β soports:must live there.