nginx Scripts and Tools

Posted on 20 2026

The source material covers four scripts: static compression, OCSP staple generation, TLS session key rotation, and Tor exit node list management. Two of those are no longer needed.

OCSP stapling is handled automatically by nginx with ssl_stapling on in the global TLS configuration. The complex script for pre-generating OCSP response files was needed before nginx handled this natively. It is not needed here.

TLS session key rotation was needed when ssl_session_tickets on was used. This series disables session tickets (ssl_session_tickets off) in favour of session cache. There are no ticket keys to rotate.

Tor exit nodes is not relevant to this setup.

What is useful: static pre-compression, certificate renewal via certbot, and a health check script for verifying all virtual servers are responding correctly.

Static pre-compression

nginx can serve pre-compressed .gz files instead of compressing responses on the fly. For a static website with content that changes infrequently, pre-compressing once is more efficient than compressing on every request.

Create /usr/local/bin/nginx-precompress:

#!/usr/bin/env bash
#
# Pre-compress static web files for nginx gzip_static
# Reads document root directories from nginx configuration
# Creates .gz companion files for compressible file types
#
# Usage: sudo nginx-precompress
# Schedule: cron.daily or after deploying updated static content
#

set -euo pipefail

NGINX_USER='www-data'
NGINX_GROUP='www-data'
GZIP_CMD='/bin/gzip'

# File types to compress (matches nginx gzip_types in conf.d/gzip.conf)
FILETYPES='html htm css js xml json svg atom rss txt woff woff2'

# Log to syslog
log() {
    logger -t nginx-precompress --priority "user.${1}" "${2}"
}

if [[ ${EUID} -ne 0 ]]; then
    echo "This script must be run as root"
    exit 1
fi

log 'notice' "Starting static pre-compression for nginx"

# Extract document root directories from current nginx configuration
webdirs=$(nginx -qT 2>/dev/null | \
    grep -oP '^\s*(root|alias)\s+\K(/[^\s;]+)' | \
    sort -u)

if [[ -z "$webdirs" ]]; then
    log 'warning' "No document root directories found in nginx configuration"
    exit 0
fi

total_files=0
total_new=0
total_updated=0

for dir in $webdirs; do

    [[ -d "$dir" ]] || continue

    log 'info' "Processing directory: $dir"

    for filetype in $FILETYPES; do

        while IFS= read -r -d '' file; do

            total_files=$((total_files + 1))
            gz_file="${file}.gz"

            # Skip if already compressed and up to date
            if [[ -f "$gz_file" && "$gz_file" -nt "$file" ]]; then
                continue
            fi

            if [[ -f "$gz_file" ]]; then
                # Update outdated compressed file
                total_updated=$((total_updated + 1))
            else
                # Create new compressed file
                total_new=$((total_new + 1))
            fi

            # Compress keeping the original
            $GZIP_CMD --best --keep --force "$file"

            # Preserve ownership and permissions from original
            chown --reference="$file" "$gz_file"
            chmod --reference="$file" "$gz_file"
            touch --reference="$file" "$gz_file"

        done < <(find "$dir" -type f -name "*.${filetype}" -print0)

    done

done

log 'notice' "Pre-compression complete: ${total_files} files checked, ${total_new} new, ${total_updated} updated"

Make it executable:

sudo chmod 0755 /usr/local/bin/nginx-precompress

Enable pre-compressed file serving in the relevant virtual server:

# In a location block serving static files
location ~* \.(html|css|js|svg|xml|json)$ {
    gzip_static on;
    expires 1y;
    add_header Cache-Control 'public' always;
}

gzip_static on tells nginx to serve the .gz file if it exists and the client accepts gzip encoding. If no .gz file exists, nginx serves the original uncompressed file.

Schedule pre-compression to run after static content is deployed:

sudo tee /etc/cron.daily/nginx-precompress << 'EOF'
#!/usr/bin/env bash
/usr/local/bin/nginx-precompress
EOF

sudo chmod 0755 /etc/cron.daily/nginx-precompress

Certificate renewal with certbot

Let’s Encrypt certificates expire after 90 days. Certbot handles renewal automatically when installed as a systemd timer.

Install certbot:

sudo apt install certbot python3-certbot-nginx

Obtain a certificate for the first virtual server:

sudo certbot --nginx -d yourdomain.net -d www.yourdomain.net

Certbot edits the nginx virtual server configuration to add certificate paths. Review the changes and adjust to match the pattern from the Virtual Servers page.

Verify the renewal timer is active:

sudo systemctl status certbot.timer

Test renewal without actually renewing:

sudo certbot renew --dry-run

Certbot runs nginx -s reload automatically after a successful renewal. Verify the post-renewal hook is in place:

ls /etc/letsencrypt/renewal-hooks/deploy/

If no hooks exist, create one explicitly:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/usr/bin/env bash
systemctl reload nginx
EOF

sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Health check script

A script that connects to every virtual server and verifies the TLS connection, certificate validity, and response code. Run this after any nginx configuration change and after certificate renewal.

Create /usr/local/bin/nginx-healthcheck:

