01
The big picture
NGINX (“engine-x”) is software that sits in front of your web application and is the first thing every incoming request hits.
It does a handful of front-door jobs â handing out static files itself, passing harder requests to your real application, spreading load across multiple app servers, caching answers, and terminating HTTPS â while juggling a huge number of simultaneous visitors.
NGINX is the maître d’ / front-of-house host at the door. Guests are browsers, phones and API clients. The kitchens out back are your actual application servers. We reuse this one restaurant for the entire topic â one consistent model sticks; a pile of disconnected ones doesn’t.
THE INTERNET (a crowd of guests arriving) browser phone API client browser | | | | v v v v +-----------------------------------------------+ | N G I N X | <- the maitre d' | - hands out static files itself (bread) | | - routes harder requests to the back | | - load-balances across kitchens | | - caches, terminates TLS | +-----------------------------------------------+ | | | v v v +-----------+ +-----------+ +-----------+ | app srv | | app srv | | app srv | <- the kitchens / chefs | (Node) | | (Python) | | (Ruby) | +-----------+ +-----------+ +-----------+
One-liner: NGINX is the front door and traffic director for a web service.
02
Why NGINX exists â the C10k problem
Rewind to the late 1990s / early 2000s. The dominant web server was Apache HTTP Server, and its classic model handled visitors with one dedicated worker (process, later thread) per connection.
Imagine assigning one personal waiter to each guest for their whole visit â and that waiter just stands at the table while the guest reads the menu, chews, stares out the window. Each waiter costs a salary (memory) whether working or idle. With 50 guests, fine. With 10,000, the building can’t hold the waiters. The restaurant collapses â not because the kitchen is slow, but because you ran out of room for waiters.
That failure has a name: the C10k problem â can one server handle ten thousand concurrent connections? Coined by Dan Kegel around 1999.
OLD MODEL (thread/process per connection - classic Apache) guest 1 --> worker 1 (idle, but holding memory + a stack) guest 2 --> worker 2 (idle, blocked, waiting on the network) guest 3 --> worker 3 : : guest 10000 --> worker 10000 <-- server falls over here not from CPU - from the overhead of 10k workers
Most people assume a slow server is short on CPU or bandwidth. The C10k insight is the opposite â at high concurrency the bottleneck is per-connection overhead (memory per thread/process + the cost of the OS switching between thousands of them). The connections are mostly just sitting there waiting on a slow network, and you pay full price for every idle one.
Igor Sysoev wrote NGINX to solve exactly this for a large Russian site (Rambler); first released 2004. The fix: a tiny number of workers â roughly one per CPU core â each watching thousands of connections and only doing work when a connection actually has something to do.
NEW MODEL (event-driven, non-blocking - NGINX)
guest 1 -+
guest 2 |
guest 3 +--> ONE waiter --> watches ALL tables at once;
: | (per CPU core) springs into action only when
guest N -+ a table actually raises a hand
A real waiter can only watch so many tables. NGINX’s “watching” is done by the operating system’s event-notification machinery (epoll on Linux, kqueue on BSD/macOS), which is what makes “watch thousands at once” actually true. That machinery is the subject of sections 8–13.
03
The alternatives & trade-offs
NGINX is not the only front door in town. The real contenders, and where each one actually wins:
| Tool | What it is | Sweet spot |
|---|---|---|
| Apache HTTP Server | The incumbent web server | Per-directory config (.htaccess), running app code in-process (mod_php), huge module ecosystem, shared hosting |
| HAProxy | Dedicated proxy / load balancer | Heavy-duty L4/L7 load balancing; fine-grained traffic control |
| Caddy | Modern web server (Go) | Automatic HTTPS out of the box, dead-simple config |
| Envoy | Cloud-native proxy | Service meshes, dynamic discovery, rich observability (microservices) |
| Traefik | Modern reverse proxy (Go) | Auto-config from container labels (Docker/Kubernetes) |
| Cloud LBs (AWS ALB/ELB…) | Managed service | “I don’t want to run a box at all” |
| Just your app server | Node/Python serve HTTP directly | Tiny projects, local dev |
The honest axes
NGINX vs Apache. NGINX wins on raw concurrency, static-file serving, and low-memory proxying. Apache wins on flexibility: .htaccess lets each directory carry its own config (great for shared hosting), and it can run application code inside the server process. NGINX deliberately has no .htaccess equivalent â all config is centralized. That’s a feature (no per-request disk lookups) and a limitation (less convenient when you can’t touch main config).
NGINX vs HAProxy. HAProxy is a sharper pure load balancer with deeper traffic-shaping, but it won’t serve static files or be a general web server. NGINX is the Swiss Army knife; HAProxy is the specialist scalpel.
NGINX vs Caddy. Caddy gives automatic HTTPS with near-zero config. NGINX is more battle-tested and has a deeper ecosystem, at the cost of more setup.
NGINX vs Envoy/Traefik. In a dynamic container/microservices world where backends appear and vanish, Envoy and Traefik were built for auto-discovery. NGINX can do it but it’s not its native home.
04
When to use NGINX â and when not to
Reach for NGINX when
You want a reverse proxy in front of one or more app servers; you serve a mix of static assets and dynamic responses; you need TLS termination; you want to load-balance across a handful of backends; or you want caching. In short: the default sensible front door for most web stacks.
Pick something else when
| Red flag | Better tool |
|---|---|
| You want zero-config automatic HTTPS | Caddy |
| Service mesh with dynamic discovery + deep observability | Envoy (often via Istio) |
You depend on .htaccess / shared-hosting conventions | Apache |
| You need the most advanced load-balancing knobs | HAProxy |
| Tiny project, app server already serves HTTP fine | Add nothing â NGINX now is premature complexity |
05
What .htaccess actually is
.htaccess (“hypertext access”) is an Apache feature: a small config file you drop inside a directory of your site. It applies rules to that directory and everything below â URL rewrites, redirects, password-protecting a folder, blocking IPs, custom error pages.
A little house-rules card taped inside each room of the restaurant â “members-only,” “send these guests upstairs.” Anyone can write a card and tape it up without bothering management.
Why it exists: shared hosting. If you’re one of 500 customers on a provider’s Apache box, you can’t touch the main server config â so .htaccess lets you configure your slice by dropping a file in your folder, no admin access, no restart.
APACHE with .htaccess enabled - EVERY request for
/var/www/shop/cart/index.html makes Apache walk the path
and read a card in each room along the way:
/var/www/.htaccess <- read it
/var/www/shop/.htaccess <- read it
/var/www/shop/cart/.htaccess <- read it
...on EVERY request
Apache’s own documentation says: don’t use .htaccess if you have access to the main config â for exactly this performance reason (re-reading cards on every request) plus security. (httpd.apache.org/docs/2.4/howto/htaccess.html, “When (not) to use”.)
06
.htaccess: NGINX vs HAProxy
The twist: neither NGINX nor HAProxy has .htaccess â it’s an Apache concept. But why each lacks it is completely different, and that difference teaches you what each tool fundamentally is.
| Serves files from directories? | Per-directory runtime config? | Where config lives | |
|---|---|---|---|
| Apache | Yes | Yes â .htaccess | Main config + scattered .htaccess cards |
| NGINX | Yes | No, on purpose | One central config (nginx.conf + includes) |
| HAProxy | No | N/A â concept doesn’t apply | One central config (haproxy.cfg) |
Comparing NGINX and HAProxy on .htaccess is a low-key category error. NGINX is a web server that serves files, so it had a real choice and deliberately refused the per-directory model: all rules go in centralized config, re-read only on an explicit reload (nginx -s reload), never per-request. HAProxy doesn’t serve files at all â it’s a pure proxy/load balancer with no document root. There are no “rooms” to tape cards in; the question doesn’t even land on it.
NGINX = “No cards in rooms. One master rulebook at the door, memorized; re-memorized only when handed an update.” Faster (no per-guest card-reading), less convenient (edit the master rulebook).
HAProxy = the valet/dispatcher on the curb waving cars toward different restaurants. He has no dining room, so “house-rules cards” is a meaningless idea to him. (Tiny caveat: HAProxy can return a few canned error-page files via errorfile, but it has no document root or general static serving â so the .htaccess model still doesn’t apply.)
07
12 real-world use cases: .htaccess → NGINX
Same job, two filing systems. Syntax is illustrative â recognize the shape, look up exact flags when you ship.
| # | Goal | Apache .htaccess (module) | NGINX equivalent |
|---|---|---|---|
| 1 | Password-protect a folder | Require valid-user + AuthUserFile (mod_auth_basic) | auth_basic + auth_basic_user_file |
| 2 | 301 redirect (old → new) | Redirect 301 /old /new (mod_alias) | return 301 /new; |
| 3 | Force HTTPS | RewriteCond %{HTTPS} off + RewriteRule | return 301 https://$host$request_uri; |
| 4 | Pretty URLs / front-controller | RewriteRule . index.php (mod_rewrite) | try_files $uri $uri/ /index.php?$args; |
| 5 | Custom error pages | ErrorDocument 404 /404.html | error_page 404 /404.html; |
| 6 | Block / allow IPs | Require not ip 1.2.3.4 (2.4) | deny 1.2.3.4; / allow ...; |
| 7 | Disable directory listing | Options -Indexes | autoindex off; (already the NGINX default) |
| 8 | Browser cache lifetime | ExpiresByType image/png "access plus 30 days" | expires 30d; |
| 9 | Gzip compression | AddOutputFilterByType DEFLATE text/html | gzip on; gzip_types ...; |
| 10 | Hotlink protection | RewriteCond %{HTTP_REFERER} ... | valid_referers ...; + if ($invalid_referer) |
| 11 | Block bad bots | RewriteCond %{HTTP_USER_AGENT} BadBot | if ($http_user_agent ~* badbot){return 403;} |
| 12 | Security header (HSTS) | Header set Strict-Transport-Security ... | add_header Strict-Transport-Security "..."; |
The auth mechanism, redirect, gzip â these are HTTP-level ideas both servers implement. What changes in every single row is where the rule lives and when it’s read: Apache reads .htaccess on every request; NGINX reads central config once per reload. Switching servers mostly means relearning the filing system, not the web concepts.
Two honest footnotes: #4 isn’t a 1:1 translation â NGINX prefers try_files (does the file exist? else hand to the app) and treats if/rewrite as a last resort (see the famous “If Is Evil” community page). You rethink Apache rewrites into try_files rather than porting them. #7: directory listing is work to disable in Apache but is already off by default in NGINX.
Both servers use a near-identical .htpasswd-style file because the underlying scheme is the same web standard: HTTP Basic Authentication, RFC 7617. Apache: tape AuthType Basic + Require valid-user inside /admin. NGINX: add a location /admin/ { auth_basic ...; } block to central config, then nginx -s reload once. Same auth standard, different filing system.
08
File descriptors â the foundation
Before epoll makes sense, you need this brick. When you check a coat, they don’t pass the actual coat back and forth â they give you a numbered ticket, “#42.” From then on you say “42,” not “the navy wool one with the torn pocket.” The number is a handle â a tiny stand-in for a big complicated thing.
A file descriptor (FD) is that ticket, inside a program on Linux. And the surprising part: on Linux almost everything is treated like a “file” â not just disk files, but network connections, your keyboard, the screen, pipes between programs. Each time a program opens one, the OS hands back a small integer (0, 1, 2, 3…). That integer is the file descriptor.
THE COAT CHECK (analogy) --> YOUR PROGRAM (real) "here's ticket #42" --> open()/accept() returns FD 7 you hold ticket #42 --> program holds the integer 7 "fetch 42" (not "the navy coat") --> read(7) / write(7) attendant knows what #42 is --> kernel knows FD 7 = "TCP connection to 1.2.3.4"
So when a browser connects, NGINX gets back a file descriptor â say FD 7 â and that number now means “this visitor’s connection.” 10,000 visitors = 10,000 tickets = file descriptors 7, 8, 9, … up into the thousands.
“File descriptor” sounds like it’s only about files on disk. It isn’t â a network connection to a browser is also behind a file descriptor. That “everything is a file” design is why one mechanism can watch disk files and network connections alike.
09
epoll vs select/poll â watch many, pay for few
NGINX is holding thousands of file descriptors. The question is: which of them have something ready right now? How you answer that is the whole ballgame.
The naive way: walk the whole room (select / poll)
The waiter wants to know which tables need service, so he physically walks past every table, every round, asking “anything?” â including the 9,995 idle ones. He spends all his energy checking, not serving.
That’s literally select() / poll(): you hand the kernel your whole set of connections, it scans all of them, and you scan them again to find the ready ones. Cost grows with total connections â O(n) â on every call. (select also has a hard descriptor cap, commonly 1024.)
The smart way: a bell system (epoll)
Install a bell at every table. Tables ring when they need something. The kitchen hands the waiter a list of exactly which tables rang â nothing else. Idle tables cost zero attention. Add 5,000 more idle tables? His workload doesn’t change â only the ringing ones do.
That’s epoll, Linux’s I/O event-notification mechanism (added in Linux 2.6, ~2002). You register FDs once; the kernel maintains a ready list behind the scenes; epoll_wait returns just the active ones. Cost scales with busy connections, not total.
Watch it: idle FDs are free under epoll
epoll_wait hands back. Add a thousand more dark cells and the work below doesn’t change. (select/poll would have to scan all twelve â and all thousand â every call.)It’s tempting to credit NGINX’s speed to “it’s written in C” or “it’s just optimized.” That’s not the core. The core is the algorithmic difference between O(active) and O(total): a beautifully optimized select-based server still degrades as idle connections pile up, because its cost model is wrong. NGINX wins by changing the cost model.
NGINX doesn’t hard-code epoll â it auto-selects the best mechanism per OS:
| OS | Event mechanism |
|---|---|
| Linux | epoll |
| FreeBSD / macOS | kqueue |
| Solaris | /dev/poll, event ports |
| (fallback) | select / poll |
FD_SETSIZE (~1024) and whether NGINX defaults to epoll’s edge-triggered mode â both flagged rather than asserted.10
How epoll works â the kernel does the noticing
The crucial thing: epoll is passive. It is not running around poking each connection. The thing doing the noticing is the kernel (the OS itself), while NGINX is busy elsewhere.
When data comes back on a connection:
1. data packet arrives at the network card
2. the kernel's networking code: "this belongs to socket FD 7"
3. kernel drops the data in FD 7's receive buffer and
flips a flag: "FD 7 is now READY to read"
4. because FD 7 is registered with the epoll instance,
the kernel adds FD 7 to that instance's READY LIST
5. NGINX calls epoll_wait() --> kernel hands back: [ 7 ]
6. NGINX: "oh, FD 7 has data" and reads it
Steps 1–4 happen on their own while NGINX does nothing. By the time NGINX calls epoll_wait, the answer is already sitting there. NGINX doesn’t search â it collects. The three commands:
| Call | Plain English | When |
|---|---|---|
epoll_create1() | set up my bell-board | once |
epoll_ctl() | wire up / unwire a table’s bell (add/remove an FD) | connection opens or closes |
epoll_wait() | show me which bells are ringing right now | the hot loop |
epoll doesn’t get “attached” to connections â connections get registered onto the epoll instance via epoll_ctl(EPOLL_CTL_ADD). The board is the thing; FDs are added to it.
11
The complete request round trip
Now the payoff â one full request, browser knocks to browser answered. Notice both the browser’s connection (FD 7) and the backend’s connection (FD 51) live on the same epoll instance. One worker, one board, both directions. Press play to step through 1 → 7.
| Step | What happens |
|---|---|
| 1 | Browser knocks. Request lands on the NIC; this connection is FD 7. |
| 2 | Kernel notices, not NGINX. It buffers the data, flips FD 7’s ready flag, puts it on the epoll ready list. |
| 3 | epoll_wait collects. Returns [FD 7]; the worker reads the request. |
| 4 | Worker forwards upstream. Opens FD 51 to the backend, sends the request, then goes back to watching the board â it does not freeze. |
| 5 | Backend answers. Result arrives at the NIC; kernel marks FD 51 ready. |
| 6 | epoll_wait collects again. Returns [FD 51]; the worker reads the result. |
| 7 | Worker replies. Writes the response out to FD 7; the browser has its answer. |
The analogy stops here: FD 51’s traffic to the backend also re-enters the kernel and exits via the NIC â there’s no separate door. The sides are split only so both FDs are visible.
Step 5 hides a fork: if the backend is slow, the worker doesn’t wait at step 4 â it parks FD 51 on the board and serves everyone else. That “don’t block, register and move on” behaviour is non-blocking I/O, and it’s what keeps steps 4→6 from freezing the dining room.
12
“The board” = an epoll instance
The board is an epoll instance, created with epoll_create1(). The twist that should make you grin: the board is itself a file descriptor. Create one and the kernel hands you back… an FD referring to it. The bell-board gets its own ticket number, just like every connection.
Inside that one instance, the kernel keeps two lists (the real terms):
ONE epoll instance (one board) = one FD, holding two lists: +- epoll instance (itself FD 12) ---------------+ | INTEREST LIST (everything I'm watching) | | FD 7 FD 8 FD 9 ... FD 51 ... FD 4287 | | | | READY LIST (only what's ringing now) | | FD 7 FD 51 <- epoll_wait returns these +------------------------------------------------+
The interest list is everything you’ve registered via epoll_ctl (“all the tables whose bells I’m wired to watch”). The ready list is the subset ready right now (“the bells currently ringing”) â and epoll_wait pulls from that one.
13
Can there be many boards? Yes â one per worker
Sense 1 (trivial): nothing stops a program from calling epoll_create1() several times; each is an independent instance with its own interest/ready lists.
Sense 2 (the NGINX reality): NGINX runs as one master process + several worker processes, typically one worker per CPU core (worker_processes auto;). Each worker has its own epoll instance.
4 cores --> 4 workers --> 4 boards (NOT 4000 boards for 4000 connections)
Worker 1 (core 1) Worker 2 (core 2)
+- board A (FD) -+ +- board B (FD) -+
| FD 7, 9, 22... | | FD 8, 15, 51...|
+----------------+ +----------------+
Worker 3 (core 3) Worker 4 (core 4)
+- board C ------+ +- board D ------+
| FD 11, 30... | | FD 13, 41... |
+----------------+ +----------------+
The dining room has 4 waiters, one per section, each with their own bell-board watching only their tables. A given table’s bell is wired to exactly one waiter’s board â not all four.
Number of boards ≈ number of workers (≈ number of CPU cores) â NOT number of connections. Each connection is a ticket on exactly one board. Thousands of FDs, a handful of boards.
worker_processes and core count â auto ties it to cores, but an admin can override. Footnote: because the board is itself an FD, you can even nest one epoll inside another â real, occasionally used, not a beginner concern.14
Here be dragons â edges, wrinkles, open threads
The clean model above is true, but reality has texture. These are the known wrinkles, flagged honestly so you know where to dig later.
| Wrinkle | The short version | Confidence |
|---|---|---|
| The slow-disk asymmetry | Network sockets are non-blocking, but disk reads on Linux have historically been blocking in a way sockets aren’t. A giant file off a slow disk can stall a worker. This is why sendfile, aio, and thread pools exist in NGINX. | Concept high; verify specifics per version/OS |
| Non-blocking I/O | The reason a worker doesn’t freeze on a slow backend: it registers the FD and moves on, returning when the bell rings. The load-bearing behaviour behind the whole event loop. | High |
| Edge- vs level-triggered | epoll has two notification modes; edge-triggered fires only at the moment state changes. Fairly confident NGINX uses edge-triggered by default â treat as “verify against nginx.org” rather than settled. | Medium â flagged |
| Accept mutex / SO_REUSEPORT | When 1,000 connections arrive at once, how are they divided among the workers’ boards so one isn’t slammed while others nap? A real coordination fight over “who grabs the new guest at the door.” | Real mechanism; worth a focused read |
| FD limits | FDs are handed out as small integers â and a process can run out. The “open file descriptor limit” (ulimit -n) is a classic way a busy server falls over. Ties straight back to C10k. | High |
15
Quick-reference glossary
| Term | In one line | Restaurant analogy |
|---|---|---|
| NGINX | Front-door web server / reverse proxy | The maître d’ |
| C10k problem | Can one server handle 10,000 concurrent connections? | 10,000 guests, not enough waiters |
| Reverse proxy | Sits in front of app servers, forwards requests | Host routing guests to kitchens |
| Upstream / backend | Your actual application server | The kitchen / chefs |
| File descriptor (FD) | Small integer naming one open connection/file | The coat-check ticket |
| select / poll | Old O(n) readiness check â scans everything | Walking past every table |
| epoll | Linux O(active) readiness mechanism â ready list | The bell-board |
| epoll instance | The board itself; an FD holding interest + ready lists | One waiter’s bell-board |
| kqueue | epoll’s equivalent on BSD / macOS | A different brand of bell-board |
| worker process | One event loop, typically one per CPU core | One waiter per section |
| non-blocking I/O | Register and move on instead of waiting | Don’t stand frozen at one table |
| .htaccess | Apache per-directory config, read every request | House-rules card taped in a room |
16
Recall questions & diffuse-mode seeds
Active recall beats re-reading. Cover the answers and try these cold.
1. What specifically “ran out” first in the old thread-per-connection model, and what’s the one-sentence trick NGINX uses instead?
2. A developer on cheap shared hosting wants to password-protect one folder with no access to main config â which of Apache/NGINX/HAProxy lets them, and what’s the per-request cost?
3. When a browser connects, what does NGINX hold that represents it? Why don’t 5,000 idle new connections slow the worker â what is epoll not doing with their tickets?
4. In the round trip, between step 4 (request sent) and step 6 (result read), what is the worker doing? (Not standing at FD 51 tapping its foot.)
5. On an 8-core server with worker_processes auto;, roughly how many epoll boards exist â and does a new connection register onto all of them or just one?
• If NGINX’s superpower is not a worker per connection, what happens when one request asks it to do something genuinely slow and blocking â like reading a giant file off a slow disk? (→ the slow-disk asymmetry, sendfile/aio/thread pools.)
• Each worker has its own board and a connection sticks to one worker â so when 1,000 connections arrive in one second, how do they get divided so one worker isn’t slammed while others nap? (→ accept mutex / SO_REUSEPORT.)
• FDs are small integers handed out low â could a slammed server run out of numbers? (→ the open file-descriptor limit; the other face of C10k.)
• Apache re-reads .htaccess every request; NGINX reads config once per reload. Who would actually prefer the slower “re-read every time” behaviour â and why does that explain why .htaccess still exists?
§
References
A verification trail. Prefer tier-1 sources (the spec, official docs) over blogs.
| Topic | Source |
|---|---|
| The C10k problem | Dan Kegel, The C10K problem â kegel.com/c10k.html |
| NGINX architecture & history | The Architecture of Open Source Applications, Vol. II, “nginx” (Andrew Alexeev) â aosabook.org; “Inside NGINX” â nginx.com |
| NGINX official docs | nginx.org/en/docs â event methods, events/use, auth_basic, autoindex, proxy modules |
| “If Is Evil” / rewrite conversion | nginx.org converting rewrite rules |
| epoll API | Linux man pages: epoll(7), epoll_create(2), epoll_ctl(2) â man7.org |
| select / poll (O(n) behaviour) | Linux man pages: select(2), poll(2) â man7.org |
| File descriptors & “everything is a file” | Linux man pages: open(2); foundational Unix design â man7.org |
Apache .htaccess & MPMs | httpd.apache.org/docs/2.4/howto/htaccess.html; mpm.html |
| HTTP Basic Authentication | RFC 7617, The ‘Basic’ HTTP Authentication Scheme â rfc-editor.org |
FD_SETSIZE (~1024), whether NGINX defaults to edge-triggered epoll, and any specific syntax/flags. No memory figures or benchmarks were asserted; none were reliable.