Skip to content
GitHubLinkedIn

NGINX ingress (public vs internal)

This page proposes a maintainable NGINX “infra web service” layout for web.core.lef with strict separation by listener IP:

  • Public traffic binds only to VIPs 192.168.20.112–120 (forwarded 1:1 from public IPs by the firewall; see Firewall & public ingress).
  • Internal traffic binds only to 192.168.20.2 (LAN/VPN).

It also implements the docs.lef behavior:

  • docs.lef redirects to main.docs.lef
  • any *.docs.lef redirects to main.docs.lef unless an exact vhost exists (e.g. pivot.docs.lef)

Target layout on the server (Debian default /etc/nginx/), split by audience:

/etc/nginx/
  conf.d/
    10-known-domains.conf
    20-internal-default-certs.conf            # optional (advanced; see notes)
  generate-known-domains-map.sh
  maps/
    known_domains.map
  snippets/
    acme-http01.conf
    redirect-known-domain-to-https.conf
    ssl-internal-params.conf
  sites-enabled/
    public/
      00-default.conf
      10-<public-hostname>.conf
    internal/
      00-default.conf
      05-zone-fallbacks.conf
      10-docs-redirects.conf
      10-<internal-hostname>.conf
  nginx.conf

These example files live in this repo under src/examples/nginx/web-core-lef/ and are written to be copied to /etc/nginx/ (adjust cert paths).

nginx.conf
# Example NGINX configuration layout for `web.core.lef` (reverse proxy / ingress).
# Intended target path on the server: `/etc/nginx/nginx.conf`.
#
# This example matches the current `web.core.lef` style (Debian 12 + brotli)
# while switching to the listener-separated include layout:
# - strict listener separation by IP (public VIPs vs internal listener)
# - explicit default servers per IP:port
# - maintainable includes (conf.d/maps/snippets/sites)

user www-data;
worker_processes auto;
worker_rlimit_nofile 8192;

pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;

# brotli dynamic modules (Debian path-friendly)
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

events {
  worker_connections 2048;
}

