TL;DR — Put a reverse proxy (nginx) in front of your app: the app listens only on a private
localhostport, and nginx owns the public ports, terminates HTTPS, and routes each request by its hostname. This buys you one public entry point for many services, free auto-renewing TLS via Let's Encrypt/ACME, and clean domain rules (301-redirectwww→apex, or a whole alternate domain onto your canonical one). It works beautifully on a single box — but a single VM is a single point of failure, so the moment you need to survive that machine dying, or serve more traffic than it can hold, you move the front door up to a managed load balancer or platform.
1. Simple explanation
Your app — a Node server, a Python model API, anything — listens on a private port like 127.0.0.1:3000. You do not expose that port to the internet. Instead, nginx listens on the public ports (80 for HTTP, 443 for HTTPS), holds the TLS certificate, and forwards each incoming request to your app over the local network. The app answers in plain HTTP; nginx handles the encryption and the public-facing details.
That indirection is the whole trick. One public front door, many private rooms behind it. You can add a second service, move one, or swap the certificate without the app ever knowing.
Analogy — a building's front desk. Visitors never wander into offices. They arrive at the single public entrance and talk to the receptionist (nginx). The receptionist checks credentials at the door (TLS/HTTPS terminates here), reads the slip that says who each visitor asked for (the HTTP Host header), and walks them to the right office (localhost:PORT) — or says "that department moved; here's the new address" (a 301 redirect). The offices have no public doors of their own, so you can rename, add, or relocate departments without ever touching the entrance. The mechanism the analogy carries: routing decisions happen at one place, by name, before any office is involved.
2. Diagram
PUBLIC PRIVATE (localhost)
:80 / :443
browser ──HTTPS──▶ ┌───────────┐ ──HTTP──▶ app @ 127.0.0.1:3000
│ nginx │ (never exposed publicly)
│ (TLS ends │
│ here) │
└─────┬──────┘
match on Host header │
│
example.org , www.example.org ─────▶ proxy_pass → app (SERVE)
example.com , www.example.com ─────▶ return 301 → https://example.org
anything else ─────────────────────▶ default server (no match)
The left column is the public edge; the right column stays on the machine. The same box decides serve vs redirect purely from the requested hostname.
3. How it works
3.1 The reverse proxy in front of the app
nginx listens publicly and proxy_passes to your app on a loopback port. Three reasons this is the default production shape:
- One entry point. TLS, headers, gzip, rate limits, and logging live in one place instead of in every app.
- Many services, one box. Route by hostname or path —
api.example.orgto one process,example.orgto another — all on ports 80/443. - The app is never exposed. It binds
127.0.0.1, so the only way in is through the proxy.
The routing key is nginx's server_name. A request's Host header is matched against every server block's server_name; the matching block decides what happens. No match → the block marked default_server (or the first one) handles it.
3.2 HTTPS with Let's Encrypt (ACME)
A certificate authority will issue a free TLS certificate once you prove you control the domain. The common proof (the ACME HTTP-01 challenge) works like this: the ACME client (certbot) asks the CA for a cert, the CA hands back a token, certbot serves that token at http://your-domain/.well-known/acme-challenge/<token> on port 80, and the CA fetches it from the public internet. If it sees the right token, it issues the cert. certbot installs a timer that renews automatically before expiry.
Two consequences worth internalizing: the domain must already resolve publicly to your box before you can get a cert (the CA reaches it over the internet), and TLS terminates at nginx — traffic from nginx to your app is plain HTTP on the loopback, which is fine because it never leaves the machine.
3.3 DNS: pointing a name at the box
A domain name becomes your VM through DNS records:
- An A record maps a name to an IPv4 address (
example.org → 203.0.113.10). wwwis usually a CNAME to the apex, or its own A record.
Changes are cached for the record's TTL, so propagation is bounded by it (minutes to an hour, typically). Check what the public internet sees, not just your machine's cache, before assuming a name is live.
3.4 One canonical domain, everything else redirects
Pick exactly one canonical host (say example.org). Serve it. Send everything else to it with a 301:
www.example.org→example.org(or vice versa — just pick one).- An entire alternate domain
example.com(and itswww) →https://example.org.
301 means moved permanently; browsers and search engines cache it and consolidate ranking signals on the canonical URL. That's the difference from 302 (temporary), which they don't consolidate. Redirecting instead of deleting the old domain keeps every old link alive while giving you a single identity.
3.5 Keeping the app alive (a process manager)
A bare node server.js dies when it crashes or the box reboots. A process manager — pm2, or a systemd unit — supervises the process: restarts it on failure, starts it on boot, and captures logs. Your deploy then becomes a fixed loop: pull code → build → restart the managed process.
Boundary condition: everything in this section assumes one machine. It stops applying the moment a single box can't hold your traffic, or you can't tolerate that box being down — at which point the front door has to move off the box entirely (§8).
4. The math
4.1 Two windows that bite in practice
(a) A redirect costs one extra round trip. A request to example.com returns a 301, and the client then makes a second request to example.org. It's a one-time cost per client (the browser caches the 301), but it's real on the first hit and shows up in tail latency.
(b) Caching trades freshness for load. If the proxy or app caches a response for revalidate seconds, then a reader can see content up to revalidate seconds stale — but all readers in that window share a single origin fetch. A manual restart clears the cache and resets staleness to 0.
4.2 Worked example
Take revalidate = 3600 seconds and 1200 reads in that hour:
worst-case staleness = 3600 s / 60 = 60 min
origin fetches = 1 per window (shared by all readers)
amortization = 1200 requests / 1 fetch = 1200x fewer origin hits
So a one-hour cache means a visitor might see content up to 60 minutes old, but your origin (an S3 bucket, a database, a slow render) is hit once instead of 1200 times — a 1200× reduction. That is the trade: lower origin load, bounded staleness. If "publish and it's live instantly" matters more than load, shorten the window or restart on publish. These figures are the exact output of the script in §5.
5. Real code
The serve-vs-redirect decision and the cache windows are easy to get subtly wrong in config, so model them in plain Python first and assert the behavior. This block runs on the standard library:
# Model 1: nginx host routing -- which host is SERVED vs 301-REDIRECTED.
CANONICAL = "example.org" # the one real site
ALT = "example.com" # kept alive, but redirects
def route(host):
if host in (CANONICAL, "www." + CANONICAL):
return ("serve", None) # served by the app block
if host in (ALT, "www." + ALT):
return ("redirect-301", "https://" + CANONICAL) # permanent redirect
return ("no-match", None) # falls through to default
for h in ["example.org", "www.example.org", "example.com", "www.example.com", "other.net"]:
print(f"{h:18} -> {route(h)}")
assert route("example.org")[0] == "serve"
assert route("example.com") == ("redirect-301", "https://example.org")
assert route("www.example.com") == ("redirect-301", "https://example.org")
# Model 2: cache staleness + origin-load amortization (the revalidate window).
revalidate_s = 3600 # each response cached this long
requests_per_hour = 1200 # example read traffic
origin_fetches = 1 # one refill per window, shared by all readers
print(f"worst-case staleness: {revalidate_s // 60} min")
print(f"origin hits/hour: {origin_fetches} (vs {requests_per_hour} requests "
f"-> {requests_per_hour // origin_fetches}x fewer)")
assert revalidate_s // 60 == 60
assert requests_per_hour // origin_fetches == 1200
print("all asserts passed")
# Output:
# example.org -> ('serve', None)
# www.example.org -> ('serve', None)
# example.com -> ('redirect-301', 'https://example.org')
# www.example.com -> ('redirect-301', 'https://example.org')
# other.net -> ('no-match', None)
# worst-case staleness: 60 min
# origin hits/hour: 1 (vs 1200 requests -> 1200x fewer)
# all asserts passed
The real nginx equivalent is two server blocks — one that serves the canonical host, one that redirects everything else:
# Serve the canonical site
server {
listen 443 ssl;
server_name example.org www.example.org;
ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000; # the app, private port
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirect the alternate domain -> canonical (keeps old links alive)
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;
location /.well-known/acme-challenge/ { root /var/www/certbot; } # keep renewal working
location / { return 301 https://example.org$request_uri; }
}
And the deploy + certificate commands are a fixed, boring loop:
# get / renew the certificate (covers all four names)
sudo certbot --nginx -d example.org -d www.example.org -d example.com -d www.example.com
# apply config safely: test BEFORE reload; reload never drops live connections
sudo nginx -t && sudo systemctl reload nginx
# deploy the app
git pull && npm run build && pm2 restart app
Note nginx -t before every reload — it validates the config, so a typo fails the test instead of taking the site down.
6. Real-world example
The failure worth memorizing is the one that looks like nothing happened.
You edit the site's nginx config, run reload, and refresh the page — and your change isn't there. You edit again, reload again, nothing. The config you're editing is not the config nginx loads. On Debian/Ubuntu, nginx includes sites-enabled/, and the convention is that files there are symlinks into sites-available/. But if someone once copied a file into sites-enabled/ instead of symlinking it, you now have two independent copies — you're editing one, nginx is reading the other. Every reload faithfully re-reads the stale copy.
The tell is that sudo nginx -t prints the path of the file it actually parsed; ls -l /etc/nginx/sites-enabled/ shows whether each entry is a -> symlink or a real file. Fix the drift (make it a symlink, or edit the file nginx truly loads) and the reload finally takes.
A sibling of the same family: certbot reports "certificate issued but could not install." The certificate was obtained — but certbot couldn't wire it into nginx because no server block's server_name matched the domain it just secured. The cert on disk is fine; the config just doesn't reference it for that name. Add the name to the right server_name, point ssl_certificate at the cert, nginx -t, reload. Both bugs share a lesson: the artifact existing is not the same as the running system using it. Verify what's loaded, not what's on disk.
7. Interview questions companies actually ask
Q1. What is a reverse proxy and why put one in front of your app? It's a server that accepts client requests and forwards them to one or more backend processes, returning their responses. You use it to terminate TLS in one place, expose a single public entry point for many private services, and centralize concerns (routing, gzip, rate limiting, logging) instead of duplicating them in every app. The backends bind to loopback and are never directly reachable.
Q2. 301 vs 302 — when does it matter? 301 is moved permanently: browsers and search engines cache it and transfer ranking signals to the target. 302 is temporary: nothing is cached long-term and signals stay with the original. Use 301 to consolidate a www/apex or an old domain onto a canonical one; use 302 for a short-lived diversion (maintenance page, A/B test) you intend to undo.
Q3. Where does TLS terminate in this setup, and what protects the hop to the app? TLS terminates at nginx. The nginx→app hop is plain HTTP over 127.0.0.1, which is acceptable because it never leaves the host. If the app ran on a different machine you'd re-encrypt that hop or keep it on a private network.
Q4. How does Let's Encrypt verify you own a domain? Via an ACME challenge. In HTTP-01, certbot serves a CA-provided token at /.well-known/acme-challenge/ on port 80 and the CA fetches it over the public internet; DNS-01 instead proves control by publishing a TXT record. Either way the domain must be publicly resolvable to you first.
Q5. A record vs CNAME? An A record maps a name directly to an IPv4 address; a CNAME aliases one name to another name (which is then resolved). Apex domains usually need an A record (CNAME at the apex is often disallowed); www is commonly a CNAME to the apex.
Q6. Why a process manager, and how do you deploy without dropping the site? A process manager (pm2, systemd) restarts your app on crash and on reboot and captures logs — a bare process doesn't survive either. For the proxy layer, nginx -t && systemctl reload swaps config without dropping live connections. A plain app restart briefly drops in-flight requests unless you add graceful reload or run two instances behind the proxy.
Q7. How does nginx choose which server block handles a request? It matches the request's Host header (and, for HTTPS, the TLS SNI) against every block's server_name; the most specific match wins, and if nothing matches it falls to the default_server (or the first block on that port). This is exactly the route() function modeled in §5.
8. When to use / tradeoffs
Reach for a single VM + nginx when:
- You're running one or a few services and want one clean, free-TLS entry point.
- Traffic fits comfortably on one machine.
- You want full control of the box and a simple, legible deploy loop.
- You're serving a model/inference API or a web app that's effectively stateless (state lives in a DB or object store).
Do NOT use when:
| Situation | Why it breaks | Use instead |
|---|---|---|
| Must survive the box dying | One VM is a single point of failure | Managed load balancer across ≥2 instances / AZs |
| Traffic exceeds one machine | Vertical scaling hits a ceiling | Horizontal scale behind a load balancer |
| Frequent deploys of a stateful app | Restart drops in-flight requests/connections | Blue-green / rolling deploys on a platform |
| Global, latency-sensitive audience | One region adds distance for far users | CDN / edge in front of the origin |
Honest limits. This pattern optimizes for simplicity on one box, and every advantage is also its ceiling. A single VM is a single point of failure — if it goes down, everything behind it goes down, and TLS renewal or a bad reload can take the whole front door with it. A plain process restart is a brief availability blip unless you've added graceful reload or a second instance. It assumes your app is stateless and its data lives elsewhere; co-locating a database on the same box quietly reintroduces the SPOF you were managing. And the redirect/serve logic is only as correct as the server_name list — a missing name silently falls through to the default server, which is how "why is .com serving the wrong site?" bugs happen. Treat this as the right default for small-to-moderate deployments, and the thing you graduate from — not a target architecture for high availability.
9. Summary + related articles
- A reverse proxy (nginx) is one public front door: it terminates HTTPS and routes to private
localhostapp ports by hostname. - Free, auto-renewing TLS comes from Let's Encrypt/ACME, which requires the domain to resolve to you first.
- Serve one canonical host;
301-redirectwwwand any alternate domain onto it to keep old links alive and consolidate identity. - A process manager keeps the app alive; the deploy loop is pull → build → restart, and
nginx -tbefore every reload. - Boundary: it's a one-machine pattern. A single VM is a single point of failure — cross that line and the front door moves to a load balancer or managed platform.
Related:
- ML Inference Systems — what you're usually proxying to: a model-serving API, and how to design it.
- Scaling RAG: Many Tenants, One Code Path — where you go when one box stops being enough.
- Production Agents — operating long-running services in production beyond the first deploy.
Resources
- nginx, "Beginner's Guide" and the
ngx_http_proxy_modulereference — https://nginx.org/en/docs/ - Let's Encrypt, "How It Works" (ACME challenges) — https://letsencrypt.org/how-it-works/ ; certbot docs — https://eff-certbot.readthedocs.io/
- MDN Web Docs, HTTP
301 Moved Permanentlyand redirections — https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301 - MDN Web Docs, "What is a URL / DNS" and A vs CNAME records — https://developer.mozilla.org/en-US/docs/Web/HTTP
- PM2 process-manager docs — https://pm2.keymetrics.io/docs/usage/quick-start/ ; systemd service units — https://www.freedesktop.org/software/systemd/man/systemd.service.html