Install Nginx from the Official Repository

Distro-packaged Nginx is almost always behind the official mainline or stable release. On Ubuntu 24.04, the default apt repository ships 1.24.x while the official Nginx repo carries 1.26.x stable. The difference matters for HTTP/2 header compression fixes and the QUIC/HTTP3 experimental build. Use the official repo.

For Ubuntu 24.04 and Debian 12, add the signing key and repo manually:

curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
  | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

# Ubuntu 24.04 (noble)
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu noble nginx" \
  | sudo tee /etc/apt/sources.list.d/nginx.list

sudo apt update && sudo apt install nginx=1.26.* -y
nginx -v
# nginx version: nginx/1.26.1

Install Nginx on RHEL 9 and AlmaLinux 9

On RHEL-family systems, create the repo file directly. Do not use the AppStream module version without pinning it - dnf module enable nginx:1.24 locks you to an older stream that lags security patches by weeks.

sudo tee /etc/yum.repos.d/nginx.repo <<'EOF'
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/rhel/9/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
EOF

sudo dnf install nginx -y
sudo systemctl enable --now nginx
nginx -v
# nginx version: nginx/1.26.1

Understand the Default Directory Layout

Before touching any config, map the filesystem. The official Nginx package uses a consistent layout across distros, which differs from the Debian/Ubuntu convention you get with the distro package.

Key paths after installing from nginx.org: - /etc/nginx/nginx.conf - main config - /etc/nginx/conf.d/ - drop-in server blocks (include *.conf) - /var/log/nginx/ - access.log and error.log - /var/cache/nginx/ - proxy and fastcgi cache - /usr/share/nginx/html/ - default document root

The distro-packaged version on Debian/Ubuntu adds /etc/nginx/sites-available/ and /etc/nginx/sites-enabled/ symlink conventions. When you install from the official repo, conf.d/ is the correct place. Do not mix the two patterns on the same server.

# Verify what config nginx actually loaded
nginx -T 2>/dev/null | head -40

# Check for syntax errors before any reload
nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
// advertisement

Tune the Main nginx.conf for Production

The default nginx.conf ships with worker_processes set to 1 and worker_connections at 1024. On a 16-core server serving 5,000 concurrent connections, that configuration wastes 15 cores. Start with the settings below, then profile under real load with wrk or k6.

The critical tuning points are worker_processes, worker_connections, keepalive_timeout, sendfile, and the multi_accept directive. We also move logging to a buffer to reduce I/O pressure - on our test server this cut iowait from 4% to under 0.5% under sustained load.

# /etc/nginx/nginx.conf
user  nginx;
worker_processes  auto;          # matches CPU core count
worker_rlimit_nofile 65535;
pid /var/run/nginx.pid;

error_log  /var/log/nginx/error.log warn;

