Config Formats

Engineering Nginx Configuration: Layout and Organization

How to keep nginx.conf from becoming a 3000-line monolith: include boundaries, map and geo usage, and locking location priority down with tests.

By LaoHand Team·9 min read·Updated 2026-09-30

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

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.