Why include Splitting Is Mandatory
The main config should only carry the global skeleton: worker counts, the event model, log formats, and includes pointing at functional fragments. Writing sites directly into the main file makes every change a full-file diff, drives review cost up linearly, and makes rollback granularity unusable.
Split by responsibility, not by line count: one file for common HTTP settings, one for upstream pools, one per server block, and within each server, further splits per concern (rate limiting, static asset caching, real IP). Adding a site then means adding a file plus one include line.
Note that include paths resolve against `prefix` (usually `/etc/nginx`), not the directory of the current file — the most common stumbling block. Standardize on absolute paths across the repo and document the layout in a README.
# /etc/nginx/nginx.conf 主配置只留骨架
user nginx;
worker_processes auto;
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main "$remote_addr - $request_uri $status $body_bytes_sent $request_time";
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# map 必须定义在 http 层级,才能被 server/location 引用
include /etc/nginx/conf.d/maps/*.conf;
include /etc/nginx/conf.d/upstreams/*.conf;
include /etc/nginx/conf.d/servers/*.conf;
}Location Matching Priority Belongs in the Docs
Location matching order is the hardest part to reason about: exact `=` matches first, then the longest prefix match (within that prefix, regexes apply in declaration order unless `^~` short-circuits them), and finally regex-then-prefix fallbacks. This ordering must be frozen into a table in the docs.
The classic mistake is mixing `^~` and regex in the same scope. `^~` on a static asset directory is correct because it prevents regexes from interfering, but if a regex like `location ~ \.php$` sits at the same level, hit order becomes unpredictable. The safe rule: `^~` for static assets, regex only for dynamic requests.
Turn the priority into executable assertions. Probe each path class with `curl -I` and assert the response headers, then use `nginx -T` to dump the fully expanded configuration and confirm every include took effect with no duplicate definitions.
# 静态资源:^~ 终止正则匹配,避免被 location ~ .* 抢走
location ^~ /static/ {
root /var/www/app;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# 精确匹配:优先级最高
location = /healthz {
default_type application/json;
return 200 "{\"status\":\"ok\"}";
}
# 正则匹配:只处理动态请求
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# 兜底反代:只有上面都没命中才会到这里
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}map and geo: Turning Repetitive if Blocks into Declarative Rules
`map` defines an input-to-output lookup table at the http level. It typically turns a User-Agent into a rate-limit key, normalizes a domain to an upstream name, or maps legacy parameter names to new ones. It replaces long chains of `if ($http_user_agent ~ ...)`, and because it supports a `default` value it never falls through silently.
`geo` handles IP geolocation: `geo $variable { default 0; 1.0.0.0/8 1; ... }`, after which `if ($is_cn)` works inside a server block. Both directives support `include`, so CIDR lists and UA rules live in their own files and can even be generated from a GeoIP database.
Caveats: the first argument of `map` may be a variable, a string or a regex, but historically it cannot be a complex expression relying on captures (limitations around things like `$http_x_forwarded_for`). Keep large CIDR lists in a separate file so the main config stays readable.
# conf.d/maps/limit-key.conf —— http 层级
map $http_user_agent $limit_key {
default "other";
"~*bot|crawler|spider" "crawler";
"~*mobile|android|iphone" "mobile";
"" "nobot";
}
map $host $upstream_name {
default "app_backend";
api.example.com "api_backend";
admin.example.com "admin_backend";
}
# conf.d/maps/geo.conf —— CIDR 列表放独立文件
geo $is_blocked_region {
default 0;
include /etc/nginx/geo/blocked.cidr;
}
server {
listen 80;
server_name example.com;
if ($is_blocked_region) {
return 403;
}
location /api/ {
limit_req zone=perip burst=20 nodelay;
proxy_pass http://$upstream_name;
}
}Validation and a Security Baseline
Every config change must pass `nginx -t` before a reload — never a direct restart. `-t` parses all includes and validates syntax and directive legality; paired with `-T` it dumps the expanded config so you can diff what actually takes effect. Remember `-t` is static only and does not verify upstream reachability.
Two baseline items are most often missed: hiding the version with `server_tokens off;` and setting `limit_req`/`limit_conn` plus `client_max_body_size` explicitly — the default 1MB upload cap tends to surface as a surprise the day a new feature ships. For TLS, enable HSTS and drop weak protocols (`ssl_protocols TLSv1.2 TLSv1.3`).
Finally wire validation into CI: keep a template (template + env vars → final config) in the repo, render it in CI, run `nginx -t`, and only then allow deployment. Environment differences can no longer drift into production, and a local nginx version mismatch with production stops being a hidden compatibility hazard.
http {
server_tokens off;
client_max_body_size 20m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
# CI 校验脚本片段
set -euo pipefail
envsubst < templates/nginx.conf.tpl > build/nginx.conf
nginx -t -c "$PWD/build/nginx.conf"
nginx -T -c "$PWD/build/nginx.conf" | grep -E "server_name|location" > build/nginx.effective.txt