→ From Coder to Shipper
From Coder to Shipper · DevOps

nginx — reverse proxy & web server

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

Big picture

Who answers the knock

Every visitor's request hits nginx first — before it can touch your Flask app. The app never sees the outside world directly.

🌐 Browser

visits shipper.dev — one address, one door

▶

🚪 nginx

the front door — every request passes here

443 · TLS · static · proxy

▶
📄 /static/app.css — served from disk, done
⚙️ /api/orders — forwarded to Flask :5000

one door, two kinds of answers: what nginx can serve itself, and what it must forward

Big picture · the three jobs

Three jobs, one process

nginx is famous as a "reverse proxy" — but in a real deployment it quietly does three things at once.

Static web server

Serves images, CSS and JS straight from disk. The app is never involved — that's why sites feel fast.

Reverse proxy

Forwards dynamic requests (/api, pages) to the Flask app behind it and sends the answer back.

TLS terminator

Ends the encrypted connection at the door: visitors get HTTPS, the app gets plain local HTTP.

01
Part 1

The mental model

What nginx is, what it isn't, and how one request flows through it.

Mental model · the two names

Two faces, one process

The same program is usually described with two names. Both are true — and which face you're thinking of changes how you configure it.

Web server — owns the files

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.

Reverse proxy — owns nothing

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.

Mental model · picture it

Anatomy of a request

nginx decides per request — by path — whether to answer itself or to forward.

🌐 Browser

GET https://shipper.dev/…

▶

🚪 nginx :443

matches the request's path against the location blocks

▶
📄 /static/app.css
served from disk — the app never wakes up
⚙️ /api/orders
proxy_pass → Flask :5000 → answer streams back

the browser sees one address, one port — behind it: instant files and a forwarded app, both invisible to the visitor

Mental model · the split

Who answers what

Our Flask app with a static folder. Most of a page's requests are answered by nginx alone.

RequestWho answersWhy
GET /Flask app (proxied)The page is generated — it needs Python
GET /static/app.cssnginx, directlyThe file already exists on disk
GET /static/app.jsnginx, directlySame: a plain file, no logic
GET /api/ordersFlask app (proxied)The answer depends on the user + database
GET /favicon.iconginx, directlyStatic 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.
the whole mental model in one sentence
02
Part 2

nginx.conf — the rulebook

nginx is configured with nested blocks in one plain-text file. One site = one server block = a handful of directives.

Config · the file

A whole site, in one block

A realistic, complete config for our Flask app. Read it top to bottom — every line has a job.

nginx.conf
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.

Config · the vocabulary

The six directives you'll read every time

nginx has hundreds of directives. These six cover the 95% you'll ever read in a site config.

server

Opens a block that describes one site: its ports, its name, its rules.

listen

Which port(s) this site answers on — 80 for HTTP, 443 for HTTPS.

server_name

The domain this block belongs to — matched against each request's Host header.

root

The folder where static files live — where / is looked up on disk.

location

A rule for a path prefix: different paths can get different treatments.

proxy_pass

Where matched requests are forwarded — the app's address.

Config · the ports

Port 80 vs port 443

The listen line is where a site says which door it stands at. Two doors — one has a lock.

80 — plain HTTP

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".

443 — HTTPS

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.

Config · the domain

server_name — one machine, many sites

A single VPS can host dozens of sites. server_name is how nginx tells them apart.

One block per domain

shipper.dev gets its own server block, and so do shop.example.com and blog.example.com.

Matched against Host

Every request carries a Host header; nginx picks the block whose server_name matches it.

A default catches the rest

The first block (or default_server) answers requests that don't match any name.

03
Part 3

proxy_pass — forwarding to the app

The single most important line of a reverse proxy: it points at where the app actually lives.

Proxying · the line

The forwarding rule

One location block, one address: everything under /api/ is handed to the Flask app.

nginx.conf
# 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.

Proxying · docker

In Docker, the address is a name

When nginx runs inside the same compose network as Flask, it reaches the app by service name — no exposed port needed.

docker-compose.yml
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.

Proxying · where nginx lives

Two address books

The proxy_pass idea is the same either way — only the address of the app changes.

nginx on the host

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 in compose

nginx and Flask are containers on one private network. The service name web resolves to the app automatically.

proxy_pass http://web:5000;

Proxying · the journey

One request, through the proxy