http {
  include       mime.types;
  default_type  application/octet-stream;

  # quiet + snappy
  server_tokens off;
  tcp_nodelay on;

  # logs
  log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent"';
  access_log off;
  error_log  /var/log/nginx/http_error.log warn;

  # io + keepalive
  sendfile on;
  tcp_nopush on;
  keepalive_timeout 65;

  # bodies
  client_max_body_size    20m;
  client_body_buffer_size 64k;

  # --- compression (gzip + brotli) ---
  gzip on;
  gzip_comp_level 5;
  gzip_min_length 256;
  gzip_vary on;
  gzip_proxied any;
  gzip_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/x-javascript
    application/json
    application/ld+json
    application/xml
    application/xml+rss
    application/wasm
    image/svg+xml
    font/ttf
    font/otf
    font/woff
    font/woff2
    application/vnd.ms-fontobject
    application/vnd.api+json
    application/problem+json
    application/manifest+json;

  brotli on;
  brotli_comp_level 5;
  brotli_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/x-javascript
    application/json
    application/ld+json
    application/xml
    application/xml+rss
    application/wasm
    image/svg+xml
    font/ttf
    font/otf
    font/woff
    font/woff2
    application/vnd.ms-fontobject
    application/vnd.api+json
    application/problem+json
    application/manifest+json;

  add_header vary accept-encoding always;

  # --- websocket support ---
  map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
  }

  # --- rate limiting zones (apply in server/location as needed) ---
  limit_req_zone  $binary_remote_addr  zone=req_limit:10m  rate=10r/s;
  limit_conn_zone $binary_remote_addr  zone=per_ip:10m;

  # Maps (must load before any server blocks reference their variables).
  include /etc/nginx/conf.d/*.conf;

  # Upstreams (grouped by zone).
  include /etc/nginx/upstreams-enabled/*.conf;

  # Listener-separated vhosts.
  include /etc/nginx/sites-enabled/public/*.conf;
  include /etc/nginx/sites-enabled/internal/*.conf;
}

Rationale and constraints: see NGINX ingress rationale.

Migration action list (from a legacy 0.0.0.0 listener setup)

Section titled “Migration action list (from a legacy 0.0.0.0 listener setup)”

This is the safe order to move an existing config (that currently listens on 0.0.0.0:80/443) to strict listener separation:

  1. Snapshot current state: nginx -T, ip -br addr, ss -lntp | grep nginx.
  2. Create the new layout: /etc/nginx/sites-enabled/public/ and /etc/nginx/sites-enabled/internal/.
  3. Move or re-create existing vhost symlinks into the correct folder:
    • public: VIP listeners (192.168.20.112–120)
    • internal: internal listener (192.168.20.2)
  4. Replace the global listen 80 / listen 443 defaults with the explicit per-IP defaults from the example files.
  5. Update the known-domain map to use the composite key "$server_addr:$host" and list <listener-ip>:<hostname> entries.
    • If you currently generate snippets/known_domains.map by grepping server_name, replace it with the generator script shown above (it also extracts listen <ip>).
  6. Configure internal unknown HTTPS handling:
    • Required: internal 192.168.20.2:443 default_server uses a static internal certificate so it can return a redirect.
    • Recommended: add per-zone wildcard fallback vhosts (05-zone-fallbacks.conf) for zones where you have wildcard certs (e.g. *.core.lef, *.docs.lef) so browsers see a matching certificate when a hostname is unknown-but-in-zone.
    • Optional (advanced): SNI-based cert selection (20-internal-default-certs.conf) only if your keys are readable by NGINX workers (security trade-off).
  7. Add docs.lef and *.docs.lef redirect vhosts on the internal listener.
  8. Validate and reload: nginx -t && systemctl reload nginx.
  9. Re-check bindings: ss -lntp | grep nginx (local address should no longer be 0.0.0.0:80/443 or [::]:80/443).
  10. Run the checklist below against both public VIPs and the internal listener.

Run these on a machine that can reach the relevant listener IPs.

Listener binding sanity:

sudo ss -lntp | grep nginx

Public :80 behavior (known host redirects; unknown host dropped):

curl -I http://192.168.20.112/ -H 'Host: wiki.lef.digital'
curl -v http://192.168.20.112/ -H 'Host: does-not-exist.example'

Known-domain separation (a known host on the “wrong” VIP is treated as unknown):

curl -v http://192.168.20.113/ -H 'Host: wiki.lef.digital'        # should be 444
curl -v http://192.168.20.112/ -H 'Host: proxy.coragem.app'       # should be 444
curl -v http://192.168.20.112/ -H 'Host: main.docs.lef'           # should be 444

Public ACME path is served (no redirect for the challenge location):

curl -i http://192.168.20.112/.well-known/acme-challenge/ping -H 'Host: wiki.lef.digital'

Public :443 blocks unknown SNI (handshake rejected):

openssl s_client -connect 192.168.20.112:443 -servername does-not-exist.example

Internal :80 redirects unknown to docs:

curl -I http://192.168.20.2/ -H 'Host: does-not-exist.example'

Internal :443 redirects unknown to main docs (certificate trust may require your internal CA):

curl -Ik https://does-not-exist.example/ --resolve does-not-exist.example:443:192.168.20.2

Troubleshooting: if this returns a TLS error like tlsv1 alert internal error, check whether the internal 192.168.20.2:443 default_server uses variable-based ssl_certificate_key and the key is not readable by the worker user (common on Debian with www-data). Check:

sudo tail -n 100 /var/log/nginx/http_error.log
sudo tail -n 100 /var/log/nginx/error.log
sudo nginx -T | sed -n '/listen 192.168.20.2:443.*default_server/,/}/p'

Internal docs.lef routing:

curl -Ik https://docs.lef/ --resolve docs.lef:443:192.168.20.2
curl -Ik https://anything.docs.lef/ --resolve anything.docs.lef:443:192.168.20.2

NGINX config validity / reload:

sudo nginx -t
sudo systemctl reload nginx