NGINX 101: Under the Hood of Your Reverse Proxy
Introduction: Stop Relying on "Magic"
Tools like Nginx Proxy Manager (NPM) and SWAG are incredible. They wrap complex web server logic into nice GUIs and pre-made containers. They are the "Self-Driving Cars" of the homelab world. I personally favour SWAG overall, but I have used NPM in the past and it's developed a LOT since then, they are near-peer.
But what happens when the car breaks down?
If you want to move from a "Hobbyist" to a "SysAdmin," you need to understand the engine. You need to know what a proxy_pass actually does, why X-Forwarded-For matters, and how to debug a config file when the GUI fails you.
In this deep dive, we are stripping away the helpers. We are going to configure Raw NGINX from scratch, first on "bare metal" (Debian) to understand the structure, and then in Docker to see how the pros deploy it.
Part 1: The Anatomy of NGINX (Debian)
Let's start with a classic installation. Even if you plan to use Docker forever, understanding the Linux file structure is mandatory because Docker volumes mimic this exact layout.
1. The Installation
Update your system and grab the package:
sudo apt update && sudo apt install nginx -y
Check if it's alive:
sudo systemctl status nginx
Go to http://YOUR-SERVER-IP. If you see "Welcome to NGINX," you are live. In my case, I see the SWAG welcome sign.

2. The File Structure (Where things live)
NGINX is opinionated about where files go. Memorize these four locations:
/etc/nginx/nginx.conf: The Brain. This controls global settings (worker processes, logging formats). You rarely touch this./etc/nginx/sites-available/: The Drafts folder. You write your site configs here./etc/nginx/sites-enabled/: The Live folder. NGINX only reads configs linked here./var/log/nginx/: The Black Box. When things break,error.logtells you why.
3. The Architecture
Before we write code, visualize the flow. NGINX sits between the chaos of the internet and your fragile applications.
Part 2: Writing Your First Config
Let's create a reverse proxy for a fictional app running on Port 3000.
1. The Basic HTTP Block
Create a file: /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
# The "Plumbing" Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
2. The "Plumbing" Explained (Why do we need Headers?)
This is where beginners get stuck. If you just use proxy_pass, your backend app (e.g., Jellyfin or Ghost) sees every request coming from 127.0.0.1 (the NGINX server itself).
Host $host: Tells the app "The user typed example.com, not localhost."X-Real-IP $remote_addr: Tells the app the real user's IP (e.g., the guy in France), not NGINX's IP.X-Forwarded-Proto: Tells the app "The user connected via HTTPS," even if NGINX is talking to the app via HTTP.
3. Turning on the Switch
To make this config live, we create a symbolic link (symlink) to the enabled folder:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
Test your syntax (Crucial Step!):
sudo nginx -t
If it says "syntax is ok," restart: sudo systemctl restart nginx
Part 3: The "Modern" Way (Raw NGINX in Docker)
Running NGINX on bare metal is fine, but in 2026, we containerize everything. This keeps your host OS clean and makes backups trivial.
1. The Directory Strategy
Don't dump files in root. Organization is key.
~/docker/raw-nginx/
├── docker-compose.yml
├── nginx.conf # Global Config
└── conf.d/ # Site Configs (Replaces sites-enabled)
└── corelab.conf # Your actual proxy rules
2. The docker-compose.yml
We use the official Alpine image for speed (10MB size) and spin up a "WhoAmI" backend to test against.
YAML
services:
nginx:
image: nginx:alpine
container_name: raw-nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro
depends_on:
- backend
# A dummy app to prove the proxy works
backend:
image: traefik/whoami
container_name: backend-app
restart: unless-stopped
3. The conf.d/corelab.conf (The Magic)
Here is the cool part about Docker: DNS Resolution. Notice we don't use IP addresses. We use the container name.
# Define the upstream (Docker DNS resolves 'backend' automatically)
upstream backend_service {
server backend:80;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://backend_service;
# WebSocket Support (Required for Home Assistant / VS Code)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Summary: Raw vs. The Managers
Why would you do this "Hard Mode" setup instead of just using SWAG or NPM?
- Zero Bloat: You aren't running Fail2Ban, Certbot, or Python scripts if you don't need them. It's 10MB vs 400MB.
- Performance: In high-traffic enterprise environments, stripping away the wrapper logic saves milliseconds.
- Kubernetes Prep: This
conf.dstructure is almost identical to how K8s Ingress Controllers work.
The Verdict:
For your home, stick to SWAG or NPM—they automate SSL certificates (Let's Encrypt), which is a pain to do manually here. But now, when SWAG breaks, you know exactly which file to check.
Member discussion