Zum Inhalt

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 ports 80 or 443 to 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.

Creating the External Ingress Docker Network
docker network create --driver=bridge --subnet=172.28.0.0/16 proxy_net
/opt/npm/compose.yaml
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 the http {} context.
  • server_proxy.conf: Included inside every generated Proxy Host server {} block.
/opt/npm/data/nginx/custom/http_top.conf
# 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;
/opt/npm/data/nginx/custom/server_proxy.conf
# 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;
Custom Nginx Configuration Tab (Per-Host Override)
# Allow multi-gigabyte chunked uploads and disable disk buffering for media streams
client_max_body_size 16G;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

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.

Generating an Internal mTLS Root CA and Client PKCS#12 Bundle
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:

NPM Proxy Host Advanced Tab — mTLS Enforcement
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:

Verifying Nginx Configuration & Auditing Proxy Logs
# 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