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:
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.jsstarts the API on port 5000http://localhost:5000serves no UI, correctlyclient/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:
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:
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:
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¶
- Node three-tier repository
nginx/default.confandnginx/docker.confDockerfile.client, the Webpack build stage andnginx:alpineruntimedocs/migration/06-frontend-serving-journey.md, the working notes this post is drawn from
Series: CI Pipeline Engineering Across Three Applications (Part 7 of 8)
- Previous: One Pipeline Contract, Three Language Ecosystems
- Next: Release Identity