Why Nginx?
Nginx (pronounced "engine-X") uses an event-driven architecture: a few worker processes each handle many connections, instead of a process or thread per connection as in Apache's classic prefork setup. That keeps memory use low under many concurrent connections, it serves static files efficiently, and the reverse proxy setup is straightforward.
Coming from Apache, the main adjustment is that there is no .htaccess: all configuration lives in the server config and takes effect on reload, which is also why Nginx doesn't have to check directories for override files on every request. The official nginx documentation lists every directive used below, with its default and the contexts it's allowed in.
Don't edit /etc/nginx/nginx.conf directly for site configs. Create a file per site in /etc/nginx/sites-available/, symlink to sites-enabled, reload. Keeps things clean when you're managing multiple domains.
What you need: a Linux server with sudo access and ports 80 and 443 open in its firewall (and in the security group on a cloud VM). For the HTTPS section you also need a domain whose DNS points at the server.
Versions: the commands target Ubuntu 24.04 LTS and Debian 12/13 with the distribution's nginx package; Fedora/RHEL and Arch equivalents are noted. Distribution packages are usually older than nginx.org's current releases. Everything here works on the versions they ship, except add_header_inherit, which needs nginx 1.29.3 or newer. Check yours with nginx -v.
Installing Nginx
Pick your distro:
Ubuntu/Debian
sudo apt update
sudo apt install nginx
Fedora/RHEL
sudo dnf install nginx
Arch Linux
sudo pacman -S nginx
Start and enable on boot (the Debian/Ubuntu package already does this; Fedora and Arch don't):
sudo systemctl enable --now nginx
Verify:
sudo systemctl status nginx
Hit your server's IP in a browser. You should get the default Nginx welcome page. If the page doesn't load, check the host firewall (sudo ufw allow 'Nginx Full' on Ubuntu, or firewall-cmd on Fedora/RHEL) and, on a cloud VM, the security group. Running the backend app itself as a service is covered in Running Your Scripts as Real Services with Systemd.
Config Structure
Nginx config is hierarchical. Outermost to innermost:
- Main context — global settings, worker processes
- Events context — connection handling
- HTTP context — everything web-related
- Server blocks — one per domain/site
- Location blocks — URL path matching within a server
Most day-to-day work happens in server and location blocks. The main config mostly includes other files, so each site gets its own config file and you rarely touch nginx.conf itself. Many directives are inherited from outer to inner levels, but some (like add_header, see Security Headers) are replaced entirely as soon as an inner level sets its own.
Where Config Lives
/etc/nginx/nginx.conf # Main config file
/etc/nginx/sites-available/ # Site configs (not active yet)
/etc/nginx/sites-enabled/ # Symlinks to active sites
/etc/nginx/conf.d/ # Alternative location for configs
The sites-available/sites-enabled pattern comes from Debian's packaging, not from upstream Nginx. Fedora and RHEL packages use /etc/nginx/conf.d/ instead: any file ending in .conf there is loaded. On Arch, check which files your nginx.conf includes. Both layouts work. conf.d/ has one fewer step; sites-enabled lets you disable a site by removing a symlink without deleting its config.
Your First Server Block
Basic static site config:
sudo nano /etc/nginx/sites-available/mysite
Contents:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/mysite;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
}
Line by line:
listen 80/listen [::]:80— HTTP on IPv4 and IPv6server_name— domains this block responds toroot— filesystem path to your site filesindex— default files to servetry_files— check for file, then directory, then 404
Enable it:
sudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/
Create the web root and a test page:
sudo mkdir -p /var/www/mysite
echo "<h1>Hello from Nginx!</h1>" | sudo tee /var/www/mysite/index.html
Test config:
sudo nginx -t
Always run nginx -t before systemctl reload nginx. A reload with a broken config is refused and the old config keeps running, but a restart with a broken config leaves Nginx stopped, and a missing semicolon is all it takes. The test catches syntax errors and names the file and line.
If it passes, reload:
sudo systemctl reload nginx
Requests whose Host header matches no server_name go to the default server for that port (the one marked default_server, or else the first one defined). On Debian/Ubuntu that's the packaged default site, which is why you can still see the welcome page when your server_name doesn't match what you typed.
SSL with Let's Encrypt
Let's Encrypt issues free, automatically renewable certificates, and Certbot can obtain them and edit your Nginx config for you, so there's little reason to run a public site over plain HTTP. Point the domain's DNS at the server and open port 80 and 443 first: the default HTTP challenge needs port 80 reachable.
Install Certbot
Certbot's official instructions (checked October 2026) install it as a snap on most Linux systems, after removing any Certbot packages installed through apt or dnf:
# Official method (snap)
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
# Alternative: distribution packages
# Ubuntu/Debian
sudo apt install certbot python3-certbot-nginx
# Fedora
sudo dnf install certbot python3-certbot-nginx
Use one method, not both, so that certbot always means the same installation.
Get a Certificate
sudo certbot --nginx -d example.com -d www.example.com
Certbot proves you control the domain via an HTTP challenge, gets the certificate, adds the SSL directives to the matching server block, and can add an HTTP-to-HTTPS redirect. If you'd rather edit the config yourself, sudo certbot certonly --nginx only fetches the certificate.
Check Auto-Renewal
sudo certbot renew --dry-run
If the dry run passes, you're set. Both the snap and the distribution packages install a systemd timer or cron job that renews certificates before they expire; systemctl list-timers shows it.
Reverse Proxy
This is where Nginx really earns its keep. App on port 3000, users hit port 80/443, Nginx sits in front and forwards traffic. The AWS EC2 Deployment Guide uses exactly this setup for a Node.js app.
server {
listen 80;
server_name app.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;
proxy_cache_bypass $http_upgrade;
}
}
Use 127.0.0.1 rather than localhost: localhost can resolve to the IPv6 address ::1 as well, and if the app only listens on IPv4 those connections are refused and show up as intermittent 502s.
One gotcha here: watch the trailing slash (or any path) on proxy_pass. Per the proxy_pass documentation, without a URI part (proxy_pass http://127.0.0.1:3000;) Nginx passes the original request URI unchanged. With one (proxy_pass http://127.0.0.1:3000/;), the part of the URI that matched the location is replaced by that URI. Under location /api/, the second form turns /api/users into /users at the backend. If requests reach the backend with the wrong path, this is the first thing to check.
The headers matter:
X-Real-IP— without this your app sees all requests from 127.0.0.1X-Forwarded-For— full proxy chainX-Forwarded-Proto— tells the app if the original request was HTTPS- Upgrade/Connection — needed for WebSocket connections. Sending
Connection: upgradeon every request is a common shortcut; nginx's WebSocket proxying page shows amap $http_upgrade $connection_upgradeblock that only sends it when the client asked for an upgrade.
When the proxy returns 502, check /var/log/nginx/error.log first. It names the upstream address and the reason, for example connect() failed (111: Connection refused) while connecting to upstream, which usually means the app isn't running or listens on a different port than proxy_pass says.
Multiple Apps on One Server
Two ways to do this: subdomains or path-based routing. Subdomains keep each app's config separate and let every app assume it lives at /; path-based routing avoids a DNS record per service but requires the app to cope with its prefix.
By Subdomain
# api.example.com
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# app.example.com
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
By Path
server {
listen 80;
server_name example.com;
location /api/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Here the API backend receives paths that still start with /api/. Nginx picks the longest matching prefix location, so /api/ wins over / for API requests. Using /api/ rather than /api also stops it from matching unrelated paths such as /apidocs.
Static File Serving
Let Nginx handle static assets directly instead of proxying them to your app; serving files from disk is what it's built for, and it frees the backend for dynamic requests.
server {
listen 80;
server_name example.com;
root /var/www/example;
include snippets/security-headers.conf;
# Static files with caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|pdf|txt)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Everything else goes to the app
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Static assets get a 30-day cache lifetime (expires 30d sets Cache-Control: max-age=2592000 and an Expires header). Everything else goes to the app. Only mark files immutable if their names change whenever their content does (for example app.3f9c2b.js); otherwise browsers may keep using a stale copy for the whole 30 days.
This config also has a bug that's easy to miss, explained in the next section: the add_header inside the static-file location means those responses lose every header from security-headers.conf.
Security Headers
A few response headers close off common browser-side attacks (clickjacking, MIME sniffing, leaking full URLs in the Referer header). A reasonable baseline, matching the OWASP HTTP Headers Cheat Sheet recommendations:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
X-XSS-Protection is set to 0 deliberately. Older guides recommend "1; mode=block", but MDN now marks the header as deprecated and non-standard and warns that the browser XSS filters it controlled could themselves introduce vulnerabilities in otherwise safe pages; OWASP recommends either not setting it or turning it off with 0 (see MDN: X-XSS-Protection). The real protection against XSS is a Content-Security-Policy that doesn't allow inline scripts, which has to be written for your specific site. always makes Nginx add the header to error responses too, not just 2xx/3xx ones.
Better yet, put them in a snippet file and include it (so you don't copy-paste the same block into every server config):
# In /etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options "SAMEORIGIN" always;
# ... rest of headers
# In your server block
include snippets/security-headers.conf;
The add_header Inheritance Trap
The add_header documentation says these directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level. It's all or nothing. In the static-file example above, the server block includes the security headers, but the image/CSS/JS location has its own add_header Cache-Control, so for those files Nginx sends only Cache-Control. Every security header silently disappears from those responses, and nginx -t doesn't warn about it.
The fix is to include the snippet again in every location that sets its own add_header:
location ~* \.(jpg|jpeg|png|gif|ico|css|js|pdf|txt)$ {
expires 30d;
add_header Cache-Control "public, immutable";
include snippets/security-headers.conf; # re-add the inherited headers
}
On nginx 1.29.3 and later there's also add_header_inherit merge;, which makes a level append its own headers to the inherited ones instead of replacing them. Check what is actually sent with curl -sI https://example.com/style.css, and compare a static file with a page.
Rate Limiting
Define zones in the http block, apply per-location. Login endpoints and APIs on public servers attract automated password guessing and scraping, so this is worth doing even on small projects. Directives are documented in ngx_http_limit_req_module.
# In the http block (nginx.conf or /etc/nginx/conf.d/rate-limiting.conf)
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_status 429;
# In your server block
location /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api {
limit_req zone=general burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
The general zone allows 10 req/s per client IP, the login zone 1 req/s. burst lets short spikes through without rejecting requests immediately; set it too low and legitimate users get rejected during normal browsing. Rejected requests get 503 by default; limit_req_status 429; returns the more accurate "Too Many Requests". If Nginx sits behind another proxy or a load balancer, $binary_remote_addr is the proxy's address, so every client shares one limit unless you configure the real IP module.
Troubleshooting
Start With These Commands
# Syntax check, with file and line number on errors
sudo nginx -t
# Print the full effective configuration, all includes expanded
sudo nginx -T | less
# Why did the service fail to start?
sudo systemctl status nginx
sudo journalctl -u nginx -b --no-pager | tail -n 50
# Error log (Debian/Ubuntu/Fedora package default; check error_log in nginx -T)
sudo tail -f /var/log/nginx/error.log
nginx -T is the quickest way to answer "which config is Nginx actually using?": it shows every included file in order, so you can search it for a server_name or a stray add_header.
502 Bad Gateway
Your backend isn't responding on the address in proxy_pass. The error log line says why: "Connection refused" means nothing is listening there (app down, wrong port, or the localhost/IPv6 mismatch above); "upstream timed out" (a 504 to the client) means it's listening but too slow. sudo ss -tlnp shows what is listening on which address. On Fedora/RHEL, SELinux blocks Nginx from connecting to backends by default; sudo setsebool -P httpd_can_network_connect 1 allows it.
403 Forbidden / Permission Denied
Nginx's worker processes run as www-data (Debian/Ubuntu) or nginx (Fedora/RHEL). That user needs read access to the files and execute (traverse) access to every directory on the path. It doesn't need to own them or be able to write them:
# Keep your deploy user as owner; give directories 755 and files 644
sudo chown -R youruser:youruser /var/www/mysite
sudo find /var/www/mysite -type d -exec chmod 755 {} +
sudo find /var/www/mysite -type f -exec chmod 644 {} +
# See the permissions of every directory on the path
namei -l /var/www/mysite/index.html
Avoid chmod -R 755 on everything, which makes every file executable, and avoid making the web root writable by the web server unless the app really needs to write there. Understanding Linux Permissions explains the modes. A 403 with correct permissions can also mean a directory has no index file and autoindex is off.
"Address already in use"
bind() to 0.0.0.0:80 failed (98: Address already in use) in the journal means another program (often Apache, or a container publishing port 80) already holds the port. sudo ss -ltnp 'sport = :80' shows which process.
Changes Not Taking Effect
Check, in order: did the reload succeed (sudo systemctl reload nginx, then systemctl status nginx for errors), is there a symlink in sites-enabled, does server_name match exactly what you're typing in the browser, and does nginx -t warn about a conflicting server name (two blocks claiming the same name, where only the first is used)? Then rule out caching: test with curl -I rather than a browser that may have cached a redirect.
Useful Commands Reference
# Test configuration
sudo nginx -t
# Show the full effective configuration
sudo nginx -T
# Reload config (no downtime)
sudo systemctl reload nginx
# Restart nginx (brief downtime)
sudo systemctl restart nginx
# See what nginx is doing
sudo systemctl status nginx
# View error logs
sudo tail -f /var/log/nginx/error.log
# View access logs
sudo tail -f /var/log/nginx/access.log
# See all enabled sites
ls -la /etc/nginx/sites-enabled/
# Disable a site
sudo rm /etc/nginx/sites-enabled/sitename