Follow /api/orders from the browser's point of view — and nginx's.

  1. 1

    The browser asks

    GET https://shipper.dev/api/orders, over TLS, to port 443.

  2. 2

    nginx matches

    The path starts with /api/, so the second location block wins.

  3. 3

    nginx forwards

    It opens a connection to 127.0.0.1:5000 and relays the request — now plain local HTTP.

  4. 4

    The answer streams back

    Flask's response flows through nginx to the browser. The visitor never met port 5000.

Proxying · the payoff

Why serve static yourself?

It's not just convention — serving static files at the door is a speed and security win.

Speed

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.

Security

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.

04
Part 4

HTTPS & the lock icon

The padlock in the address bar is a certificate — and nginx is the one who shows it.

TLS · the why

What the lock actually means

HTTPS = HTTP + TLS. The certificate does three jobs, and the browser checks all three before showing the lock.

Encryption

Everything between browser and nginx is scrambled — eavesdroppers on the wire read nothing.

Identity

The certificate proves the site really is shipper.dev — not a copy on some other server.

Integrity

Nobody can quietly change what's sent — the handshake would fail.

TLS · the config

The TLS server block

The same block as before, plus two certificate lines. nginx now speaks TLS at the door.

nginx.conf
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.

TLS · the source

How the certificate arrives — Let's Encrypt

Nobody buys certificates anymore. Let's Encrypt issues them for free — and your config just points at the files.

  1. 1

    Prove the domain

    certbot asks you to serve a special file at shipper.dev; the certificate authority checks it over the internet.

  2. 2

    Receive the certificate

    Let's Encrypt issues a free certificate — valid for 90 days, renewed automatically by a timer.

  3. 3

    Files on disk

    fullchain.pem + privkey.pem land in /etc/letsencrypt/ — exactly where the config points.

  4. 4

    Reload to pick up

    Renewal writes new files; a reload makes nginx use them. The lock never goes away.

TLS · in the terminal

Issuing it

One command, and the padlock is yours for 90 days.

ops@vps — zsh
$ sudo certbot --nginx -d shipper.dev
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Congratulations! Your certificate is saved to:
/etc/letsencrypt/live/shipper.dev/
$ sudo nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful
90 days of padlock — renewed automatically by a timer
05
Part 5

Keeping the front door running

nginx config is easy to break and easy to check. Three commands cover the daily work.

Ops · every day

Test, reload, watch

Change the config, test it, reload — nginx applies changes without dropping a single connection.

ops@vps — zsh
$ sudo nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
$ tail -f /var/log/nginx/access.log
1.2.3.4 - - [27/Oct/2026:09:12:44 +0000] "GET /api/orders HTTP/1.1" 200 312
1.2.3.4 - - [27/Oct/2026:09:12:45 +0000] "GET /static/app.css HTTP/1.1" 200 8123
test → reload → watch: the whole daily routine
Ops · the cheat sheet

Five commands, the whole job

Everything else is rare. When you need it, you'll search for it — these five become muscle memory.

GoalCommand
Check the config before applyingnginx -t
Apply changes without dropping trafficsystemctl reload nginx
Restart when a reload won't fix itsystemctl restart nginx
See who is visiting right nowtail -f /var/log/nginx/access.log
See what went wrongtail -f /var/log/nginx/error.log
Big picture · the whole chain

One box between the internet and your app

Everything you've learned in this course now sits behind exactly one door: DNS finds it, TLS protects it, the proxy decides.

🌐 Browser

https://shipper.dev

▶

🚪 nginx

TLS · static files · proxy — the single control point

:80 · :443

▶

🐳 Flask app (container)

:5000 · private network

▶

🗄 Database

volume

the internet talks to nginx · nginx talks to the app · everything in between is yours to control

Big picture · out of scope

What we're deliberately skipping

nginx has a deep toolbox. These four are real — but they're not your next step.

Load balancing

Spreading requests across several app instances with an upstream block. One app is enough for now.

Rate limiting

Throttling abusive clients with limit_req rules — an ops detail, not a mental model.

Caching (proxy_cache)

Letting nginx store backend answers to serve them again — powerful, but a tuning topic.

Directive deep-dive

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.
the architect's view

Door open. Traffic flowing.

You now know the whole chain: domain → nginx → Flask → database. The next topic puts it all together in one deployment.