How to Setup Nginx with Node.js to Serve Static Files

The `/api/health` route can return Node’s JSON while `/assets/site.css` still gets a 404. I was surprised that a stylesheet failure can start with how NGINX reads the URL, not with the app’s response.

That turns a browser symptom into a specific path to reason about. The URI NGINX receives is the first clue in that translation.

How Nginx splits static and application requests

Nginx can serve a file from disk and proxy application requests to Node.js in the same server block. The root directive appends the request URI to its directory, so /assets/site.css maps to /srv/myapp/public/assets/site.css when the root is /srv/myapp/public.

That makes the location rule important. This example sends asset paths to Nginx, returns 404 when an asset is missing, and forwards other paths to the Node app.

For path mapping, NGINX documents how root appends the full URI and how alias replaces the matching location prefix.

NGINX routes existing assets to disk, missing assets to 404, and other paths to Node.js
A request flow for this configuration, with NGINX serving existing assets and proxying application paths.

Check the paths and Node listener first

Use one host for Nginx and Node.js in this example. Keep static files under the public directory and bind the Node listener to the loopback address.

SettingExampleWhy it matters
Document root/srv/myapp/publicNginx joins this directory to a requested file path.
Static asset/srv/myapp/public/assets/site.cssThis file is available at /assets/site.css.
Node listener127.0.0.1:3187Nginx can connect locally without exposing the app port publicly.

The app port in the Node code and the Nginx upstream must match. The sample uses 3187, but you can choose another unused loopback port.

Configure Nginx to serve files and proxy app routes

Start Node on loopback, then give Nginx separate rules for asset files and application paths. Keeping those rules distinct means a missing stylesheet does not become an HTML response from the app.

1. Run the Node.js app on loopback

This small HTTP server returns JSON for one health route and a 404 for other application paths. Its loopback binding keeps port 3187 available to Nginx on the same host.

const http = require("node:http");

const app = http.createServer((request, response) => {
  if (request.url === "/api/health") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ status: "ok" }));
    return;
  }

  response.writeHead(404, { "content-type": "text/plain" });
  response.end("Not found");
});

const port = Number(process.env.PORT || 3187);
app.listen(port, "127.0.0.1", () => {
  console.log(`Node app listening on 127.0.0.1:${port}`);
});

The early return keeps the health response from falling into the app’s not-found path. Start this file with node app.js, then keep that process running while you test Nginx.

2. Add the Nginx server block

Add these upstream and server blocks inside the active http context. Keep your distribution’s existing mime.types include enabled so browsers receive CSS with the text/css content type.

upstream node_app {
    server 127.0.0.1:3187;
}

server {
    listen 8080;
    server_name example.com;
    root /srv/myapp/public;

    location ^~ /assets/ {
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ @node_app;
    }

    location @node_app {
        proxy_pass http://node_app;
        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;
    }
}

The asset rule maps /assets/site.css to the file under the configured root and stops a miss with 404. The general location serves existing files first, then sends other paths to the named Node location.

The proxy headers preserve the host and forwarded client details for the app. If you use Express, the Express reverse-proxy example covers the application-side settings.

3. Validate the active configuration

Test the complete loaded configuration before asking Nginx to reload. A passing test confirms the syntax and referenced files can be read, but the request checks below confirm that the paths do what you expect.

sudo nginx -t

After a successful test, reload Nginx through the service manager that started it. A reload applies the new server block while leaving the Node process running.

Test the static and application routes

I ran the stylesheet and health requests through NGINX 1.30.5. The first returned CSS as text/css and the second returned the Node JSON response.

curl -i --silent --show-error http://127.0.0.1:8080/assets/site.css && curl -i --silent --show-error http://127.0.0.1:8080/api/health

The first request stops at the asset rule, while the second reaches the named Node upstream.

Terminal output from NGINX 1.30.5 showing CSS text/css and a Node health response, both with HTTP 200
NGINX 1.30.5 serves the stylesheet as CSS and proxies the health response to Node.js.
RequestExpected resultHandled by
/assets/site.css200 with CSS contentNginx reads the file from disk
/api/health200 with a JSON bodyNode.js returns the response through Nginx
/assets/missing.css404 Not FoundNginx stops at the asset location

Diagnose 404 and 502 responses

A 404 and a 502 point to different checks, so trace the request path before changing a location.

  • If an existing asset returns 404, compare its URL with the Nginx root and check that the Nginx worker can read the file.
  • If a missing file under /assets/ returns 404, that is the configured result. Nginx does not send that request to Node.
  • If an application route returns 502, check that Node is running on the same address and port as the upstream. Test Node directly with curl -i –silent –show-error http://127.0.0.1:3187/api/health.

I kept /assets/ out of Node’s fallback so a missing stylesheet gets NGINX’s 404 response. A stopped Node listener returned 502 for the health route.

Terminal output from NGINX 1.30.5 showing HTTP 404 for a missing asset
NGINX 1.30.5 returns 404 for a missing file under /assets/.
Terminal output from NGINX 1.30.5 showing HTTP 502 when the Node.js listener is unavailable
When the Node.js listener is stopped, NGINX returns 502 for the proxied health route.

If Node answers directly but Nginx returns 502, compare the upstream address with the listener. When both requests return 404, confirm whether the path belongs to the asset rule or the Node app.

Keep the Node port private

Keep NGINX public and bind Node to loopback because a proxy does not hide a backend port that is reachable from the network.

I started the sample on loopback and checked the socket with ss. It listed 127.0.0.1:3187, so only local connections can reach that listener.

ss -ltnp 'sport = :3187'
ss output showing the Node app listening only on 127.0.0.1:3187
The Node process listens on loopback, not on every network interface.

For the rest of a production Node setup, see CodeForGeek’s Node.js production checklist.

Frequently asked questions

The path rule depends on how your public URL should map to the filesystem.

When should you use alias instead of root?

Use alias when the URL prefix should be replaced by a different directory path. Use root when the filesystem path should include the full request URI.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337