#!/usr/bin/env bash
#
# nginx virtual server health check
# Tests TLS connectivity and certificate validity for all configured servers
#
# Usage: nginx-healthcheck [--verbose]
#

set -uo pipefail

VERBOSE=false
[[ "${1:-}" == "--verbose" ]] && VERBOSE=true

PASS=0
FAIL=0
WARN=0

log_result() {
    local status="$1"
    local server="$2"
    local message="$3"

    case "$status" in
        PASS) echo "✓ ${server}: ${message}"; PASS=$((PASS + 1)) ;;
        FAIL) echo "✗ ${server}: ${message}" >&2; FAIL=$((FAIL + 1)) ;;
        WARN) echo "! ${server}: ${message}"; WARN=$((WARN + 1)) ;;
    esac
}

# Extract virtual server names from nginx configuration
servers=$(nginx -qT 2>/dev/null | \
    grep -oP '^\s*server_name\s+\K[^\s;]+' | \
    grep -v '^\*' | \
    grep '\.' | \
    sort -u)

if [[ -z "$servers" ]]; then
    echo "No virtual servers found in nginx configuration" >&2
    exit 1
fi

for server in $servers; do

    # Skip internal hostnames and localhost
    [[ "$server" =~ ^(localhost|127\.|10\.|192\.168\.) ]] && continue

    # Test HTTPS connection and certificate
    cert_info=$(openssl s_client \
        -connect "${server}:443" \
        -servername "$server" \
        -verify_return_error \
        2>/dev/null < /dev/null)

    if [[ $? -ne 0 ]]; then
        log_result FAIL "$server" "TLS connection failed"
        continue
    fi

    # Check certificate expiry
    expiry=$(echo "$cert_info" | \
        openssl x509 -noout -enddate 2>/dev/null | \
        cut -d= -f2)

    if [[ -n "$expiry" ]]; then
        expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null)
        now_epoch=$(date +%s)
        days_remaining=$(( (expiry_epoch - now_epoch) / 86400 ))

        if [[ $days_remaining -lt 0 ]]; then
            log_result FAIL "$server" "Certificate EXPIRED"
        elif [[ $days_remaining -lt 14 ]]; then
            log_result WARN "$server" "Certificate expires in ${days_remaining} days"
        else
            $VERBOSE && log_result PASS "$server" "Certificate valid for ${days_remaining} days"
        fi
    fi

    # Test HTTP response code
    http_code=$(curl -s -o /dev/null -w "%{http_code}" \
        --max-time 5 \
        "https://${server}/" 2>/dev/null)

    case "$http_code" in
        200|301|302|304)
            $VERBOSE && log_result PASS "$server" "HTTP ${http_code}"
            ;;
        000)
            log_result FAIL "$server" "Connection refused or timeout"
            ;;
        *)
            log_result WARN "$server" "Unexpected HTTP ${http_code}"
            ;;
    esac

done

echo ""
echo "Results: ${PASS} passed, ${WARN} warnings, ${FAIL} failed"

[[ $FAIL -gt 0 ]] && exit 1
exit 0

Make it executable:

sudo chmod 0755 /usr/local/bin/nginx-healthcheck

Run it:

# Quick check (only show warnings and failures)
sudo nginx-healthcheck

# Verbose (show all results including passing checks)
sudo nginx-healthcheck --verbose

Add it to the weekly anacron jobs to catch certificate issues early:

cat > ~/.anacron/cron.weekly/nginx-healthcheck << 'EOF'
#!/usr/bin/env bash
/usr/local/bin/nginx-healthcheck 2>&1 | \
    mail -s "nginx health check on $(hostname -s)" root
EOF

chmod 0755 ~/.anacron/cron.weekly/nginx-healthcheck

Log analysis

Quick commands for analysing nginx access logs:

# Top 10 client IP addresses by request count
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# Top 10 requested URLs
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# HTTP error responses in the last hour
awk -v d="$(date --date='1 hour ago' '+%d/%b/%Y:%H')" \
    '$4 ~ d && $9 >= 400 {print $9, $7}' \
    /var/log/nginx/access.log | sort | uniq -c | sort -rn

# Requests per minute (useful for detecting attacks)
awk '{print $4}' /var/log/nginx/access.log | \
    cut -d: -f1-3 | sort | uniq -c

# Check for rate limit hits (429 responses)
grep ' 429 ' /var/log/nginx/access.log | \
    awk '{print $1}' | sort | uniq -c | sort -rn | head -20

Testing after configuration changes

A sequence to run after any nginx configuration change:

# 1. Validate configuration syntax
sudo nginx -t

# 2. Reload nginx
sudo systemctl reload nginx

# 3. Run health check
sudo nginx-healthcheck --verbose

# 4. Check error log for any new issues
sudo journalctl -u nginx --since "5 minutes ago"

The health check script connects to servers from the server itself, not from an external perspective. It verifies the nginx configuration is serving correctly but does not test whether firewall port forwards are working or whether DNS resolves correctly from external networks. Test from an external connection (mobile hotspot or VPN to a different exit) after making firewall or DNS changes.