NGINX Variables: Where $var Silently Doesn't Work

Many NGINX directives expand variables, so it is natural to assume they all do. In the places below NGINX takes $name as plain text, and nginx -t says nothing.

Updated: October 2026 * 8 min read

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 writeWhat NGINX does
${BACKEND_HOST} from a templateNGINX 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.comAnswers only a Host header containing $tenant.
rewrite ^/$old/(.*)$ ..., if ($uri ~ $re)Dead anchor again. The rewrite or if never fires.
geo and split_clients valuesStored as the literal string.
auth_request /auth/$hostThe subrequest goes to the literal URI /auth/$host.
error_log /var/log/nginx/$host.logWrites to a file literally named $host.log.
*_temp_path, *_cache_pathCreates a directory with $ in its name.
map keys, header names, default_type, real_ip_header, valid_referersLiteral 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 $ in geo or split_clients values; expand in a chained map
  • No variables in location, server_name, rewrite or if patterns
  • A fixed auth_request URI
  • Fixed error_log, *_temp_path and *_cache_path paths
  • Templates rendered before NGINX starts, and no ${...} left in the final config

Further Reading