A Rate Limit That Limited Everyone
This configuration looks like a per-client rate limit with an allowlist. It ran in production for months:
geo $limit_key {
default $binary_remote_addr; # stored as text, never expanded
127.0.0.1 "";
}
limit_req_zone $limit_key zone=dynamic:10m rate=5r/s;
Unlike map, the geo module never expands variables in its values. Every visitor outside the allowlist got the same key, the 19-character string $binary_remote_addr, so they all shared one 5 r/s bucket. Whenever a crawler sent a burst, real visitors got 503 responses. We reproduced it on NGINX 1.31.8: a second client's very first request was rejected.
The fix is the allowlisting pattern from the NGINX rate limiting guide: geo only classifies, and a chained map expands the variable.
geo $limit {
default 1;
127.0.0.1 0;
10.0.0.0/8 0;
}
map $limit $limit_key {
0 ""; # empty key: not limited
1 $binary_remote_addr; # map values do expand
}
limit_req_zone $limit_key zone=dynamic:10m rate=5r/s;
The Short List
Every entry below was tested on NGINX 1.31.8. Each one passes nginx -t without a warning.
| You write | What NGINX does |
|---|---|
${BACKEND_HOST} from a template | NGINX never reads environment variables. The template was not rendered. |
location /$prefix/ | Matches only URIs that contain the text $prefix. |
location ~ ^/$prefix/ | $ is the end-of-string anchor. The regex can never match. |
server_name $tenant.example.com | Answers only a Host header containing $tenant. |
rewrite ^/$old/(.*)$ ..., if ($uri ~ $re) | Dead anchor again. The rewrite or if never fires. |
geo and split_clients values | Stored as the literal string. |
auth_request /auth/$host | The subrequest goes to the literal URI /auth/$host. |
error_log /var/log/nginx/$host.log | Writes to a file literally named $host.log. |
*_temp_path, *_cache_path | Creates a directory with $ in its name. |
map keys, header names, default_type, real_ip_header, valid_referers | Literal text: a key that never matches, a header named $n, Content-Type: $p. |
Environment Variables
This is the most common confusion of all. The top Stack Overflow question, nginx: use environment variables, has over 150,000 views. NGINX has no syntax for reading the environment in its configuration. A placeholder like ${BACKEND_HOST} has to be replaced before NGINX starts.
The official Docker image does this for you. Put the file in /etc/nginx/templates/ with a .template suffix, and the entrypoint runs envsubst into /etc/nginx/conf.d/:
# /etc/nginx/templates/default.conf.template
server {
listen ${NGINX_PORT};
location / {
proxy_pass http://${BACKEND_HOST}:8080;
}
}
If the file ends up in conf.d/ directly, the placeholder reaches NGINX unrendered. Where variables expand, nginx -t fails with "unknown variable". Everywhere else on this page the text is used as it is. NGINX variable names are case-insensitive, so ${HOST} silently becomes $host instead of failing.
location and server_name
Using variables in Nginx location rules has over 230,000 views. NGINX builds its location tree and server-name tables once, when it loads the configuration, so there is no request to take a variable's value from.
set $admin admin;
location /$admin/ { deny all; } # matches only "/$admin/"
location ~ ^/$admin/ { deny all; } # never matches; the deny is skipped
server_name $tenant.example.com; # matches Host "$tenant.example.com"
The regex form is the dangerous one. $ means "end of string", and nothing can follow the end, so the block silently never applies. When that block holds a deny, your access control is gone. Write the value out, or use a wildcard or regex server name:
location ~ ^/admin/ { deny all; }
server_name ~^(?<tenant>[a-z0-9-]+)\.example\.com$;
A regex can still match a dollar sign written as \$ or [$]. The one variable server_name understands is the special name $hostname, the machine's host name.
rewrite and if Regexes
The same anchor rule applies to rewrite and to if with ~, ~*, !~ or !~*. if ($uri ~ $blocked) compiles the text $blocked as the pattern, so it never matches. To compare against a list of patterns, use map, whose regex keys run at request time:
map $uri $is_blocked {
default 0;
~^/private 1;
~^/internal 1;
}
server {
if ($is_blocked) { return 403; }
}
auth_request
auth_request /auth/$host; sends the subrequest to the literal URI /auth/$host. If any location answers that URI with a 2xx status, a catch-all for example, every request is authorized. Use a fixed URI and pass request data to the auth location in headers:
location / {
auth_request /auth;
}
location = /auth {
internal;
proxy_set_header X-Original-Host $host;
proxy_set_header X-Original-URI $request_uri;
proxy_pass http://auth-backend;
}
error_log and Cache Paths
access_log accepts variables, so error_log looks like it should too. It does not. It has to keep working even when NGINX cannot allocate memory, so it takes a fixed path (trac #57). error_log /var/log/nginx/$host.error.log; creates one file literally named $host.error.log. The same goes for proxy_cache_path, client_body_temp_path and the other *_temp_path and *_cache_path directives. Give each server its own fixed path instead.
What Does Expand
These accept variables: map values, add_header and proxy_set_header values, access_log, root, alias, try_files, proxy_pass, return, the limit_req_zone key, limit_rate (since 1.17.0) and ssl_certificate (since 1.15.9). A variable in proxy_pass brings its own trap: it needs a resolver, covered in NGINX Resolver Security. include $file and limit_req zone=$name fail loudly at nginx -t, so they cannot ship by accident.
Catch Them Automatically
Gixy's non_expanding_variable check, added in 0.2.60, reports every case on this page. It follows map chains to tell you when a geo literal ends up as a limit_req_zone key, and it ignores escaped dollars, character classes and variables that a set or map defines.
pip install --upgrade gixy-ng
gixy --tests non_expanding_variable /etc/nginx/nginx.conf
Tip: run Gixy against the rendered configuration (nginx -T output), not the template, so unrendered placeholders show up where they would reach NGINX.
Checklist
- No
$ingeoorsplit_clientsvalues; expand in a chainedmap - No variables in
location,server_name,rewriteorifpatterns - A fixed
auth_requestURI - Fixed
error_log,*_temp_pathand*_cache_pathpaths - Templates rendered before NGINX starts, and no
${...}left in the final config