The front door of your app: it greets every visitor, serves static files itself, forwards the rest to the app behind it — and terminates HTTPS.
← / → to navigate · EN/VI switch below
Every visitor's request hits nginx first — before it can touch your Flask app. The app never sees the outside world directly.
visits shipper.dev — one address, one door
the front door — every request passes here
443 · TLS · static · proxy
one door, two kinds of answers: what nginx can serve itself, and what it must forward
nginx is famous as a "reverse proxy" — but in a real deployment it quietly does three things at once.
Serves images, CSS and JS straight from disk. The app is never involved — that's why sites feel fast.
Forwards dynamic requests (/api, pages) to the Flask app behind it and sends the answer back.
Ends the encrypted connection at the door: visitors get HTTPS, the app gets plain local HTTP.
What nginx is, what it isn't, and how one request flows through it.
The same program is usually described with two names. Both are true — and which face you're thinking of changes how you configure it.
nginx is pointed at a folder (root) and serves whatever is in it: images, CSS, JS — even whole static sites.
Think: a shop that sells its own inventory.
nginx accepts the request, forwards it to another program (your Flask app), and relays the answer back. The visitor never learns the app exists.
Think: a front desk that passes orders to the kitchen.
nginx decides per request — by path — whether to answer itself or to forward.
GET https://shipper.dev/…
matches the request's path against the location blocks
the browser sees one address, one port — behind it: instant files and a forwarded app, both invisible to the visitor
Our Flask app with a static folder. Most of a page's requests are answered by nginx alone.
| Request | Who answers | Why |
|---|---|---|
| GET / | Flask app (proxied) | The page is generated — it needs Python |
| GET /static/app.css | nginx, directly | The file already exists on disk |
| GET /static/app.js | nginx, directly | Same: a plain file, no logic |
| GET /api/orders | Flask app (proxied) | The answer depends on the user + database |
| GET /favicon.ico | nginx, directly | Static file — and browsers cache it |
Your app is the shop. nginx is the front desk: it greets every visitor, hands out the brochures, and passes the orders to the kitchen — without ever letting customers walk in.
nginx is configured with nested blocks in one plain-text file. One site = one server block = a handful of directives.
A realistic, complete config for our Flask app. Read it top to bottom — every line has a job.
server {
listen 80;
server_name shipper.dev;
root /var/www/shipper; # static files live here
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:5000;
}
}
One server block = one site. Two location blocks = the whole static-vs-dynamic split.
nginx has hundreds of directives. These six cover the 95% you'll ever read in a site config.
Opens a block that describes one site: its ports, its name, its rules.
Which port(s) this site answers on — 80 for HTTP, 443 for HTTPS.
The domain this block belongs to — matched against each request's Host header.
The folder where static files live — where / is looked up on disk.
A rule for a path prefix: different paths can get different treatments.
Where matched requests are forwarded — the app's address.
The listen line is where a site says which door it stands at. Two doors — one has a lock.
The default web port. Fast and simple, but the whole conversation is readable by anyone on the wire.
listen 80; — browsers now warn "Not secure".
The encrypted default. nginx ends the TLS session at the door, then talks to the app in plain local HTTP.
listen 443 ssl; — the padlock in the address bar.
A single VPS can host dozens of sites. server_name is how nginx tells them apart.
shipper.dev gets its own server block, and so do shop.example.com and blog.example.com.
Every request carries a Host header; nginx picks the block whose server_name matches it.
The first block (or default_server) answers requests that don't match any name.
The single most important line of a reverse proxy: it points at where the app actually lives.
One location block, one address: everything under /api/ is handed to the Flask app.
# everything under /api/ goes to the Flask app
location /api/ {
proxy_pass http://127.0.0.1:5000; # the app runs on port 5000
}
/api/orders arrives at Flask still as /api/orders — nginx changes who receives it, not what it says.
When nginx runs inside the same compose network as Flask, it reaches the app by service name — no exposed port needed.
services:
web:
build: . # our Flask app
nginx:
image: nginx:1.27
ports:
- "80:80"
- "443:443"
depends_on:
- web
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
Only nginx's 80/443 face the internet. Flask stays private inside the network.
The proxy_pass idea is the same either way — only the address of the app changes.
nginx runs on the server; Flask runs in a container mapped to a host port. nginx knocks on localhost.
proxy_pass http://127.0.0.1:5000;
nginx and Flask are containers on one private network. The service name web resolves to the app automatically.
proxy_pass http://web:5000;
Follow /api/orders from the browser's point of view — and nginx's.
GET https://shipper.dev/api/orders, over TLS, to port 443.
The path starts with /api/, so the second location block wins.
It opens a connection to 127.0.0.1:5000 and relays the request — now plain local HTTP.
Flask's response flows through nginx to the browser. The visitor never met port 5000.
It's not just convention — serving static files at the door is a speed and security win.
A CSS file is a file. nginx reads it from disk and sends it — no Python process, no database, no thinking.
A page's 40 static requests never touch the app.
The app can sit in a private network. Attackers reach nginx — the door — but never the app's port.
One less door into the building.
The padlock in the address bar is a certificate — and nginx is the one who shows it.
HTTPS = HTTP + TLS. The certificate does three jobs, and the browser checks all three before showing the lock.
Everything between browser and nginx is scrambled — eavesdroppers on the wire read nothing.
The certificate proves the site really is shipper.dev — not a copy on some other server.
Nobody can quietly change what's sent — the handshake would fail.
The same block as before, plus two certificate lines. nginx now speaks TLS at the door.
server {
listen 443 ssl;
server_name shipper.dev;
ssl_certificate /etc/letsencrypt/live/shipper.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shipper.dev/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5000;
}
}
Two files: fullchain.pem is shown to the browser; privkey.pem never leaves the server.
Nobody buys certificates anymore. Let's Encrypt issues them for free — and your config just points at the files.
certbot asks you to serve a special file at shipper.dev; the certificate authority checks it over the internet.
Let's Encrypt issues a free certificate — valid for 90 days, renewed automatically by a timer.
fullchain.pem + privkey.pem land in /etc/letsencrypt/ — exactly where the config points.
Renewal writes new files; a reload makes nginx use them. The lock never goes away.
One command, and the padlock is yours for 90 days.
nginx config is easy to break and easy to check. Three commands cover the daily work.
Change the config, test it, reload — nginx applies changes without dropping a single connection.
Everything else is rare. When you need it, you'll search for it — these five become muscle memory.
| Goal | Command |
|---|---|
| Check the config before applying | nginx -t |
| Apply changes without dropping traffic | systemctl reload nginx |
| Restart when a reload won't fix it | systemctl restart nginx |
| See who is visiting right now | tail -f /var/log/nginx/access.log |
| See what went wrong | tail -f /var/log/nginx/error.log |
Everything you've learned in this course now sits behind exactly one door: DNS finds it, TLS protects it, the proxy decides.
https://shipper.dev
TLS · static files · proxy — the single control point
:80 · :443
:5000 · private network
volume
the internet talks to nginx · nginx talks to the app · everything in between is yours to control
nginx has a deep toolbox. These four are real — but they're not your next step.
Spreading requests across several app instances with an upstream block. One app is enough for now.
Throttling abusive clients with limit_req rules — an ops detail, not a mental model.
Letting nginx store backend answers to serve them again — powerful, but a tuning topic.
gzip, headers, timeouts, worker tuning… the reference exists; you look things up when you need them.
The internet knocks. nginx answers. Everything else is a conversation between nginx and the app — private, local, and under your control.
You now know the whole chain: domain → nginx → Flask → database. The next topic puts it all together in one deployment.