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