2.4 Variables and map
Configuration that computes. NGINX has no if you should love (Lesson 11.2 explains why), but it has something better: variables with per-request values and map, the cleanest dispatch in town.
Basic
What you will learn
What an NGINX variable is — a lazily-evaluated per-request slot.
The core request variables, each observed live:
$host,$uri,$request_uri,$args/$arg_*,$scheme,$request_method,$remote_addr.set— creating your own (and the two places it's legal).map— derivation tables: syntax, matching modes, defaults, and three production idioms (including the WebSocket-upgrade classic).The variable gotchas that explain 90% of "why is it empty?".
1. What a variable is
A variable is
not a value stored in your config file. It is a
named slot whose value is computed
per request, at first use, by the module that owns it. Until something reads
$remote_addr, no address is resolved; when two locations never touch
$args, no argument parsing happens for them. Lazy evaluation is why a config with hundreds of variables costs nothing for the requests that don't use them.
Two consequences worth money:
Variables cannot be assigned from the outside world at config load.
$hostat parse time has no value; it materializes per request. (The compile-time world hasenvand command-line-gfor the few genuinely global values, like$MALLOC_ARENA_MAX-style knobs.)Reading order can matter for mutable pipeline variables —
$urichanges if arewriteran first (Module 11). Everything else is stable within a request.
The example config's
/whoami endpoint is a live probe — it returns nine variables as text. This is the actual output for
GET /whoami?foo=bar&x=1 with
Host: alpha.example.test:
$ curl -s -H 'Host: alpha.example.test' \
'http://127.0.0.1:8200/whoami?foo=bar&x=1'
host = alpha.example.test
uri = /whoami
request_uri = /whoami?foo=bar&x=1
args = foo=bar&x=1
arg_foo = bar
method = GET
remote_addr = 127.0.0.1
scheme = http
server_name = alpha.example.testNine lines, nine lessons. Let's take them in pairs.
2. The core variables, observed
$host vs
$server_name — where the request
is vs what you
configured
$host is the
request's view: the host name from the request line, or the
Host header (lowercased, port stripped), or the matched
server_name as a last resort — never the SNI name (that lives in
$ssl_server_name).
$server_name is the
config's view: the
server_name of whichever block won the Lesson 2.2 tournament.
# both equal here (Host matched the exact server_name):
host = alpha.example.test
server_name = alpha.example.testSend
Host: ALPHA.example.test and
$host lowercases to the same value. Send a host that matched the wildcard
.example.test and they diverge —
$host carries what the client
asked for (that's why the wildcard demo prints
host=random.example.test), while
$server_name prints the configured pattern.
Rule of thumb: proxy
$host
downstream (Module 4); log
$server_name
for config auditing.
$uri vs
$request_uri — normalized vs verbatim
$request_uri= the raw request line URI — path plus query string, exactly as the client sent it, percent-escapes intact, never modified by anything.$uri= the decoded, current path — escapes resolved,../., normalized, and rewritten if rewrite directives ran.
uri = /whoami # decoded path, no args
request_uri = /whoami?foo=bar&x=1 # verbatim, args includedCache keys want
$request_uri (stability). Location matching and file paths want
$uri (normalization). Mixing them up is the #1 source of "my cache serves the same page for different query strings" bugs (Module 8).
$args and
$arg_<name> — the query string
$args is the whole string after
?. Each
$arg_name extracts one parameter (case-sensitive name, first occurrence, URL-decoded):
args = foo=bar&x=1
arg_foo = barThese are how you build "one endpoint, parameterized behavior" without any rewriting — the
/env?env=prod in §4 is the pattern in miniature.
$scheme,
$request_method,
$remote_addr
http/
https (known
after TLS termination — Module 6), the method, and the peer IP as seen by NGINX (which is
not the client IP once a proxy chain exists —
$realip_remote_addr and Module 6's
real_ip fix that).
3.
set — your own variables
location /set-demo/ {
set $tier "free";
if ($arg_pro = 1) { set $tier "pro"; } # ( Module 11 will delete this if )
return 200 "tier=$tier\n";
}
set is a rewrite-phase directive (that's why it belongs to Module 11's phase story) usable in
server,
location, and
if contexts. Its value is a string — computed once per request when the rewrite phase reaches it. Two laws:
Declare before use in the config text — nginx resolves variable names to indexes as it parses, top to bottom: a name that is neither declared earlier (by
map,geo,set, or a module’s own variables) nor one of the prefix families ($http_*,$arg_*,$cookie_*) fails the whole config withunknown "tier" variable— whether the use is string interpolation (return,proxy_pass) or a bare argument (limit_req_zone $key,proxy_cache_key). Only the values are late: they materialize per request (Lesson 2.6 shows the parse rule live, with a file rename).A
setin the server block runs before location choice; asetin the winning location overwrites it. One request, one timeline (Lesson 11.1 makes the whole timeline explicit).
4.
map — the branching brain
map creates a
new variable from an
existing one via a lookup table, evaluated at http level, on first use per request. It is NGINX's answer to "I need a conditional" — without a runtime
if.
# map <source> <destination>
map $arg_env $env_label {
prod production; # literal source value
staging staging;
dev development;
default unknown; # when nothing matched
}Live, from a real request:
$ curl -s -H 'Host: alpha.example.test' 'http://127.0.0.1:8200/env?env=prod'
arg_env = prod
mapped = production
$ curl -s -H 'Host: alpha.example.test' 'http://127.0.0.1:8200/env?env=nonsense'
arg_env = nonsense
mapped = unknownThe full syntax card:
map $source $dest {
default fallback; # required in spirit; "unknown var" otherwise
~^/api/v\d+/ api-versioned; # regex values (~ and ~* allowed)
"/exact string" quoted; # quoted literals for spaces
include snippet/*.map; # yes, maps can include
hostnames; # enable wildcard domain matching:
*.example.test internal; # *.example.test
example.test internal; # exact + implied subdomains
default external;
}Three production idioms
Idiom 1 — the WebSocket upgrade header. The single most-copied NGINX snippet on earth, and now you can read it:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close; # empty header (normal requests) -> close
}
# later (Module 4):
proxy_set_header Connection $connection_upgrade;Observed directly at the
/conn-upgrade probe:
$ curl -s -H 'Host: alpha.example.test' http://127.0.0.1:8200/conn-upgrade
connection_upgrade = close
$ curl -s -H 'Host: alpha.example.test' -H 'Upgrade: websocket' \
http://127.0.0.1:8200/conn-upgrade
connection_upgrade = upgradeWhy a map? Because
Connection must be
Upgrade only for websocket traffic and
close otherwise — a
derivation, not a constant. With a
map it's declarative and testable; with an
if it's a rewrite-phase hazard (Module 11's cautionary tale).
Idiom 2 — traffic classification. A third map in the example config classifies hosts with the
hostnames mode:
map $host $site_class {
hostnames;
*.example.test internal;
default external;
}
Idiom 3 — per-segment policy. The same shape powers canary releases (
map $cookie_version $pool { default stable; canary canary-pool; }, Module 5), cache TTLs by file class (Module 8), and TLS redirect exceptions (
map $uri $redirect_required { ~^/healthz 0; default 1; }). Once you see "policy differs by X", the answer is
map $X $policy.
5. The "why is my variable empty?" checklist
It was never read. Lazy evaluation:
$upstream_addris empty in a location that doesn't proxy. Print probes like/whoamito see truth.Phase too early. Variables owned by later phases (
$upstream_*,$sent_http_*) are empty before the content phase — e.g. inside rewrite-phaseifconditions orlog-early locations.Scope shadowing. A
setin the winning location overwrote the server-level one; or amapdefault silently fired.Header spelling.
$http_user_agentnot$http_useragent; underscores, lowercase, always.It's per-worker. Anything you
setlives in one worker's request only — cross-worker state needs a shared zone (Lesson 1.4 §5).
Try it yourself
Probe requests of your own design.
/whoamiis a microscope — aim it:
curl -s -H 'Host: alpha.example.test' \
'http://127.0.0.1:8200/whoami?a=1&a=2&X=3&space=a%20b'Predict first: which
arg_a wins? Is
arg_X (uppercase) found? What does
arg_space show?
Map surgery. Add a branch to
$env_label(qa -> quality-assurance),nginx -s reload, test it. Then break thedefaultline and watch unmatched inputs return an empty string — the checklist's #3, live.Watch divergence.
$hostvs$server_nameon the wildcard vhost:
curl -s -H 'Host: random.example.test' http://127.0.0.1:8200/class(That endpoint is on the default server too — try
Host: unknown.host.xyz and see
site_class flip to
external.)
Regex map values. Add
~^/api/v(\d+)/ api-v$1;to a new map and probe it. (Captures in map regexes become$1— yes, really.)
Q&A
Q1. Why is
$request_uri the recommended cache-key component but
$uri the recommended
location/
root component?
Answer
Cache keys must distinguish requests the client made differently (
/a?x=1 vs
/a?x=2) and survive rewrites unchanged → verbatim
$request_uri. Filesystem mapping needs the
current, decoded path →
$uri. One is identity; the other is a filesystem address.
Q2. A map with no
default and unmatched input — what does
$destination contain?
Answer
An empty string. Not an error, not the last branch — empty. Logs fill with mysteriously blank fields; policies built on the variable silently take their "else" paths. Always write the
default.
Q3. Write the one-line idiom: "add header
X-Env: prod only for requests whose
$arg_env is
prod, nothing otherwise".
Answer
map $arg_env $env_header { prod "prod"; default ""; } then
add_header X-Env $env_header; — an
empty value makes
add_header omit the header entirely. (This empty-value-omits trick is also the standard way to make optional headers.)
Q4.
$http_upgrade is empty for regular requests. What does
$connection_upgrade evaluate to, and why is that the
desired behavior?
Answer
close — the map's
'' branch. Proxied
Connection headers must not advertise
Upgrade for non-upgrade traffic, or keep-alive pools (Module 5) misbehave. The map encodes the correct protocol behavior, per request, without any
if.
Takeaways
Variables are lazy, per-request slots owned by modules — reading them is what computes them.
$host/$server_name= request view vs config view;$uri= decoded-and-possibly-rewritten path;$request_uri= verbatim identity;$args/$arg_*= query access;$http_*/$sent_http_*= headers.setcreates your own; mind declaration-before-parse-time-use and the rewrite-phase timeline.mapis the config's branching brain: derivation tables with literal, regex, andhostnamesmodes; always write thedefault; three idioms (upgrade header, classification, per-segment policy) cover most needs."Empty variable" has five causes — lazy evaluation, phase, shadowing, spelling, per-worker scope — and now you have the checklist.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.