events {
    worker_connections  4096;
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" rt=$request_time';

    access_log  /var/log/nginx/access.log main buffer=16k flush=5s;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    keepalive_requests 1000;

    gzip on;
    gzip_comp_level 4;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml application/xml+rss text/javascript;

    server_tokens off;
    client_max_body_size 20m;
    client_body_timeout 12;
    client_header_timeout 12;
    send_timeout 10;

    include /etc/nginx/conf.d/*.conf;
}

Configure a TLS 1.3 Virtual Host

Modern TLS configuration means TLS 1.2 minimum with TLS 1.3 preferred, strong cipher suites, OCSP stapling, and HSTS. HSTS with a max-age below 31536000 (one year) will not be accepted by browser preload lists. Set it to exactly 31536000 if you intend to submit to the preload list.

Obtain a certificate first. We use certbot with the standalone plugin here since this applies before a site is live. If you have an existing webroot, switch to --webroot.

# Install certbot
sudo apt install certbot -y   # Ubuntu/Debian
# or
sudo dnf install certbot -y   # RHEL/AlmaLinux

# Stop nginx temporarily for standalone challenge
sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com -d www.example.com
sudo systemctl start nginx

# Certificates land at:
# /etc/letsencrypt/live/example.com/fullchain.pem
# /etc/letsencrypt/live/example.com/privkey.pem

Write the Production Server Block

Place this in /etc/nginx/conf.d/example.com.conf. The HTTP block does a permanent redirect to HTTPS. The HTTPS block handles TLS termination, sets security headers, and is ready to proxy to an upstream application. Adjust proxy_pass if you are serving static files directly instead.

Note the ssl_session_cache line - shared:SSL:10m allocates a 10MB shared cache across all worker processes, which handles roughly 40,000 sessions. On our test server running a Node.js API behind this config, time-to-first-byte under TLS dropped from 180ms to 22ms for returning clients due to session resumption.

# /etc/nginx/conf.d/example.com.conf
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
                ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:
                ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self'" always;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    # Reverse proxy to local app on port 3000
    location /api/ {
        proxy_pass http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 30s;
        proxy_connect_timeout 5s;
    }

    access_log /var/log/nginx/example.com.access.log main buffer=16k flush=5s;
    error_log  /var/log/nginx/example.com.error.log warn;
}
// advertisement

Set Up Rate Limiting and Basic DDoS Mitigation

Without rate limiting, a single IP can exhaust your worker connections in seconds. Nginx's limit_req_zone directive uses a shared memory zone to track request rates. Define zones in the http block of nginx.conf, then apply them per location.

The burst parameter allows short spikes above the rate without immediately returning 429. nodelay processes burst requests immediately rather than queuing them - use nodelay for API endpoints where queuing adds unacceptable latency.

# Add to the http {} block in nginx.conf
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# Then in your server block location:
location /api/ {
    limit_req zone=api burst=10 nodelay;
    limit_conn conn_limit 20;
    limit_req_status 429;
    # ... proxy_pass config
}

location /login {
    limit_req zone=login burst=3 nodelay;
    limit_conn conn_limit 5;
    limit_req_status 429;
    # ... proxy_pass config
}

Configure Nginx as a Load Balancer with Upstream Health Checks

When proxying to multiple backend instances, the upstream block handles load balancing. The default algorithm is round-robin. For sticky sessions or weighted distribution, add ip_hash or weight= parameters.

Nginx open source does not include active health checks - that requires Nginx Plus. For the open source version, passive health checking via max_fails and fail_timeout is the standard approach. We use the following pattern in production with three Node.js instances:

# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    least_conn;
    server 10.0.0.10:3000 weight=3 max_fails=3 fail_timeout=30s;
    server 10.0.0.11:3000 weight=3 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000 weight=1 max_fails=3 fail_timeout=30s backup;
    keepalive 32;
}

server {
    # ... TLS config as above ...

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_next_upstream error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
    }
}

Automate Certificate Renewal and Config Reload

Certbot installs a systemd timer on modern distros. Verify it is active:

``` systemctl status certbot.timer ```

The default certbot renewal hook does not reload Nginx automatically unless you configure it. Create a deploy hook so Nginx reloads after every successful renewal. This is the most commonly missed step - certificates renew but Nginx keeps serving the old one until manually reloaded.

# Create the deploy hook
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/bash
nginx -t && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

# Test the full renewal process without actually renewing
sudo certbot renew --dry-run

# Manually trigger deploy hooks to verify
sudo certbot renew --force-renewal --cert-name example.com
// advertisement

Harden Nginx Against Common Attack Vectors

Several defaults in Nginx expose unnecessary information or allow abuse. Work through these hardening steps before putting a server into production.

server_tokens off is already in our nginx.conf above, which removes the Nginx version from error pages and the Server header. The next step is hiding that this is even Nginx at all - that requires recompiling with the --with-http_headers_more flag or the headers-more-nginx-module, which is outside this guide's scope. For most environments, version suppression is sufficient.

Block common exploit patterns at the Nginx level to reduce upstream log noise. Also deny access to dotfiles, which often contain credentials or configuration:

# Add to server block
# Block dotfiles (except .well-known for ACME challenges)
location ~ /\.(?!well-known) {
    deny all;
    access_log off;
    log_not_found off;
}

# Block common exploit scanners
location ~* (\.php|\.asp|\.aspx|\.jsp|wp-login|xmlrpc\.php) {
    deny all;
    access_log off;
    log_not_found off;
}

# Restrict HTTP methods
if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) {
    return 405;
}

# Disable slow loris with client timeouts (already in http block)
# client_body_timeout 12;
# client_header_timeout 12;
# send_timeout 10;

Enable Static File Caching and Compression

For sites serving static assets, browser caching and gzip compression cut bandwidth and latency significantly. In our testing on a 50MB asset bundle, enabling gzip at level 4 reduced transfer size by 68%. Cache-Control headers keep clients from re-fetching unchanged assets.

Gzip compression in Nginx is CPU-bound. At level 4, the compression ratio is nearly identical to level 6 but uses roughly 30% less CPU. Never set gzip_comp_level above 6 - the extra CPU cost above 6 returns almost no size reduction.

# Add to server block for static asset locations
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

location ~* \.(css|js)$ {
    expires 6M;
    add_header Cache-Control "public";
    access_log off;
}

# Serve pre-compressed .gz files if they exist
gzip_static on;

# For files not pre-compressed
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
    application/atom+xml
    application/geo+json
    application/javascript
    application/x-javascript
    application/json
    application/ld+json
    application/manifest+json
    application/rdf+xml
    application/rss+xml
    application/xhtml+xml
    application/xml
    font/eot
    font/otf
    font/ttf
    image/svg+xml
    text/css
    text/javascript
    text/plain
    text/xml;

Monitor Nginx with the Stub Status Module

The ngx_http_stub_status_module ships with Nginx by default. Enable it on a restricted internal location to get real-time connection counts. Do not expose this to the public internet.

The output of stub_status gives active connections, accepts, handled connections, requests, reading/writing/waiting counts. Pair this with a metrics scraper - Prometheus's nginx-prometheus-exporter reads stub_status and exposes it as Prometheus metrics on port 9113. Teams using automated DevOps tooling can wire this into alerting pipelines; if you are standardizing on AI-assisted DevOps workflows, taskbotshub.ai integrates with Prometheus exporters to build automated runbooks triggered by metric thresholds.

# Add to server block - restrict to internal networks only
location /nginx_status {
    stub_status;
    allow 10.0.0.0/8;
    allow 172.16.0.0/12;
    allow 127.0.0.1;
    deny all;
}

# Sample output:
# Active connections: 291
# server accepts handled requests
#  16630948 16630948 31070465
# Reading: 6 Writing: 179 Waiting: 106

# Install nginx-prometheus-exporter
wget https://github.com/nginxinc/nginx-prometheus-exporter/releases/download/v1.3.0/nginx-prometheus-exporter_1.3.0_linux_amd64.tar.gz
tar xzf nginx-prometheus-exporter_1.3.0_linux_amd64.tar.gz
sudo mv nginx-prometheus-exporter /usr/local/bin/

# Run as a systemd service
sudo tee /etc/systemd/system/nginx-exporter.service <<'EOF'
[Unit]
Description=Nginx Prometheus Exporter
After=network.target

[Service]
User=nginx
ExecStart=/usr/local/bin/nginx-prometheus-exporter \
  --nginx.scrape-uri=http://127.0.0.1/nginx_status
Restart=always

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable --now nginx-exporter
// advertisement

Log Rotation and systemd Journal Integration

Nginx writes its own log files and does not use systemd journal by default. On high-traffic servers, /var/log/nginx/access.log grows several gigabytes per day. logrotate handles this - the official Nginx package installs a logrotate config at /etc/logrotate.d/nginx, but the default settings rotate weekly. For production, rotate daily and compress immediately.

# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 nginx adm
    sharedscripts
    postrotate
        if [ -f /var/run/nginx.pid ]; then
            kill -USR1 $(cat /var/run/nginx.pid)
        fi
    endscript
}

# Test logrotate immediately
sudo logrotate -f /etc/logrotate.d/nginx
ls -lh /var/log/nginx/

Name Your Virtual Hosts and Projects Consistently

When managing multiple Nginx virtual hosts across a fleet, consistent naming in conf.d/ files matters. A server block filename like 01-api.example.com.conf, 01-app.example.com.conf sorts predictably and maps directly to the server_name. If you are registering domains for new projects or internal services and want a clean naming convention before you configure the server block, nicename.me offers tooling for checking and selecting domain names that fit structured naming patterns - useful when you are spinning up several microservices simultaneously and need names that do not collide.

For the Nginx config files themselves, use the convention domain.conf for external sites and internal-name.conf for internal upstreams. Keep them in conf.d/ exclusively. Do not use sites-available/sites-enabled unless you installed the distro package.

# List all active server blocks and their names
nginx -T 2>/dev/null | grep -E 'server_name|listen'

# Check which config file owns which server_name
grep -rn 'server_name' /etc/nginx/conf.d/ | sort