Nginx 101: how it actually works (from a newbie's perspective)

I spent an entire evening wondering why rustpad.mrsubodh.xyz was serving QuakeJS. The answer, as always, was nginx. This post is everything I wish I knew before I touched a single config file.

What is nginx anyway?

Nginx (pronounced engine-x) is a web server. But calling it just a web server is like calling a Swiss Army knife just a knife. It also works as:

  • a reverse proxy — sit in front of your app and forward requests to it
  • a load balancer — split traffic across multiple backends
  • a static file server — serve HTML, CSS, JS directly from disk
  • an SSL terminator — handle HTTPS so your app doesn't have to

Most self-hosters use it as a reverse proxy + SSL terminator. Your app runs on some internal port (3030, 8080, whatever) and nginx sits in front of it, handling the public internet traffic.

Internet → nginx (port 443, SSL) → your app (port 3030, no SSL)

The file structure

On a Debian/Ubuntu system nginx lays out its files like this:

/etc/nginx/
├── nginx.conf                  ← the master config
├── sites-available/            ← all your vhost configs live here
│   ├── default
│   ├── myapp
│   └── blog.mrsubodh.xyz
├── sites-enabled/              ← symlinks to the ones nginx actually loads
│   ├── default -> ../sites-available/default
│   └── blog.mrsubodh.xyz -> ../sites-available/blog.mrsubodh.xyz
├── conf.d/                     ← alternative place for configs (auto-loaded)
├── modules-enabled/            ← dynamically loaded modules
├── mime.types                  ← maps file extensions to Content-Type headers
└── snippets/                   ← reusable config fragments

The key insight: nginx only loads what's in sites-enabled. sites-available is just a staging area. If your config isn't symlinked into sites-enabled, nginx ignores it completely this is the #1 cause of "why isn't my site working".

nginx.conf — the master config

# /etc/nginx/nginx.conf (simplified)

user www-data;                      # the user nginx worker processes run as
worker_processes auto;              # one worker per CPU core

events {
    worker_connections 1024;        # max simultaneous connections per worker
}

http {
    include /etc/nginx/mime.types;          # file extension → Content-Type
    include /etc/nginx/conf.d/*.conf;       # load everything in conf.d/
    include /etc/nginx/sites-enabled/*;     # load all enabled vhosts
}

You rarely touch nginx.conf directly. All your site-specific config goes in sites-available/.


The server block — the heart of nginx

Every site you host is defined in a server {} block. Think of it as a virtual host nginx can serve hundreds of different domains from a single machine by matching the server_name directive.

server {
    listen 80;                      # which port to listen on
    server_name example.com;        # which domain this block handles

    root /var/www/example;          # where the files live on disk
    index index.html;               # default file to serve

    location / {
        try_files $uri $uri/ =404;  # try the file, then directory, then 404
    }
}

When a request comes in, nginx:

  1. Looks at which port it arrived on
  2. Matches the Host header against all server_name values
  3. Routes the request to the matching server {} block
  4. Processes the location {} blocks inside it

Location blocks routing within a site

location blocks let you handle different URL paths differently:

server {
    server_name tools.mrsubodh.xyz;

    # exact match — only /health
    location = /health {
        return 200 "ok";
    }

    # prefix match — /api and everything under it
    location /api {
        proxy_pass http://127.0.0.1:8080;
    }

    # regex match — all .jpg and .png files
    location ~* \.(jpg|png|gif)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # catch-all — everything else
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Match priority (highest to lowest):

  1. = exact match
  2. ^~ prefix match (stops searching if matched)
  3. ~ and ~* regex (case-sensitive and case-insensitive)
  4. Plain prefix match
  5. / catch-all

Reverse proxying — the most important thing nginx does

Instead of serving files directly, nginx can forward requests to another process:

server {
    listen 80;
    server_name rustpad.mrsubodh.xyz;

    location / {
        proxy_pass http://127.0.0.1:3030;   # forward to local port 3030

        # these headers tell the backend who the real client is
        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;
    }
}

Without those proxy_set_header lines your backend thinks every request came from 127.0.0.1 which breaks rate limiting, logging, and any geo-based logic.

WebSocket proxying

Regular HTTP proxying doesn't work for WebSockets. You need two extra headers:

location / {
    proxy_pass http://127.0.0.1:3030;
    proxy_http_version 1.1;

    # these two lines are what make WebSockets work
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_set_header Host $host;
    proxy_read_timeout 86400;    # keep WS connections alive for 24h
}

Forgetting Upgrade and Connection is why collaborative apps like Rustpad connect but don't sync in real time.


SSL with Let's Encrypt

Raw HTTP is fine for internal tools but anything public-facing needs HTTPS. Certbot handles this automatically:

# install certbot
sudo apt install certbot python3-certbot-nginx -y

# get a certificate and auto-configure nginx
sudo certbot --nginx -d yourdomain.com

Certbot modifies your nginx config and adds the SSL blocks. The result looks like:

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # certificate files managed by certbot
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # certbot-generated best-practice SSL settings
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://127.0.0.1:3030;
        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-Forwarded-Proto https;
    }
}

# redirect all HTTP → HTTPS
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

Certificates auto-renew via a systemd timer. Check the timer:

sudo systemctl status certbot.timer

Enabling and disabling sites

The sites-available / sites-enabled pattern uses symlinks:

# enable a site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

# disable a site (just remove the symlink, the config file stays safe)
sudo rm /etc/nginx/sites-enabled/myapp

# check what's currently enabled
ls -la /etc/nginx/sites-enabled/

The nginx workflow — every time you change anything

# 1. edit your config
sudo nano /etc/nginx/sites-available/myapp

# 2. test for syntax errors BEFORE reloading
sudo nginx -t

# 3. reload (graceful — no downtime)
sudo systemctl reload nginx

# 4. if something broke, check the logs
sudo tail -50 /var/log/nginx/error.log
sudo tail -50 /var/log/nginx/access.log

Never skip nginx -t. A bad config will crash nginx on reload and take down every site on your server simultaneously.


Useful nginx commands

# test config syntax
sudo nginx -t

# dump the entire compiled config (great for debugging)
sudo nginx -T

# see all server_names currently loaded
sudo nginx -T | grep server_name

# reload gracefully (zero downtime)
sudo systemctl reload nginx

# full restart (brief downtime, use only if reload fails)
sudo systemctl restart nginx

# check nginx status
sudo systemctl status nginx

# watch access log in real time
sudo tail -f /var/log/nginx/access.log

# watch error log in real time
sudo tail -f /var/log/nginx/error.log

Common headers worth knowing

# security headers — add these to every public-facing site
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;

# CORS — needed if your frontend calls a different domain
add_header Access-Control-Allow-Origin "*";

# required for SharedArrayBuffer (used by PDF tools, video editors)
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

# static file caching
add_header Cache-Control "public, immutable";
expires 30d;

Serving static files

For pure static sites (HTML/CSS/JS — like it-tools, CyberChef, Homer):

server {
    listen 443 ssl;
    server_name tools.mrsubodh.xyz;

    ssl_certificate /etc/letsencrypt/live/tools.mrsubodh.xyz/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tools.mrsubodh.xyz/privkey.pem;

    root /var/www/it-tools;
    index index.html;

    # SPA routing — send all 404s to index.html for client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }

    # cache static assets aggressively
    location ~* \.(js|css|png|jpg|ico|woff2|svg)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

The try_files $uri $uri/ /index.html line is critical for single-page apps (React, Vue, Svelte). Without it, refreshing any URL that isn't / returns a 404.


Basic auth — password protect any site

# install apache utils for htpasswd
sudo apt install apache2-utils -y

# create a password file
sudo htpasswd -c /etc/nginx/.htpasswd subodh

Then add to your server block:

location / {
    auth_basic "Protected";
    auth_basic_user_file /etc/nginx/.htpasswd;
    try_files $uri $uri/ /index.html;
}

Rate limiting — stop abuse

# define a rate limit zone in the http {} block (nginx.conf or conf.d/)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api {
        limit_req zone=api burst=20 nodelay;
        proxy_pass http://127.0.0.1:8080;
    }
}

10r/s means 10 requests per second. burst=20 allows a short spike of 20 before throttling kicks in.


The mistake that cost me an evening

sites-enabled was full of symlinks. grep -r "server_name" /etc/nginx/sites-enabled/ returned nothing. Turns out certbot had written SSL config for rustpad.mrsubodh.xyz into the default config file instead of the rustpad-specific one because the rustpad nginx config didn't exist when certbot first ran.

Lesson: always create your nginx config and enable it BEFORE running certbot. Certbot patches the config file that already has the matching server_name if it can't find one, it falls back to default.

The debugging command that saved me:

sudo nginx -T | grep server_name

This dumps every server_name nginx actually loaded not what you think it loaded based on which files exist in sites-enabled, but what actually ended up in the compiled config. When something isn't routing correctly, run this first.


Quick reference — full config for a proxied HTTPS app

# /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name myapp.mrsubodh.xyz;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name myapp.mrsubodh.xyz;

    ssl_certificate /etc/letsencrypt/live/myapp.mrsubodh.xyz/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.mrsubodh.xyz/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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 https;
        proxy_read_timeout 86400;
    }
}

Enable it:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d myapp.mrsubodh.xyz

That's the entire workflow. Every app I run on mrsubodh.xyz.