Skip to content

Day 4 · Networking IV — Nginx, Reverse Proxy & TLS

Four days of networking come together today. You can address machines, resolve names, and reach them securely — now you'll run the thing that serves them. Nginx is the front door of most of the internet: it hands out static files, wraps them in HTTPS, records every request, forwards traffic to your app, and spreads load across many app copies. Build all of it, and Operation Go Live has its web tier.

Learning Objectives

  • Install and run Nginx and understand its configuration layout
  • Serve static assets from a server block (virtual host)
  • Generate a self-signed SSL certificate and serve your site over HTTPS
  • Shape Nginx access logs with a custom log_format
  • Put Nginx in front of a Python API as a reverse proxy
  • Spread traffic across multiple backends as a load balancer

Theory · ~20 min

1. What a web server actually does

A web server listens on a port (80 for HTTP, 443 for HTTPS) and, for each request, does one of four jobs:

Job Meaning
Serve static files Hand back HTML/CSS/JS/images from disk
Terminate TLS Handle HTTPS so the app behind it can speak plain HTTP
Reverse proxy Forward the request to a backend app and relay its reply
Load balance Distribute requests across several backends

Nginx ("engine-x") does all four and is the most-deployed web server in the world. Its event-driven model handles thousands of connections cheaply — which is why it usually sits at the front of production stacks. Every reply carries a status code: 2xx success, 3xx redirect, 4xx the client erred (404 not found), 5xx the server erred (502 bad gateway) — you'll read these in the logs later today.

📺 Watch — NGINX Explained in 100 Seconds (Fireship, ~2 min)

A 2-minute primer on what Nginx is and why it sits at the front of the stack. Watch this first, then the deep dive in Section 2.

NGINX Explained in 100 Seconds

Chapters: what is Nginx · configuration · reverse proxy

2. Nginx config layout

/etc/nginx/
├── nginx.conf          ← global settings; includes the rest
├── conf.d/             ← extra http-level config (e.g. log formats)
├── sites-available/    ← every site config you've written (may be inactive)
├── sites-enabled/      ← symlinks to the ones that are ON
└── snippets/           ← reusable fragments (e.g. TLS settings)

The unit of config is a server block — Nginx's virtual host. It decides how to answer requests for a given name/port:

server {
    listen 80;
    server_name example.com;      # which hostname this block answers for
    root /var/www/example;        # where files live
    index index.html;

    location / {                  # match URL paths
        try_files $uri $uri/ =404;
    }
}

try_files $uri $uri/ =404 means: try the file, then the directory, else return 404.

📺 Deep dive — NGINX for Beginners (KodeKloud, full tutorial)

Want more than the 100-second version? This chaptered walkthrough covers the config model in depth.

NGINX for Beginners — full tutorial

Chapters: config file structure · server blocks & virtual hosts · directory structure · essential commands · install on Ubuntu · reverse proxy & load balancing

3. TLS & self-signed certificates — the padlock

TLS (Transport Layer Security; the successor to SSL) encrypts the connection so nobody between client and server can read or tamper with it. When Nginx "terminates TLS," it holds the certificate and does the crypto, handing plain HTTP to your app behind it.

📺 Watch — SSL, TLS, HTTPS Explained (ByteByteGo, ~6 min)

A crisp animated explainer of the handshake below. Watch it, then read the step-by-step walk-through.

SSL, TLS, HTTPS Explained

Chapters: HTTPS · TLS

