- HTML 98%
- Shell 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| fail2ban | ||
| knowledge | ||
| monitoring | ||
| nginx | ||
| scripts | ||
| templates | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
▐ ▄ ▄▄ • ▪ ▐ ▄ ▐▄• ▄ ▄▄▄ ▄▄▄·
•█▌▐█▐█ ▀ ▪██ •█▌▐█ █▌█▌▪▀▄ █·▐█ ▄█
▐█▐▐▌▄█ ▀█▄▐█·▐█▐▐▌ ·██· ▐▀▀▄ ██▀·
██▐█▌▐█▄▪▐█▐█▌██▐█▌▪▐█·█▌▐█•█▌▐█▪·•
▀▀ █▪·▀▀▀▀ ▀▀▀▀▀ █▪•▀▀ ▀▀.▀ ▀.▀ == 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
sudoand 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;innginx/nginx.confor 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 sudoersDefaults 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
- nginx HTTP Core Module
- ACME module · source
- Limit Request Module · Stream module
- mapcidr · tarampampam/error-pages
- Machine-readable knowledge bundle:
knowledge/(OKF)