Edge Ingress Gateway: Nginx Proxy Manager (NPM), Wildcard DNS-01 ACME & mTLS Access Lists
Executive Summary: Exposing self-hosted container services directly to the public internet without centralized TLS termination, HTTP/3 (
QUIC) negotiation, and strict access control invites automated credential-stuffing and CVE exploitation. This architectural guide details the production deployment of Nginx Proxy Manager (NPM)—combining the battle-tested C-based OpenResty/Nginx event loop with an administrative control plane—to orchestrate Let's Encrypt Wildcard certificates (DNS-01), WebSocket/gRPC proxying, real-IP restoration (ngx_http_realip_module), and cryptographic Mutual TLS (mTLS) client authentication.
-
Zero-Port-80 DNS-01 Wildcards
Provision wildcard certificates (
*.lan.nejat-hakan.de) via ACME DNS-01 API challenges without exposing ports80or443to the public internet. -
Dedicated Docker Ingress Bridge
Isolate upstream backends inside an internal container bridge (
proxy-tier) so zero backend ports (8080,3000,9000) are bound on the host NIC. -
Security Headers & mTLS
Enforce HSTS preload, CSP,
X-Frame-Options, and client-certificate verification (ssl_verify_client on) via reusable Custom Nginx Snippets. -
Structured JSON Access Logs
Parse upstream latency (
$upstream_response_time) and true client IPs (CF-Connecting-IP/X-Forwarded-For) for CrowdSec and Fail2ban ingestion.
1. Architecture: Isolated Container Ingress & Split-Horizon TLS
In a hardened self-hosted environment, internal applications should never bind ports on 0.0.0.0. Instead, Nginx Proxy Manager serves as the sole ingress gateway attached to an external Docker network (proxy_net), forwarding traffic to isolated backend containers over internal Docker DNS names (http://immich-server:2283, http://vaultwarden:80).
%%{init: {'flowchart': {'wrappingWidth': 600, 'subGraphTitleMargin': {'top': 15, 'bottom': 15}}}}%%
flowchart TB
subgraph Clients["External & LAN Clients"]
Browser["HTTPS / HTTP3 Client<br/>*.nejat-hakan.de"]
ACME["Let's Encrypt CA<br/>DNS-01 API Challenge"]
end
subgraph IngressGateway["Nginx Proxy Manager (proxy_net)"]
NginxCore["OpenResty / Nginx Engine<br/>TLS 1.3 + HTTP/2 + HTTP/3"]
Certbot["Embedded Certbot<br/>DNS-01 Wildcard Renewal"]
AccessList["Access Lists & mTLS<br/>IP Allowlist / Client Cert"]
end
subgraph InternalBackends["Isolated Backend Containers (No Host Ports)"]
Vault["Vaultwarden<br/>vaultwarden:80"]
Nextcloud["Nextcloud App<br/>nextcloud:80"]
Jellyfin["Jellyfin Media<br/>jellyfin:8096"]
end
Browser -->|"TCP/UDP 443 (SNI)"| NginxCore
Certbot <-->|"TXT _acme-challenge"| ACME
NginxCore --> AccessList
AccessList -->|"WebSocket + Proxy"| Vault
AccessList -->|"FastCGI / HTTP"| Nextcloud
AccessList -->|"Streaming Buffer Off"| Jellyfin
2. Production Container Stack (NPM + MariaDB Backend)
While NPM defaults to SQLite, production deployments with dozens of proxy hosts, access lists, and frequent audit log writes benefit from a dedicated MariaDB backend or tuned SQLite WAL configuration on an isolated Docker bridge network.
docker network create --driver=bridge --subnet=172.28.0.0/16 proxy_net
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
- "127.0.0.1:81:81/tcp"
environment:
TZ: "Europe/Berlin"
DISABLE_IPV6: "false"
# Run NPM with unprivileged UID/GID if desired
PUID: 1000
PGID: 1000
volumes:
- ./data:/data:Z
- ./letsencrypt:/etc/letsencrypt:Z
networks:
- proxy_net
networks:
proxy_net:
external: true
Never Expose Admin Port 81 to 0.0.0.0
Always bind port 81 exclusively to loopback (127.0.0.1:81:81/tcp) and access the NPM administration UI either via an SSH local port forward (ssh -L 8181:127.0.0.1:81 user@host) or by creating an internal Proxy Host inside NPM (npm.lan.nejat-hakan.de → http://127.0.0.1:81) protected by a LAN-only Access List.
3. Reusable Custom Nginx Hardening Snippets (/data/nginx/custom/)
NPM automatically includes custom configuration files placed inside /data/nginx/custom/:
http_top.conf: Included at the top of thehttp {}context.server_proxy.conf: Included inside every generated Proxy Hostserver {}block.
# Hide OpenResty/Nginx version tokens in Server header and error pages
server_tokens off;
# Harden TLS 1.3 / 1.2 Elliptic Curves and Cipher Suites
ssl_ecdh_curve X25519:secp384r1;
ssl_session_tickets off;
# Trust local WireGuard VPN and Docker subnet for X-Forwarded-For real IP extraction
set_real_ip_from 172.28.0.0/16;
set_real_ip_from 10.88.0.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Enforce Strict-Transport-Security (HSTS) + Modern Browser Isolation Headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()" always;
# Increase default proxy buffer sizes for large OIDC/SAML JWT cookies
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
4. Mutual TLS (mTLS) Client Certificate Authentication
For sensitive administrative interfaces (Proxmox, Portainer, Grafana), password authentication alone is insufficient. By configuring Mutual TLS (mTLS) in a proxy host's Advanced tab, Nginx rejects the TLS handshake at Layer 4/7 before a single HTTP byte reaches the backend application unless the client presents a certificate signed by your internal Root CA.
mkdir -p /opt/npm/data/mtls && cd /opt/npm/data/mtls
# 1. Generate Private Root CA Key and Certificate (10 Years)
openssl genpkey -algorithm ED25519 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 -out ca.crt \
-subj "/CN=Sovereign Infrastructure mTLS Root CA/O=NejatHakan"
# 2. Generate Client Key, CSR, and Sign Client Certificate
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out client.key
openssl req -new -key client.key -out client.csr -subj "/CN=nejat-workstation/O=Engineering"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 825 -sha256
# 3. Export PKCS#12 (.p12) Bundle for Browser / iOS / Android Import
openssl pkcs12 -export -out nejat-client.p12 -inkey client.key -in client.crt -certfile ca.crt
Add the following directives to the Advanced tab of the target Proxy Host in NPM:
ssl_client_certificate /data/mtls/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
5. Verification, Log Auditing & Syntax Validation
Verify Nginx configuration integrity inside the container and inspect live upstream response latencies:
# 1. Test generated Nginx configurations inside the container
docker exec nginx-proxy-manager nginx -t
# 2. Gracefully reload Nginx worker processes without dropping active connections
docker exec nginx-proxy-manager nginx -s reload
# 3. Inspect HTTP status codes and upstream response times across all proxy hosts
tail -f /opt/npm/data/logs/proxy-host-*_access.log