How a secure connection is established — a simple walk-through:

  1. Browser connects to the server and asks for a secure connection.
  2. Server sends its certificate (the domain, a public key, an expiry, and a CA's signature).
  3. Browser checks the certificate: is it for this site, and still valid (not expired)?
  4. Browser checks who issued it — a Certificate Authority (CA) — and whether it trusts that CA (browsers ship with a pre-installed list of trusted CAs).
  5. If the CA is trusted and everything checks out, the two agree on a session key and the connection is trusted and encrypted.

A certificate is a key pair plus an identity, sealed by a signature:

Piece What it is
Private key A secret you generate and never share; does the decryption/signing
Public key Derived from the private key, safe to hand out; ends up inside the certificate
CSR (Certificate Signing Request) Your public key + identity (domain, org), bundled so a CA can sign it

Who signs the CSR is the only difference between the two paths:

CA-signed:    you ─CSR─▶ Certificate Authority ─signs with CA key─▶ trusted cert
Self-signed:  you ─────▶ sign it with your OWN key ──────────────▶ untrusted cert

A self-signed cert skips the CA — the same key that owns the cert also signs it. The crypto is identical to a real one, but no browser has your key in its trust store, so it warns. That's the trade-off for a cert you can mint offline yourself.

Type Trusted by browsers? Use
Self-signed No (warning shown) Local dev, internal tools — today's lab
Let's Encrypt Yes (free, automated CA) Public sites — your Week 4 Go-Live
Commercial CA Yes (paid) Enterprise/EV needs

Self-signed today, Let's Encrypt in production

A real Let's Encrypt cert needs a public domain pointing at a reachable server on port 80 to validate, which you'll have in Week 4 on a real cloud box. certbot automates the same CSR-to-CA round trip you'd otherwise do by hand. See Advanced Topics.

Generate and self-sign one with openssl — a single command makes the private key and the certificate together:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout golive.key -out golive.crt \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

-x509 says "self-sign directly" (no CSR sent anywhere), -newkey rsa:2048 generates a fresh 2048-bit key at the same time, and -nodes leaves it passphrase-free so Nginx can start unattended. The subjectAltName (SAN) lists the names the cert is valid for — modern clients match the hostname against the SAN, not the CN, so a cert without one fails verification even when trusted. Inspect what you issued — decode the certificate into human-readable form to check the subject, validity dates, and public key:

openssl x509 -in golive.crt -text -noout

Point Nginx at the pair with two directives inside the HTTPS server block:

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/certs/golive.crt;   # the certificate (public)
    ssl_certificate_key /etc/nginx/certs/golive.key;   # the private key (secret)
}

4. Reverse proxy vs load balancer

A reverse proxy stands in front of one backend app and forwards to it — the client only ever talks to Nginx. Turn one backend into a pool and Nginx becomes a load balancer, rotating requests across them (round-robin by default) so no single app instance is overwhelmed and any one can die without downtime.

              ┌─▶ app :3001
Client ─▶ Nginx ─▶ app :3002    (load balancer: one front, many backends)
              └─▶ app :3003

Run a backend on several ports to see this for real. Any tiny server works (a Docker container, a Node app…); the simplest is Python's built-in one, launched three times:

# The same "app" on three ports
for p in 3001 3002 3003; do
  python3 -m http.server "$p" --bind 127.0.0.1 &
done

Then declare those three as an upstream pool and forward to it — one backend makes Nginx a reverse proxy, a pool makes it a load balancer:

