Skip to content

Four Ways to Serve a Frontend, Three of Them Wrong

Splitting an Express app into three tiers took one line out of server.js. That line was the only thing serving the frontend, and removing it created a question the original architecture never had to answer: with the API no longer serving the UI, what does? I tried three answers before the right one, and each failure was a different lesson about what decoupling actually costs.

The Line That Held It Together

The original two-tier app had this in server/server.js:

app.use(express.static(path.join(__dirname, '../client/public')));

One Express process on port 5000 served both the API and the compiled React bundle. node server/server.js, open http://localhost:5000, done. Frontend and backend were the same deployment unit because they were the same process.

The three-tier refactor trimmed server.js to an API entry point and that line went with it. Afterwards:

  • node server/server.js starts the API on port 5000
  • http://localhost:5000 serves no UI, correctly
  • client/public/ contains a valid Webpack build that nothing is serving

The build was fine. The serving infrastructure did not exist yet.

Attempt 1: Put the Line Back

The fastest fix is to re-add express.static() pointing at the new build output. It works immediately.

It also undoes the refactor. A three-tier architecture separates presentation from application logic so the two can be built, scaled, and deployed independently. Serving static files from the API process re-merges them into one deployable, which is the two-tier model with extra directories.

The cost is not theoretical. Independent containerisation, putting a CDN in front of the frontend, and scaling the presentation tier separately from the API all become impossible the moment one process owns both. Rejecting this was the only decision in the whole sequence made on architecture rather than on an error message.

Attempt 2: webpack-dev-server

The next suggestion was the standard React development answer:

"scripts": {
  "start": "webpack serve --mode development --open"
}

This failed twice over, and only one of the reasons was interesting.

The dull reason: the package was never added to client/package.json. It had been installed by hand, which means a fresh clone plus npm install does not reproduce the environment. A dependency that exists on one machine and in no manifest is a bug regardless of whether the command works.

The interesting reason: even installed and configured, webpack-dev-server is a development tool. It solves hot module replacement and fast rebuilds. It does not solve "what serves this in a container", which is the actual question. Reaching for it here was answering a different question because it was the familiar one.

Attempt 3: python3 -m http.server

With the build output confirmed valid, the quickest way to see it in a browser:

cd client/public
python3 -m http.server 8080

The page loaded. HTML rendered, CSS applied, layout correct. Then every button failed silently, and the browser console filled with failed fetches and CORS errors.

The frontend bundle calls /api/users as a relative path. Served from port 8080, that resolves to http://localhost:8080/api/users. The API is on port 5000. Python's HTTP server serves files and has no proxy capability whatsoever, so there is nothing to forward the request.

This attempt was worth making, because it isolated the problem precisely. The built files are correct. The bundle is correct. The failure is entirely in the serving layer, and the requirement is now explicit: something that serves static files and forwards /api/ to another process on another port.

That is the definition of a reverse proxy.

The Answer: Nginx in Front of Both

Browser
Nginx :80
   ├── GET /            → client/public/index.html
   ├── GET /bundle.js   → client/public/bundle.js
   └── /api/*           → proxy_pass → Express :5000
                          MySQL :3306

One origin, two backends, routing by path:

server {
    listen 80;

    location / {
        root /home/ibtisam/node-monolith-3tier-app/client/public;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://localhost:5000/api/;
        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;
    }
}

try_files $uri $uri/ /index.html is the single-page-app fallback: any path that is not a real file returns index.html so client-side routing can handle it. Without it, a refresh on any route other than / returns 404.

Because the frontend and API now share an origin, the CORS problem from attempt 3 disappears rather than getting configured away. /api/users is same-origin from the browser's perspective; the cross-origin hop happens server-side where the same-origin policy does not apply.

Two Failures That Had Nothing To Do With the Config

The config above was correct on the first write. It still did not work, twice, for reasons in the layer underneath.

Nginx's default site shadows everything. After copying the config into conf.d/ and restarting, the browser still showed "Welcome to nginx!". Ubuntu and Debian ship an enabled default site symlinked at /etc/nginx/sites-enabled/default, listening on port 80. It wins, and nothing reports a conflict:

sudo rm -f /etc/nginx/sites-enabled/default
sudo systemctl restart nginx

nginx -t passes in both states, because both configurations are individually valid. There is no syntax error to find.

Permissions are a separate layer from configuration. With the default site gone, Nginx served the right location and returned 403. Nginx workers run as www-data, which needs two distinct things:

sudo chmod o+x ~                                    # traverse into the home directory
sudo chmod -R o+r ~/node-monolith-3tier-app/client/public   # read the files

Execute on every parent directory to walk the path, read on the target files. Granting one without the other produces the same 403, and the config is not what is wrong in either case.

The pattern in both

A correct config plus a wrong environment fails identically to a wrong config. nginx -t validates syntax, not whether the process can reach what the syntax points at. When a config is right and the result is wrong, stop reading the config.

The Same Config, Twice, For Two Environments

Containerising this produced a second config rather than a parameterised one. The repository carries nginx/default.conf for bare metal and nginx/docker.conf for Compose and the built image. One directive differs:

# bare metal: Express on the same host
proxy_pass http://localhost:5000/api/;

# docker: Express in a sibling container
proxy_pass http://server:5000/api/;

localhost inside the Nginx container is the Nginx container. It is not the host, and it is not the API. The name server resolves through Compose's internal DNS to the sibling service.

The static root moves too, from a host filesystem path to /usr/share/nginx/html, where Dockerfile.client copies the build output from its Webpack stage. Nothing about the runtime image contains Node: a node:22-alpine stage compiles the bundle, and the final stage is nginx:alpine with static files and a config.

Two files rather than one templated file is a deliberate choice. Each is readable on its own, each carries a header explaining the one line that differs, and neither needs an environment variable substituted at container start to be understood.

One asymmetry worth naming, because it looks like an oversight and is not. The API image ends on USER appuser, hardened to non-root like the Java and Python images in the same project. The client image does not, because the Nginx master process needs root to bind port 80 and then drops its worker processes to the unprivileged nginx user itself. Adding USER here would break the bind rather than harden anything. The correct hardening for this image is a port above 1024 plus an unprivileged base, which is a change to make when something actually requires it rather than for symmetry with its sibling.

What This Cost, and What It Bought

The honest summary is that decoupling two tiers created a third thing to run, configure, and containerise. That is the actual price, and it is not visible when you read "separate presentation from application logic" as a principle.

What it bought is everything the three-tier split was for. The frontend ships as an nginx:alpine image with no Node runtime in it. The API scales without touching the frontend. The proxy is where TLS, rate limiting, and caching go when they are needed, without any of it entering application code.

Decoupling always requires an intermediary. When two components stop talking through a shared process, something has to sit in front and route between them. In a three-tier web application that something is a reverse proxy, and there is no configuration of the two remaining tiers that avoids needing it.

Source


Series: CI Pipeline Engineering Across Three Applications (Part 7 of 8)