Nginx Site Configuration in Practice

As the entry point of a website, Nginx is most often used in three ways: hosting static sites, forwarding PHP requests to PHP-FPM, and reverse-proxying API traffic to Node.js. This article puts the three configurations side by side, then covers the commands you run when going live and the mistakes that cost people the most time. Whatever the use case, the config revolves around two blocks: server decides which site a request matches, and location decides which handler the request lands on.

Scenario 1: Static site + HTTPS

Static sites (HTML, CSS, JS) need the least configuration. The block below 301-redirects port 80 to HTTPS and sets a one-year cache on static assets:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    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;

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

    location / {
        try_files $uri $uri/ /index.html;
    }

    location ~* \.(jpg|jpeg|png|gif|ico|css|js|webp|svg)$ {
        expires 365d;
        add_header Cache-Control "public, immutable";
    }

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Reference: Nginx official documentation https://nginx.org/en/docs/

Certificates are available for free from Let's Encrypt; see Certbot automation for automated renewal.

Scenario 2: PHP-FPM proxy (WordPress / Laravel)

Dynamic sites must hand .php requests to PHP-FPM, and the try_files fallback keeps front-end routes landing on index.php:

server {
    listen 443 ssl http2;
    server_name example.com;

    root /var/www/example.com/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}

Scenario 3: Node.js reverse proxy

Node applications usually listen on a local port such as 3000, with Nginx as the public entry point. For WebSocket traffic, remember to forward the Upgrade header:

server {
    listen 443 ssl http2;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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;
    }
}

Key directives explained

A few directives appear in all of these configs and deserve attention: server_name matches requests by domain — a typo yields 404s or wrong-site matches; root points at the site directory and must match the real deployment path; try_files tries files in order with the last entry as the fallback; add_header appends response headers and applies to all status codes once always is added. In proxy scenarios, proxy_pass decides where the request goes, while proxy_set_header forwards the original Host, client IP, and scheme to the backend — backend logs and analytics depend on those fields.

Comparing the three configurations

Scenario Core directive Typical use Key gotcha
Static site root / try_files Landing pages, docs sites Cache static assets
PHP-FPM fastcgi_pass WordPress, Laravel Don't miswrite SCRIPT_FILENAME
Reverse proxy proxy_pass Node.js, APIs, WebSocket Forward Host and real IP

Logging and access control

Once the site is live, logs are your first source of truth for troubleshooting. The default config already records every request in access.log; consider a custom format in the server block that also captures response time and request body size so slow requests are easy to spot:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" $request_time';
access_log /var/log/nginx/example.com.access.log main;

Don't skip basic access control either: explicitly deny hidden directories like .git, .env, and backup; restrict admin panels by IP with allow/deny; and pair that with fail2ban against brute-force scanning. Together these rules block a good chunk of unwanted visitors at the config layer. Also rotate logs regularly with logrotate so a single log file never grows into a disk problem.

Organizing config with include

Once you have many sites, writing every config out in full gets tedious. Nginx supports include, so extract the shared parts into snippets: SSL params in conf.d/ssl.conf, cache rules in conf.d/cache.conf, and pull them in from site configs. Adding a new site then only requires the differing bits — server_name, root, and location — which is much easier to maintain.

Performance tips

Basics like caching static assets, enabling HTTP/2, and hiding the version string make sites noticeably faster. HTTP/2 is already enabled by listen 443 ssl http2; static assets get long caches via expires; server_tokens off hides the version banner and reduces information leakage. For systematic tuning, follow the Nginx optimization guide.

Going live

# 1. Create the site config
sudo nano /etc/nginx/sites-available/example.com

# 2. Enable the site
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/

# 3. Validate the config
sudo nginx -t

# 4. Reload Nginx
sudo systemctl reload nginx

# 5. Check status
sudo systemctl status nginx

Common pitfalls

  1. Forgetting nginx -t before reloading. A syntax error makes Nginx refuse to start at all; validate first, then reload.
  2. The real IP is lost through the proxy. Backends see only 127.0.0.1 as REMOTE_ADDR unless you set X-Real-IP and X-Forwarded-For.
  3. Wrong SSL certificate paths. After renewal the path may not match and Nginx reports no such file; manage certificate paths through symlinks.
  4. try_files fallback behaving unexpectedly. Single-page apps fall back to /index.html; PHP apps to /index.php?$query_string. Don't mix them up.
  5. The trailing-slash proxy_pass trap. proxy_pass http://127.0.0.1:3000 without a slash preserves the full URI, while a trailing slash strips the matched prefix — the behaviors differ a lot, so think it through before configuring.

FAQ

Config changes have no effect? Nginx does not auto-reload; run nginx -t, then systemctl reload nginx. Also confirm the site is symlinked into sites-enabled. How do I unify www and non-www domains? Declare both names in the 80 and 443 server blocks and add a 301 from www to the apex domain to avoid duplicate content. PHP site returns 404 instead of executing? A common cause is a try_files fallback written as /index.html; PHP apps should fall back to /index.php?$query_string. How do I know a cert is expiring? Use certbot's renew hook to auto-renew 30 days before expiry; failures trigger an email. How do I reuse config across many sites? Split shared cache and security-header rules into conf.d/*.conf and include them; site configs keep only their differences. How do I customize 404 pages? Use error_page 404 /404.html; in the server block and drop a 404.html in root. How do I rate-limit per IP? Combine limit_req_zone with limit_req on sensitive paths like login endpoints.

For performance tuning, see the Nginx optimization guide; for the full picture on proxying, read the Nginx reverse proxy guide. If you deploy with Docker, multi-container deployment is worth a look. All of this falls under the environment deployment category.