Your application listens on 127.0.0.1:3000. Visitors type a name and expect port 443 with a padlock. Nginx is the twelve lines of configuration that connect those two facts, and once you have written them once you will write them for every service you ever deploy. This guide is the working version of those lines: a server block that proxies correctly, the four headers your application needs, a certificate that renews itself, and the specific configuration that websockets and file uploads require and do not get by default.
Everything here assumes one machine with root - a VDS or a dedicated box - running nginx as a system package with your application beside it as a systemd service or a container. If you want the conceptual version of what a reverse proxy is doing and why, what a reverse proxy does is that post. This one is the configuration.
Where nginx keeps its configuration#
On Debian and Ubuntu, /etc/nginx/nginx.conf holds global settings and ends with two include lines. One pulls in /etc/nginx/conf.d/*.conf, the other pulls in /etc/nginx/sites-enabled/*. The convention is to write a file per site in sites-available and symlink the ones you want live into sites-enabled, so disabling a site is removing a link rather than deleting a file.
$ sudo apt install -y nginx$ sudo nano /etc/nginx/sites-available/app.conf$ sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/$ sudo rm /etc/nginx/sites-enabled/default$ sudo nginx -t && sudo systemctl reload nginxOn RHEL, Rocky and Alma there is no sites-available split; everything goes in /etc/nginx/conf.d/ with a .conf extension. The server blocks themselves are identical.
Three commands do all the work. nginx -t parses the whole configuration and reports the file and line number of any error, and you should never reload without it. systemctl reload nginx re-reads the configuration without dropping connections - old worker processes finish their current requests and exit, new ones start with the new configuration. nginx -T prints the entire merged configuration including every include, which is how you find the stray file that is overriding you.
The default site is worth deleting or replacing. It answers on port 80 for any hostname that does not match another block, which means a request for a domain you have never heard of gets your default page. A catch-all that closes the connection without answering is tidier:
server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444;}444 is an nginx-specific code meaning "close without a response". Only one block per port may carry default_server, and a second one produces duplicate default server at config test time.
The smallest server block that works#
server { listen 80; listen [::]:80; server_name app.example.com; location / { 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 X-Forwarded-Host $host; }}That is a complete, working reverse proxy. Certbot will add the TLS half in a moment. A few things in it are not obvious:
proxy_http_version 1.1 should be there from the start. Nginx talks HTTP/1.0 upstream by default, which breaks keepalive to the backend and makes websocket upgrades impossible. There is no downside to setting it.
server_name is how nginx picks a block, matching against the Host header. Several names on one block are space-separated: server_name example.com www.example.com;. A request whose Host matches nothing lands in the default server, which is why the catch-all above matters.
$proxy_add_x_forwarded_for appends the client address to any existing X-Forwarded-For header rather than replacing it, which is correct when there is another proxy in front of you and is a hole when there is not - a client can send its own header and you will pass it along. If nothing is in front of nginx, use proxy_set_header X-Forwarded-For $remote_addr; and remove the ambiguity.
The trailing slash in proxy_pass#
This is the single most common nginx mistake, and it is silent until a route 404s.
# Passes /api/users through unchanged: backend sees /api/userslocation /api/ { proxy_pass http://127.0.0.1:8000;}# Strips the location prefix: backend sees /userslocation /api/ { proxy_pass http://127.0.0.1:8000/;}The rule: if proxy_pass contains a URI part - anything after the host and port, including a bare / - nginx replaces the matched location prefix with it. If it contains no URI part, the request URI is passed through untouched. One character decides whether your API sees /api/users or /users.
Whichever you choose, the application has to agree. An application that builds links assuming it lives at the root will emit broken URLs when served under /api/, which is why hosting applications on subdomains is usually less work than hosting them on paths. And note that this rewriting only applies to prefix locations - combining a URI in proxy_pass with a regex location is a configuration error nginx rejects at test time.
TLS with certbot, and what renewal actually needs#
$ sudo apt install -y certbot python3-certbot-nginx$ sudo certbot --nginx -d app.example.com$ sudo certbot renew --dry-runThe nginx plugin reads your server block, obtains a certificate, edits the block to add a TLS listener and a redirect from port 80, and installs a renewal job. The result looks like this:
server { server_name app.example.com; listen 443 ssl; listen [::]:443 ssl; http2 on; ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location / { proxy_pass http://127.0.0.1:3000; # ... the header lines from above }}server { listen 80; server_name app.example.com; return 301 https://$host$request_uri;}http2 on; as a separate directive is the syntax from nginx 1.25.1 onwards. On older builds it is a parameter on the listen line - listen 443 ssl http2; - and mixing the two styles produces a deprecation warning rather than a failure.
Three conditions have to hold before a certificate can be issued or renewed, and every certbot failure is one of them:
- The DNS name resolves to this machine. Issuance proves you control the name by answering a challenge at it. Point the A record first, wait for it to propagate, then run certbot. DNS records explained covers the record types and the TTL wait.
- Port 80 is open and reaches nginx. The HTTP-01 challenge fetches a file under
/.well-known/acme-challenge/, over plain HTTP, even for a site that redirects everything to HTTPS. Blocking port 80 in UFW is the most common self-inflicted renewal failure. Certbot's redirect block handles the challenge path correctly; if you write the redirect by hand, exempt that location. - Nginx reloads after renewal. The certbot packages install a deploy hook that does this. If you renew by some other route, add
--deploy-hook "systemctl reload nginx", because nginx holds the old certificate in memory until it is told otherwise.
Renewal runs twice a day from a systemd timer and only acts when a certificate is within thirty days of expiry, so a transient failure has weeks of margin. Check it is armed with systemctl list-timers | grep certbot. The mechanics of what the challenge proves are in HTTPS and Let's Encrypt explained.
WebSockets, streaming and timeouts#
A websocket starts as an ordinary HTTP request carrying Connection: Upgrade and Upgrade: websocket. Nginx does not forward hop-by-hop headers by default, so the upgrade dies in transit unless you say otherwise. The standard pattern uses a map at the http level so that non-websocket requests are not affected:
map $http_upgrade $connection_upgrade { default upgrade; '' close;}location /socket.io/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s;}proxy_read_timeout defaults to 60 seconds, and it is measured between reads, not for the whole connection. An idle websocket is therefore cut every minute, producing a reconnect loop that looks exactly like a network fault. Raise the timeout on websocket routes and send an application-level ping every twenty to thirty seconds so the connection is never idle long enough to qualify.
Server-sent events and any streaming response need one more line. Nginx buffers upstream responses by default, so a stream is held until enough of it has arrived and the client sees a long silence followed by everything at once:
location /events { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 24h;}The application can also switch buffering off per-response by sending X-Accel-Buffering: no, which is tidier when only some endpoints stream. WebSockets behind a reverse proxy covers the sticky-session problem that appears once there is more than one backend instance.
Several apps behind one nginx#
The point of a proxy is that one address and one port serve any number of services. Two ways to split them, and subdomains are almost always the better one:
# By hostname: two server blocks, two certificates, no path rewritingserver { server_name app.example.com; location / { proxy_pass http://127.0.0.1:3000; } }server { server_name api.example.com; location / { proxy_pass http://127.0.0.1:8000; } }# By path: one certificate, but the backend must know it lives under /api/location /api/ { proxy_pass http://127.0.0.1:8000/; }location / { proxy_pass http://127.0.0.1:3000; }Longest-prefix wins among prefix locations, so /api/ beats / for a request to /api/users regardless of the order they appear in the file. Regex locations, written location ~ ^/pattern, are evaluated in file order and take precedence over prefix matches, which is a good reason to use as few of them as you can.
For a backend you will keep-alive to, declare it as an upstream. The two extra lines are required: keepalive to an upstream needs HTTP/1.1 and an empty Connection header, or nginx sends Connection: close on every request and the pool never gets used.
upstream app { server 127.0.0.1:3000; keepalive 32;}server { location / { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Connection ""; }}An upstream with several server lines gives you round-robin load balancing, with ip_hash or least_conn as alternatives and backup for a standby. If the application is in a container, the proxy can reach it by container name instead of a port - see Docker Compose for small stacks for that shape, where nothing but the proxy publishes a port at all.
A Unix socket is a valid upstream too, and it is marginally faster than loopback TCP as well as impossible to reach from the network: proxy_pass http://unix:/run/app/app.sock;. Make sure the nginx user can read the socket.
Static files, uploads and limits#
Nginx serves files faster than any application runtime, so let it. Serving the static directory directly also stops those requests from waking your application at all:
location /static/ { alias /srv/app/static/; access_log off; expires 30d; add_header Cache-Control "public, immutable";}client_max_body_size 25m;gzip on;gzip_types text/plain text/css application/json application/javascript image/svg+xml;server_tokens off;alias replaces the matched location prefix with the given path; root appends the whole URI to it. Both are correct in different situations and mixing them up produces a 404 with a very clear line in error.log showing the path nginx tried to open. Read that line rather than guessing.
client_max_body_size defaults to 1 MB, which is smaller than most upload forms expect, and exceeding it produces a 413 before the request ever reaches your application. Set it to the largest upload you intend to accept and no larger. expires and Cache-Control on hashed asset filenames are free performance; on files whose names do not change, they are a way to serve stale content for a month, so be sure which you have - HTTP caching headers explained draws that line properly.
One caution about add_header: it is not cumulative across levels. A header added in a location block replaces, rather than adds to, headers added in the parent server block. If you set security headers globally and then add a caching header in one location, the security headers vanish from that location. Repeat them, or use the always parameter carefully and test with curl -I.
Rate limiting is two directives and worth having on a login endpoint:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;location /login { limit_req zone=login burst=10 nodelay; proxy_pass http://127.0.0.1:3000;}Note that this limits by $binary_remote_addr, the address nginx sees. If something else proxies to nginx, that is one address for everybody. Either use the real IP module - set_real_ip_from with the upstream proxy's range plus real_ip_header X-Forwarded-For - or limit on a variable derived from the forwarded header. Rate limits and abuse covers choosing the numbers.
502, 504, 413 and the rest#
Every answer here starts in /var/log/nginx/error.log. Nginx writes a specific reason for nearly every failure, including the exact upstream it tried and the errno it got back.
502 Bad Gateway. Nginx could not get a valid response from the backend. The error log will say connect() failed (111: Connection refused) - the application is not running or is on a different port - or no live upstreams, or a 104: Connection reset by peer from a backend that crashed mid-request. Check the process with sudo ss -lntp, then its own logs. If the application runs in a container and nginx on the host, the application must listen on an address the host can reach, not on the container's loopback.
504 Gateway Timeout. The backend accepted the connection and did not answer within proxy_read_timeout. This is a slow request, not a proxy fault. Find the slow endpoint before raising the timeout, because raising it usually just moves the failure to the browser.
413 Request Entity Too Large. client_max_body_size. See above.
404 on routes that exist. The proxy_pass trailing slash, or alias versus root. The error log prints the path that was attempted.
A redirect loop. The application forces HTTPS by checking the scheme of its own connection, which is plain HTTP because nginx terminated TLS. Send X-Forwarded-Proto and turn on the framework's trusted-proxy setting; do not remove the redirect.
Every visitor has the same IP in the application log. The application is not reading X-Forwarded-For. Each framework has a switch for that, off by default for good reasons.
`nginx -t` passes but the change did nothing. Another server block matched first, usually the default server, or you edited sites-available and never made the symlink. nginx -T | grep server_name shows every block nginx actually loaded.
Permission denied connecting upstream on RHEL or Rocky. SELinux. sudo setsebool -P httpd_can_network_connect 1 is the one-line fix, and sudo ausearch -m avc -ts recent is how you confirm that was the cause.
Run nginx and the application as separate services so each can be restarted alone; systemd services for your apps has the unit file, including the Restart= and After=network.target lines that decide what comes back after a reboot.
If none of this is work you want to do, it is worth knowing it is not compulsory: RE:NODE's app and web plans include a proxy slot that does the same job. You point an A record at the address the panel shows, the certificate is issued and renewed automatically inside a 21-day window, and the real client address arrives in X-Forwarded-For. The reason to run nginx yourself is everything a managed proxy deliberately does not do - custom locations, rate limit zones, several applications on paths, a Unix socket upstream - and that needs a machine with root.
FAQ#
Do I need nginx if my app can serve HTTPS itself?
Usually yes, and not for speed. The proxy keeps the certificate out of your application, lets you run several services on one address and port, gives you one place for rate limits and upload limits, and lets you restart the application without dropping the listener. For a single internal service on a private network, you can skip it.
Nginx or Caddy?
Caddy gets certificates automatically with no plugin and has a much shorter configuration file, which makes it the easier choice for a simple site. Nginx has more knobs, far more documentation and examples, and is what most guides and most hosting stacks assume. Either is a fine answer; nginx is the one you will meet more often.
Why does my app see nginx's IP instead of the visitor's?
Because nginx made the connection to it. Send X-Forwarded-For and X-Forwarded-Proto, then enable the trusted-proxy setting in your framework so it reads them. Trusting those headers unconditionally is unsafe, which is why it is never on by default.
Can nginx proxy a game server?
Not as an HTTP proxy. Games use their own protocols, mostly over UDP, and proxy_pass speaks HTTP. Nginx does have a stream module for plain TCP and UDP forwarding, which can pass game traffic through, but it is a different block and it gives you none of the hostname routing that makes HTTP proxying useful.
How do I test the configuration without breaking the live site?
sudo nginx -t parses everything and refuses to reload on a syntax error, so a broken file cannot take the site down through reload. For behaviour rather than syntax, add the new server block on a spare hostname, test it with curl -H 'Host: new.example.com' http://127.0.0.1/, then move the real name across.
Should I reload or restart?
Reload. It keeps the listening sockets open and lets existing connections finish on the old workers, so nothing is dropped. Restart only when you change something nginx reads at startup, such as the user it runs as or the set of listening ports in some setups.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.