When you want to take a Node.js application live or move a static HTML site to a server, sooner or later you have to write an Nginx server block. A correctly written server block both binds the request to the right domain and makes sure the application behind it has the right information about the client (real IP, protocol, host name). A server block written with pieces missing usually fails silently: access logs show everyone coming from the same IP, HTTPS redirects break, or the request never reaches where it was supposed to.
What Is a Server Block, and Which Block Does Nginx Pick?
When you want to host more than one domain or application on a single server, Nginx needs to know which site to route an incoming request to. The server block structure handles that job: a configuration unit defined with the server { ... } directive that answers requests arriving at a particular server_name and listen combination. For Apache users, this is the equivalent of the "virtual host" concept.
On Debian/Ubuntu-based systems each server block is usually written as a separate file under /etc/nginx/sites-available/. But a file being there does not enable it automatically — only blocks added as a symbolic link (symlink) inside /etc/nginx/sites-enabled/ actually run. This two-directory structure makes it possible to disable a site by removing only the symlink, without deleting the file.
When Nginx receives a request it does the matching in two stages. First it determines the server blocks that answer the IP and port the request arrived on (the listen directive); several blocks may be defined on the same port. Then, among those blocks, it tries to match the domain in the request's Host header against server_name. If no server_name matches, Nginx uses the first server block defined for that port (or the block explicitly marked as default_server). This behaviour is the most frequent source of the "an unexpected site opens in the browser" problem when several sites are defined.
Serving a Static Site: root, index, try_files
If a server block is going to serve files straight from disk, there is no need for an application server running behind it; Nginx answers the request itself. The root directive states the directory the files live in, and index defines which file is served by default (such as index.html) when the URL points to a directory.
The line try_files $uri $uri/ =404; is the lifeblood of static serving: Nginx first tries the request as an exact file ($uri), then as a directory if it cannot find one ($uri/, which falls back to the index file), and returns 404 if that fails too. Thanks to this order, Nginx returns an error directly for a file that does not exist; it never reaches an interpreter or an application.
Reverse Proxy: proxy_pass and Why the Headers Matter
If the site is fed not by static files but by an application running in the background (Node.js/Express, Django, Rails and so on), Nginx's role changes: it does not answer the request itself, it passes it to the application behind and carries the response back to the client. This model is called a reverse proxy and is set up with the proxy_pass directive — for example a Node.js application listening on port 127.0.0.1:3000 is connected with proxy_pass http://127.0.0.1:3000;. This separation lets you take work such as TLS termination, compression, static file caching and load balancing completely out of the application code; the application only deals with business logic.
proxy_pass alone is not enough; if the headers below are not forwarded, the application behind ends up with wrong information about the real source of the request:
proxy_set_header Host $host;— lets the backend application see which domain the client sent the request to.proxy_set_header X-Real-IP $remote_addr;— carries the client's real IP address; without it the application sees every request as coming from Nginx's own address.proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;— keeps the list of IPs the request passed through if it went through a chain of proxies.proxy_set_header X-Forwarded-Proto $scheme;— tells the backend whether HTTP or HTTPS is spoken between the client and Nginx.
The last header is especially critical. Even when Nginx speaks HTTPS with the client, it usually passes the request to the backend application as plain HTTP. Without the X-Forwarded-Proto header the application may think the request arrived unencrypted; that leads to secure cookies not being set, redirects being generated with http:// instead of https://, or "mixed content" errors. Most frameworks such as Express, Django and Rails read this header and determine the request's real protocol from it — but a setting like "trust proxy" usually has to be enabled on the application side as well, otherwise the framework will not trust the header even though it sees it.
In applications that use WebSocket (live notifications, chat, hot reload and so on), the Upgrade and Connection headers also have to be forwarded along with proxy_http_version 1.1;; otherwise the connection is downgraded to an ordinary HTTP request and the WebSocket handshake fails.
SSL/TLS Termination
On a site using HTTPS, Nginx usually takes on the certificate work; this is called TLS termination (SSL termination). The client connects to Nginx encrypted, Nginx decrypts the traffic and passes it on to the backend — serving the files directly in static mode, and mostly as plain HTTP in proxy mode. The certificate and private key are defined with the ssl_certificate and ssl_certificate_key directives; if Let's Encrypt is used, these files are typically found at the paths /etc/letsencrypt/live/<domain>/fullchain.pem and privkey.pem.
In practice a server block that only listens for SSL on 443 is not enough; adding a second block that also listens on port 80 and redirects all HTTP traffic to HTTPS with return 301 https://$host$request_uri; keeps the site from remaining open to unencrypted access. In the reverse proxy example above these two blocks are shown together: the first does the redirect, the second terminates the certificate and carries the request to the application.
Common Mistakes
| Mistake | Result and fix |
|---|---|
| A missing/extra trailing slash on <code>proxy_pass</code> | <code>proxy_pass http://backend/;</code> strips the prefix matched by the location from the request URI and forwards the rest; <code>proxy_pass http://backend;</code> (without the slash) forwards the original URI as it is. Confusing the two makes the path reaching the backend change unexpectedly. |
| The proxy headers are never set | The application sees every request as coming from Nginx's own address (usually 127.0.0.1); the access logs constantly show the same local IP rather than real client IPs. This is not fixed until <code>X-Real-IP</code> and <code>X-Forwarded-For</code> are added. |
| More than one block for the same listen + server_name | If two server blocks define the same <code>listen</code> and <code>server_name</code> combination, Nginx prints a "conflicting server name" warning on startup and uses only the block loaded first; the other is silently disabled. |
| Forgetting the reload after changing the configuration | Saving the file is not enough on its own; the change only takes effect once Nginx is reloaded. The syntax should first be tested with <code>sudo nginx -t</code>, and if there is no error applied with <code>sudo systemctl reload nginx</code> (without dropping active connections). |
After creating the configuration, save the file as /etc/nginx/sites-available/<domain> and then enable it with the command sudo ln -s /etc/nginx/sites-available/<domain> /etc/nginx/sites-enabled/. Making it a habit to test the syntax before going live and then to use reload rather than restart prevents most production outages.
When writing a new server block, using a tool that generates the right order and the required proxy headers automatically, instead of building the directives line by line by hand, prevents most of the mistakes above from the outset.