upstream app_pool {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

server {
    listen 80;
    location / {
        proxy_pass http://app_pool;    # round-robins across 3001 → 3002 → 3003
    }
}

5. Logs — every request, recorded

Nginx writes an access log line per request and an error log line per failure. Two directives shape the access log, and where each one goes matters:

  • log_format names a line shape — it must live in the top-level http block (define it once, globally).
  • access_log switches logging on and picks a format by name — it goes in a server (or location) block.
http {
    log_format golive '$remote_addr [$time_local] "$request" $status ${request_time}s';   # define once, here

    server {
        access_log /var/log/nginx/golive_access.log golive;   # use it per-site, by name
    }
}

A request then lands in the log as one line — each $variable filled in for that request:

127.0.0.1 [15/Jul/2026:10:22:41 +0000] "GET / HTTP/1.1" 200 0.001s

Here $status tells you it was a 200 and $request_time that it took a millisecond. Putting log_format inside a server block is the #1 beginner mistake — Nginx refuses to start with "log_format" directive is not allowed here. Good log shape is how you later answer "which requests are slow?" and "who's getting 502s?".


Lab · ~50 min

Work inside your Vagrant VM. (If you enabled ufw on Day 3, ports 80 and 443 are already allowed.) Each step evolves the same golive site, so keep going in order.

Step 1 — Install & start Nginx

sudo apt update && sudo apt install -y nginx
sudo systemctl enable --now nginx
curl -I http://localhost                # 200 OK from the Nginx default page

Step 2 — Serve a static site

sudo mkdir -p /var/www/golive
sudo tee /var/www/golive/index.html << 'EOF'
<!DOCTYPE html>
<html>
  <head><title>Operation Go Live</title></head>
  <body><h1>Served by Nginx 🚀</h1><p>Static assets, live.</p></body>
</html>
EOF
sudo chown -R www-data:www-data /var/www/golive

sudo tee /etc/nginx/sites-available/golive << 'EOF'
server {
    listen 80;
    server_name localhost;

    root /var/www/golive;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

sudo ln -sf /etc/nginx/sites-available/golive /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default    # disable the default site
sudo nginx -t                                  # ALWAYS test before reloading
sudo systemctl reload nginx

curl http://localhost                          # your page

nginx -t then reload, every time

nginx -t validates the config; reload applies it with zero downtime (existing connections finish). Never restart a busy server when reload will do.

Step 3 — Generate a self-signed certificate & serve HTTPS

First mint a key + self-signed cert in one openssl command:

sudo mkdir -p /etc/nginx/certs
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/nginx/certs/golive.key \
  -out    /etc/nginx/certs/golive.crt \
  -subj "/C=NP/ST=Bagmati/L=Kathmandu/O=DevOpsMonth/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

What each flag does:

Flag Meaning
req -x509 Make a self-signed certificate directly (skip the CSR-to-CA round trip)
-newkey rsa:2048 Generate a fresh 2048-bit RSA private key at the same time
-nodes Don't passphrase-encrypt the key, so Nginx can start unattended
-days 365 Valid for a year — self-signed certs have no auto-renewal
-keyout / -out Where to write the private key and the certificate
-subj Identity fields; CN (Common Name) is the primary hostname
-addext Subject Alternative Name — the hostnames/IPs the cert is valid for; modern clients match against this, not CN

Now redirect HTTP to HTTPS and serve the site over TLS:

sudo tee /etc/nginx/sites-available/golive << 'EOF'
# Redirect all HTTP to HTTPS
server {
    listen 80;
    server_name localhost;
    return 301 https://$host$request_uri;
}

# HTTPS
server {
    listen 443 ssl;
    server_name localhost;

    ssl_certificate     /etc/nginx/certs/golive.crt;
    ssl_certificate_key /etc/nginx/certs/golive.key;

    root /var/www/golive;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx

curl -kI https://localhost          # -k skips verification; note "HTTP/1.1 200"
curl -I  http://localhost           # note the 301 redirect to https

# Verify properly instead of skipping: trust the cert as a CA. Works because it
# now has a matching SAN — without -addext above, this would fail on the hostname.
curl --cacert /etc/nginx/certs/golive.crt https://localhost

Inspect the certificate you issued:

openssl x509 -in /etc/nginx/certs/golive.crt -noout -subject -dates

The browser would warn (untrusted signer), but the connection is fully encrypted — the same TLS a real cert uses.

Step 4 — Shape your access logs

As the theory noted, log_format must sit in the http block — not in your site's server block. Ubuntu's nginx.conf pulls every file in conf.d/ into that http block, so dropping the format there is the clean place for it. The access_log that uses the format then goes in the server block. First, the format:

sudo tee /etc/nginx/conf.d/golive_log.conf << 'EOF'
log_format golive '$remote_addr [$time_local] "$request" '
                  '$status ${body_bytes_sent}b ${request_time}s '
                  '"$http_user_agent"';
EOF

Now rewrite the site config with the access_log line added to the HTTPS server block — this is the full file, so you can just replace it:

sudo tee /etc/nginx/sites-available/golive << 'EOF'
# Redirect all HTTP to HTTPS
server {
    listen 80;
    server_name localhost;
    return 301 https://$host$request_uri;
}

# HTTPS
server {
    listen 443 ssl;
    server_name localhost;

    ssl_certificate     /etc/nginx/certs/golive.crt;
    ssl_certificate_key /etc/nginx/certs/golive.key;

    access_log /var/log/nginx/golive_access.log golive;   # ← uses the 'golive' format

    root /var/www/golive;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx

curl -k https://localhost >/dev/null            # generate a request
curl -k https://localhost/nope >/dev/null       # generate a 404
sudo tail -n 5 /var/log/nginx/golive_access.log

Each line now shows the client IP, the exact request, the status code, bytes sent, and how long Nginx took — the fields you'll grep during an incident.

Step 5 — Reverse-proxy a Python API

Real sites serve a running app, not just files. Write a tiny Python API that reports which port answered, then have Nginx forward to it:

cat > /tmp/api.py << 'EOF'
from http.server import BaseHTTPRequestHandler, HTTPServer
import json, sys

port = int(sys.argv[1]) if len(sys.argv) > 1 else 3000

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps({"message": "hello from the API", "port": port}).encode())
    def log_message(self, *a): pass   # keep the terminal quiet

HTTPServer(("127.0.0.1", port), Handler).serve_forever()
EOF

python3 /tmp/api.py 3000 &            # start the app on :3000
BACKEND=$!

Swap the HTTPS location / from serving files to proxying the app:

sudo tee /etc/nginx/sites-available/golive << 'EOF'
server {
    listen 80;
    server_name localhost;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name localhost;

    ssl_certificate     /etc/nginx/certs/golive.crt;
    ssl_certificate_key /etc/nginx/certs/golive.key;

    access_log /var/log/nginx/golive_access.log golive;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx
curl -k https://localhost             # JSON from the API, THROUGH Nginx over HTTPS
kill $BACKEND

Those proxy_set_header lines pass the real client info to the backend — without them the app thinks every request came from Nginx itself.

Step 6 — Load-balance across backends

Run three copies of the API, then define an upstream pool and watch Nginx rotate between them:

for p in 3001 3002 3003; do python3 /tmp/api.py "$p" & done

sudo tee /etc/nginx/sites-available/golive << 'EOF'
upstream app_pool {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

server {
    listen 80;
    server_name localhost;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name localhost;

    ssl_certificate     /etc/nginx/certs/golive.crt;
    ssl_certificate_key /etc/nginx/certs/golive.key;

    access_log /var/log/nginx/golive_access.log golive;

    location / {
        proxy_pass http://app_pool;
        proxy_set_header Host $host;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx

# Hit it several times — watch the "port" in the JSON change round-robin
for i in $(seq 6); do curl -sk https://localhost; echo; done

Replies fan out across 3001 → 3002 → 3003 → 3001 …. Kill one backend and Nginx routes around it — that's zero-downtime resilience in a few lines.

pkill -f "api.py"     # stop all three backends

Advanced Topics

  • Real certificates with Let's Encryptcertbot --nginx issues and auto-renews a trusted cert; needs a public domain + port 80. You'll run this for real on your Go-Live server in Week 4 → certbot.eff.org
  • Load-balancing strategiesleast_conn, ip_hash, weights, and health checks → Nginx — HTTP load balancing
  • HTTP hardening — HSTS, gzip/brotli, caching headers, HTTP/2 → Mozilla SSL Config Generator
  • Location matching — exact, prefix, and regex location rules and their precedence → DigitalOcean — Nginx location blocks
  • Apache, the other server — you'll meet it in the wild; the same ideas, <VirtualHost> syntax → Apache docs

Assignment

  1. Name-based virtual hosts. Configure Nginx to serve two different sites on port 80 from the same VM, chosen by hostname — site-a.local and site-b.local — each with its own root and a distinct index.html. Add both names to /etc/hosts pointing at 127.0.0.1 (you did this on Day 2), then prove each returns its own page with curl -H "Host: site-a.local" http://localhost and the same for site-b. Paste both configs and both outputs.

  2. Read the story in the logs. With your proxied site running, make a successful request, a request to a path that doesn't exist, and (bonus) stop your backend and hit the proxy. Then from /var/log/nginx/golive_access.log (and error.log) find the log lines and report: the status code for each case (expect a 200, a 404, and a 502), and one sentence on what a 502 Bad Gateway tells you about where the problem is.


Further Reading