nginx reverse proxy — automated HTTPS, rate limiting, threat blocklists, and security headers, with install/site-creation scripts and docs
  • HTML 98%
  • Shell 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
shm0rt 002479d09a fix(default-server): click toggles play/pause, add poster fallback
Click only paused with no way to resume — stuck video with no path
forward once clicked. Now toggles: play() if paused, pause() if
playing. Also adds poster.jpg (first frame) so any load where autoplay
doesn't fire shows a real frame instead of a plain black box.
2026-07-05 01:41:12 +02:00
fail2ban feat(fail2ban): install and enable nginx jails by default 2026-07-05 00:46:41 +02:00
knowledge fix(default-server): click toggles play/pause, add poster fallback 2026-07-05 01:41:12 +02:00
monitoring Initial commit: nginx reverse proxy config, scripts, and docs 2026-06-21 23:11:02 +02:00
nginx fix(default-server): click toggles play/pause, add poster fallback 2026-07-05 01:41:12 +02:00
scripts fix(update): re-exec after pull so self-modified logic actually runs 2026-07-05 01:13:14 +02:00
templates refactor(template): inherit headers at server level, researched login regex 2026-06-29 05:43:03 +02:00
.gitignore Initial commit: nginx reverse proxy config, scripts, and docs 2026-06-21 23:11:02 +02:00
LICENSE Initial commit: nginx reverse proxy config, scripts, and docs 2026-06-21 23:11:02 +02:00
README.md fix(update): re-link nginx/* on every update, not just install 2026-07-05 01:09:18 +02:00

 ▐ ▄  ▄▄ • ▪   ▐ ▄ ▐▄• ▄ ▄▄▄   ▄▄▄·
•█▌▐█▐█ ▀ ▪██ •█▌▐█ █▌█▌▪▀▄ █·▐█ ▄█
▐█▐▐▌▄█ ▀█▄▐█·▐█▐▐▌ ·██· ▐▀▀▄  ██▀·
██▐█▌▐█▄▪▐█▐█▌██▐█▌▪▐█·█▌▐█•█▌▐█▪·•
▀▀ █▪·▀▀▀▀ ▀▀▀▀▀ █▪•▀▀ ▀▀.▀  ▀.▀     ==  nginx-rp

A production-ready nginx reverse proxy for a plain Ubuntu VM. One install script sets up automatic SSL, rate limiting, an auto-refreshed threat blocklist, and friendly error pages.

  • Automatic SSL — Let's Encrypt via the native ACME module (no certbot)
  • Modern TLS — TLS 1.3 only, post-quantum hybrid key exchange (OpenSSL 3.5+)
  • Rate limiting — per-IP request zones, trusted IPs exempt
  • Threat blocklist — daily-aggregated IP blocklist + offensive-scanner UA filter
  • Fail2ban — bans IPs on nginx auth failures, rate-limit hits, and bot-search probes
  • Compression — gzip for text responses
  • HTTP & stream — HTTP/HTTPS vhosts and raw TCP/UDP services, both scripted
  • Custom error pages — themed 4xx/5xx pages from tarampampam/error-pages

Using this repo as an agent? A machine-readable Open Knowledge Format bundle lives in knowledge/ — start there.

Prerequisites

  • An Ubuntu machine with sudo and a public IP
  • DNS for your domain(s) pointing at it
  • Ports 80 + 443 open and routed to the machine (HTTP + HTTPS/ACME)
  • Your public IP (exempted from rate limiting)
  • OpenSSL 3.5+ for the post-quantum curve (Ubuntu 25.10+/26.04). On older OpenSSL, set ssl_ecdh_curve X25519:prime256v1; in nginx/nginx.conf or nginx won't start.

install.sh installs everything else — nginx (nginx.org mainline) + the ACME module, Go + mapcidr for the blocklist, and fail2ban.

Installation

One line — clones to /opt/nginx-rp, installs nginx + the ACME module, prompts for your public IP (the rate-limit exemption), then wires everything up:

sudo bash -c "$(curl -fsSL https://quelloffen.ch/shm0rt/nginx-rp/raw/branch/main/scripts/install.sh)"

Pass the script as an argument — not … | sudo bash. Piping breaks the interactive prompt on hosts with sudoers Defaults use_pty (the modern Ubuntu/Debian default). For an unattended install, set the IP up front: sudo PUBLIC_IP=203.0.113.10 bash -c "$(curl -fsSL …)".

Prefer a checkout? Clone first, then run the same script:

sudo git clone https://quelloffen.ch/shm0rt/nginx-rp /opt/nginx-rp
sudo /opt/nginx-rp/scripts/install.sh

Only the nginx/ subtree is symlinked into /etc/nginx; repo files (LICENSE, scripts/, templates/, knowledge/) never enter the live config, and per-host values stay in gitignored files — so git pull always updates cleanly.

Common Commands

Command What it does
sudo ./scripts/install.sh Full setup — packages, config, /etc/nginx symlinks, daily cron
sudo ./scripts/update.sh Pull latest, nginx -t, reload (clean fast-forward)
sudo ./scripts/create-site.sh <fqdn> <ip:port> Add an HTTP/HTTPS site
sudo ./scripts/create-site.sh --stream <name> <port> <ip:port> Add a TCP/UDP service
sudo ./scripts/update-smart-blocklist.sh Rebuild the threat blocklist now
sudo systemctl reload nginx Apply manual config edits
sudo ./scripts/install.sh uninstall Remove symlinks + cron, restore *.bak backups
sudo ./scripts/install.sh link / unlink (Re)create / remove only the symlinks

uninstall/unlink leave the nginx/Go/mapcidr packages installed. Each command is covered in detail in its own section below.

Adding an HTTP Site

create-site.sh fills in templates/http-site.conf, writes nginx/conf.d/<domain>.conf, runs nginx -t, and reloads on success. The upstream id defaults to the domain with dots turned into dashes.

sudo ./scripts/create-site.sh                                  # interactive prompts
sudo ./scripts/create-site.sh cloud.example.com 10.0.0.5:8080  # fqdn  backend

The generated vhost redirects HTTP→HTTPS, requests a certificate automatically, and includes the recommended feature set (SSL, proxy headers, security headers, error pages, rate limiting, blocklist). Edit nginx/conf.d/<domain>.conf afterwards to tune it; your vhosts are gitignored.

Stream (TCP/UDP) Services

For raw TCP/UDP proxying — databases, SMTP, SSH, game servers — nginx.conf has a stream {} block that includes stream.d/*.conf. Streams route by listen port (there is no server_name), so each service needs its own port.

The same generator with --stream fills in templates/stream-service.conf and writes nginx/stream.d/<name>.conf:

sudo ./scripts/create-site.sh --stream                              # interactive prompts
sudo ./scripts/create-site.sh --stream postgres 5432 10.0.0.5:5432  # name port backend
sudo ./scripts/create-site.sh --stream dns 53 10.0.0.5:53 --udp     # add --udp for UDP

Or copy the template into nginx/stream.d/ and edit by hand. The stream module is built into the nginx.org mainline package the installer uses; stream services are gitignored like vhosts.

Updating

sudo ./scripts/update.sh

Pulls the latest repo (clean fast-forward), re-links nginx/* into /etc/nginx (so a newly-added top-level directory like nginx/rickroll/ doesn't end up missing on hosts installed before it existed), runs nginx -t, and reloads. Because every per-host value lives in gitignored files (nginx/local/, your vhosts, stream services, and the blocklist), the tracked tree is never modified on the VM — so updates never hit a merge conflict. If a pull ever isn't a fast-forward, the script stops and leaves the tree untouched.

Maintenance

Ownership. install.sh hands /opt/nginx-rp to the admin who ran it (via SUDO_USER), so you edit the config without sudo. This stays secure: nginx runs as root and reads the symlinked config regardless of file owner, and applying any change still needs sudo (reload). Run the scripts as your admin user with sudo, not as the root account. If you ever see git's "dubious ownership" warning, run git config --global --add safe.directory /opt/nginx-rp.

Auto security updates. Keep nginx patched against CVEs with unattended-upgrades. Ubuntu's default config only covers the Ubuntu pocket, not nginx.org — add the origin so mainline gets patched. In /etc/apt/apt.conf.d/50unattended-upgrades, under Origins-Pattern, add:

"origin=nginx";

Verify: sudo unattended-upgrades --dry-run --debug 2>&1 | grep -i nginx.

Update safely. sudo ./scripts/update.sh pulls as the repo owner, re-links nginx/* into /etc/nginx, runs nginx -t, and reloads — a clean fast-forward (it stops untouched if not). By hand (skips the re-link — fine unless the pull added a new top-level nginx/ directory):

cd /opt/nginx-rp && git pull --ff-only && sudo nginx -t && sudo systemctl reload nginx

Smart Blocklist

scripts/update-smart-blocklist.sh rebuilds nginx/blocklist/smart-blocklist.txt from public threat-intelligence feeds, aggregates the CIDR ranges with mapcidr, writes them as nginx map entries, then reloads nginx. The installer schedules it via cron (daily, 06:00); run it anytime:

sudo /opt/nginx-rp/scripts/update-smart-blocklist.sh

Sources (one fetch each, then aggregated with mapcidr): Spamhaus DROP + DROPv6, Feodo Tracker, DShield block list, and Blocklist.de (apache). The generated file is gitignored and feeds the map $remote_addr $is_blocked block, consumed by block.conf (returns 444 on a match).

Custom entries: add your own to nginx/blocklist/custom-blocklist.txt (IP_OR_CIDR 1;, one per line):

1.2.3.4 1;
2.3.4.0/24 1;

Feature Reference

Mostly composable snippets in nginx/features/, included from a vhost (see templates/http-site.conf) and resolving under /etc/nginx/features/ at runtime — plus a couple of full services (fail2ban) that aren't nginx includes at all.

Automatic SSL — acme-ssl.conf

Native ACME (no certbot): certificates are requested and renewed automatically. The issuer in nginx.conf uses the tls-alpn-01 challenge over TLSv1.3 and caches state in /var/cache/nginx/acme; the include points ssl_certificate at the ACME-managed cert (HSTS lives in security-headers.conf). Your contact email is the contact line in the acme_issuer block of nginx.conf.

Rate limiting — default-rate-limit.conf, login-rate-limit.conf

All zones key on $untrusted_ip, which is empty for trusted IPs (RFC1918, localhost, and your public IP via geo $is_trusted_ip) so they are never limited; exceeding a limit returns 429. nginx.conf defines four zones:

Zone Limit Snippet / use
normal_limit 30 r/s default-rate-limit.conf (burst=200 nodelay) — general traffic, applied site-wide by the template
login_limit 5 r/s login-rate-limit.conf (burst=10 nodelay) — wired to a researched auth-endpoint regex in the template (WordPress, Devise/Rails, OAuth/OIDC token+authorize, NextAuth, password reset, MFA)

The login regex deliberately excludes high-frequency reads (NextAuth /api/auth/session, OIDC /.well-known/…) so single-page apps are not throttled. Tune it per-vhost for unusual login paths.

Security headers — security-headers.conf

HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy. Set once at the server level in the template and inherited by all locations via add_header_inherit (so a location can add its own headers without losing these).

Threat blocklist & bad bots — block.conf

Returns 444 (connection closed, no response) to any IP in the smart blocklist or your custom list ($is_blocked), and to offensive-scanner User-Agents ($is_bad_bot — sqlmap, nikto, nmap, etc.). The UA list is curated and static: legit crawlers (Googlebot, bingbot, …) and normal clients are never matched. UA is spoofable, so this cuts scanner noise rather than stopping determined attackers — the IP feeds remain the real filter.

Fail2ban — fail2ban/jail.local

Installed and enabled by default, not opt-in. Overrides the packaged /etc/fail2ban/jail.conf (never edited directly) to enable four stock jails on top of nginx's own logs: nginx-http-auth (repeated basic-auth failures), nginx-limit-req (tripping the rate limits above), nginx-botsearch (probes for wp-login.php, phpmyadmin, etc.), and nginx-bad-request (malformed requests). bantime=1h with bantime.increment=true, findtime=10m, maxretry=5. install.sh copies it and restarts fail2ban only after nginx is up, since the jails need /var/log/nginx/* to already exist.

Custom error pages — error-pages.conf

Themed pages from tarampampam/error-pages (the lost-in-space theme) for 400 401 403 404 405 407 408 409 410 411 412 413 416 418 429 500 502 503 504 505. The error_page directives live in nginx.conf; error-pages.conf serves the files from the internal /error-pages/ location (alias /etc/nginx/error-pages/). Pages live in nginx/error-pages/ — edit the HTML/CSS to rebrand.

Proxy headers & WebSocket — proxy-headers.conf

Forwards Host, X-Real-IP, X-Forwarded-For/Proto/Host/Port, the $connection_upgrade-aware Connection header, and Upgrade over proxy_http_version 1.1. As the edge proxy, forwarded values are derived from the real connection ($host, $remote_addr, $scheme) rather than trusting client-supplied X-Forwarded-* — this prevents spoofing and host-header injection. It also strips the Proxy request header (httpoxy, CVE-2016-5385). WebSocket works out of the box (the Upgrade header is universal); there is no separate snippet — for apps holding idle sockets >60s, add proxy_read_timeout 3600s; in that vhost. If another reverse proxy sits in front, enable the optional behind-proxy.conf (see below).

Local-only — local-only.conf

Restricts a site or location to trusted networks, returning 403 to everyone else. Reuses the $is_trusted_ip geo block from nginx.conf (RFC1918 + localhost

  • your public IP), so it stays in sync with the rate-limit exemption — no separate list to maintain. Useful for admin panels.

Compression — gzip (in nginx.conf)

gzip for text responses (text/css, JS, JSON, XML, SVG, wasm; HTML always included). gzip_proxied any enables it for proxied responses. nginx skips anything the backend already compressed, so self-compressing apps are unaffected.

Behind a front proxy — behind-proxy.conf

Optional. Include only when another reverse proxy / load balancer sits in front. Uses real_ip to recover the true client IP from the front proxy's X-Forwarded-For (trusting only the addresses you list in the gitignored nginx/local/front-proxy-ips.conf), keeping rate-limiting, the blocklist, and forwarded headers correct. Enable per-site (uncomment the include in a vhost) or globally (include it in nginx.conf http{}). Assumes this nginx still terminates TLS. Not enabled by default — the edge-hardened defaults are the norm.

Default server (rickroll) — conf.d/default.conf

Catch-all for undefined domains and direct-IP scans: HTTP serves an autoplaying, looping rickroll video from nginx/rickroll/ (own root, no redirect — nothing leaves the box), and HTTPS handshakes are rejected (ssl_reject_handshake on) so scanners can't enumerate hosted domains.

Both nginx/rickroll/index.html and nginx/rickroll/rickroll.mp4 are tracked in the repo — ships with every clone. Re-encoded as H.264/AAC MP4 with +faststart so playback starts immediately:

ffmpeg -i input.mp4 -c:v libx264 -profile:v high -pix_fmt yuv420p -crf 23 \
       -c:a aac -b:a 128k -movflags +faststart rickroll.mp4

The page tries unmuted autoplay first (browsers usually block it without a user gesture) and falls back to muted — always plays, sound depends on the browser's autoplay policy.

Monitoring — Prometheus exporter (opt-in)

Not installed by default. The nginx-prometheus-exporter lives in its own script:

sudo ./scripts/monitoring/prometheus-exporter.sh            # install
sudo ./scripts/monitoring/prometheus-exporter.sh uninstall  # remove

Install builds the exporter, enables monitoring/stub_status.conf (a localhost-only stub_status endpoint on 127.0.0.1:8080) by copying it into nginx/conf.d/, and runs a systemd service. Metrics are served on http://127.0.0.1:9113/metrics. uninstall reverses it (the binary is left in /usr/local/bin).

Documentation