Nginx 101: how it actually works (from a newbie's perspective)
Table of Contents
- What is nginx anyway?
- The file structure
- The server block — the heart of nginx
- Location blocks routing within a site
- Reverse proxying — the most important thing nginx does
- SSL with Let's Encrypt
- Enabling and disabling sites
- The nginx workflow — every time you change anything
- Useful nginx commands
- Common headers worth knowing
- Serving static files
- Basic auth — password protect any site
- Rate limiting — stop abuse
- The mistake that cost me an evening
- Quick reference — full config for a proxied HTTPS app
A ground-up explanation of nginx config files, server blocks, reverse proxying, SSL, and everything in between. Written after spending way too long debugging sites-enabled. I spent an entire evening wondering why 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: 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. On a Debian/Ubuntu system nginx lays out its files like this: The key insight: nginx only loads what's in You rarely touch Every site you host is defined in a When a request comes in, nginx: Match priority (highest to lowest): Instead of serving files directly, nginx can forward requests to another process: Without those Regular HTTP proxying doesn't work for WebSockets. You need two extra headers: Forgetting Raw HTTP is fine for internal tools but anything public-facing needs HTTPS. Certbot handles this automatically: Certbot modifies your nginx config and adds the SSL blocks. The result looks like: Certificates auto-renew via a systemd timer. Check the timer: The Never skip For pure static sites (HTML/CSS/JS — like it-tools, CyberChef, Homer): The Then add to your server block: Lesson: always create your nginx config and enable it BEFORE running certbot. Certbot patches the config file that already has the matching The debugging command that saved me: This dumps every server_name nginx actually loaded not what you think it loaded based on which files exist in Enable it: That's the entire workflow. Every app I run on 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?
Internet → nginx (port 443, SSL) → your app (port 3030, no SSL) The file structure
/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 fragmentssites-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
}nginx.conf directly. All your site-specific config goes in sites-available/. The server block — the heart of nginx
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
}
}Host header against all server_name valuesserver {} blocklocation {} 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;
}
}= exact match^~ prefix match (stops searching if matched)~ and ~* regex (case-sensitive and case-insensitive)/ catch-all Reverse proxying — the most important thing nginx does
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;
}
}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
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
}Upgrade and Connection is why collaborative apps like Rustpad connect but don't sync in real time. SSL with Let's Encrypt
# install certbot
sudo apt install certbot python3-certbot-nginx -y
# get a certificate and auto-configure nginx
sudo certbot --nginx -d yourdomain.comserver {
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;
}sudo systemctl status certbot.timer Enabling and disabling sites
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.lognginx -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
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";
}
}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 subodhlocation / {
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.server_name if it can't find one, it falls back to default.sudo nginx -T | grep server_namesites-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;
}
}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.xyzmrsubodh.xyz.