You render an nginx upstream list from Consul with consul-template and reload nginx from the template's `command`. After an incident the rendered file came out with an empty upstream and every request failed. Walk through consul-template's render-and-reload cycle, and how you would make it safe.
answer
- a blocking query per dependency
- the file is a pure function of the answer
- zero results is a legal answer
- validate before the reload runs
- coalesce a flapping catalog
basics
~20 sconsul-template holds a blocking query per dependency in the template, re-renders when one changes, writes the file atomically and then runs the command. It will happily render an empty list if Consul answers with zero results, so guard the render, validate the config, and only then reload.
solid answer
~60 sThe cycle is: for every `service` or `key` call in the template, consul-template opens a long poll; when any of them advances it re-renders in memory, compares the result with what is on disk, and only if it differs writes the file (to a temp file, then renamed into place) and runs `command`. The important detail for your incident is that a Consul *outage* does not produce an empty file — consul-template retries and keeps the last render. An empty upstream means Consul **answered**, with zero results: every instance failed its health check, or the token lost read access, or the filter in `service "web|passing"` matched nothing. The template did exactly what you told it to. The fixes are layered: guard the render so a zero-length result aborts rather than writes; set `error_on_missing_key` so a missing key fails instead of rendering empty; point `command` at a wrapper that runs `nginx -t` and reloads only if the config validates; and set `wait` so a flapping catalog coalesces into one reload instead of fifty.
code
hcl · 24 linesconsul {
address = "127.0.0.1:8500"
retry {
enabled = true
attempts = 0
backoff = "250ms"
max_backoff = "1m"
}
}
template {
source = "/etc/consul-template/upstreams.ctmpl"
destination = "/etc/nginx/conf.d/upstreams.conf"
command = "/usr/local/bin/reload-nginx"
command_timeout = "30s"
perms = 0644
backup = true
error_on_missing_key = true
wait {
min = "2s"
max = "10s"
}
}go deeper
Know that consul-template watches Consul, rewrites a config file when the data changes, and then runs a command such as a proxy reload — so the file on disk is generated, never hand-edited.
Explain the cycle precisely: a blocking query per dependency, re-render, compare with disk, atomic write, then command. Name the template options that matter — wait, command_timeout, error_on_missing_key, backup.
Reason about the failure honestly: an empty render means Consul answered with nothing, not that it was down. Show the layered defence — guard the render, validate before reload, damp the churn — and how you would test a template change before shipping it.
Own the choice of delivery mechanism across the fleet — render-and-reload, DNS resolution, or a runtime API — and the standards that keep generated config safe: templates reviewed as code, validation in the reload path, and an alert when a render or command fails.
## What the process is actually doing consul-template parses your template and extracts its *dependencies* — each `{{ service "web" }}`, `{{ key "config/app/mode" }}`, `{{ ls }}` or `{{ tree }}` call becomes a watched query. It opens a blocking query per dependency and keeps them open. When any query returns a new index, it re-renders the entire template in memory against the current view, compares the bytes with the existing destination file, and: - if identical, does nothing — no write, no command, which is why a wake-up on an unrelated change is harmless; - if different, writes the new content (to a temporary file in the destination directory, then renames it into place so a reader never sees a half-written config) and executes the template's `command`, subject to `command_timeout`. ```hcl template { source = "/etc/consul-template/nginx-upstreams.ctmpl" destination = "/etc/nginx/conf.d/upstreams.conf" command = "/usr/local/bin/reload-nginx" perms = 0644 backup = true error_on_missing_key = true wait { min = "2s" max = "10s" } } ``` ## Diagnosing the empty render The instinct is "Consul went down and the template rendered nothing". That is not how it behaves. If the agent is unreachable, the queries fail, consul-template retries with backoff and the destination file is left exactly as it was — stale, but serving. An empty list on disk therefore means the query *succeeded* and legitimately returned zero entries. The realistic causes: - **Every instance became unhealthy.** `{{ service "web" }}` returns only passing instances by default, so a bad check definition, a dependency outage or a deploy that failed its readiness gate empties the set. This is health checking doing its job and templating faithfully reporting it. - **The ACL token lost access.** A token whose policy no longer grants read on that service or KV prefix yields empty results rather than an obvious error in your rendered output. - **A filter that matches nothing.** A tag filter such as `service "canary.web"` renders empty the moment the tag disappears. The general lesson: **the template is a pure function of what Consul says, and "nothing" is a legal input.** Safety has to be written into the template and the reload path, not assumed. ## Making the render safe First, refuse to render nothing. Capture the result, check its length, and do not emit an empty block: ``` {{- $backends := service "web|passing" -}} {{- if gt (len $backends) 0 -}} upstream web { {{ range $backends }}server {{ .Address }}:{{ .Port }}; {{ end }} } {{- else -}} {{ scratch.Set "abort" true }} {{- end -}} ``` The cleanest version of this makes the *whole render* fail, so consul-template keeps the previous file rather than writing a valid-but-empty one. `error_on_missing_key = true` gives you the same property for KV lookups: an absent key aborts the render instead of interpolating an empty string into the middle of a directive. Second, never let an unvalidated file become live config. Point `command` at a small wrapper rather than directly at the reload: ```bash #!/usr/bin/env bash set -euo pipefail nginx -t # config test; non-zero exit aborts here nginx -s reload ``` Because a failing `command` is visible in consul-template's logs, a bad render becomes an alert instead of an outage. Combined with `backup = true` you also keep the previous file on disk for a manual fallback. ## Damping the churn `wait { min = "2s" max = "10s" }` is a quiescence timer: after the first change, consul-template waits for `min` of calm before rendering, and renders regardless after `max`. Without it, a rolling deploy that flaps twenty instances through the catalog triggers twenty renders and twenty reloads in a few seconds — each reload spawning a new worker generation while the old ones drain, which is how a routine deploy turns into a memory and connection spike. ## Testing it before it is live `consul-template -once` renders a single pass and exits, which is what you run in CI against a fixture Consul; `-dry` prints the rendered result to stdout instead of writing it, so you can diff a template change against real data without touching a file. A template change deserves the same review as a code change, because it *is* the code that produces your production routing table. ## Where the boundary sits Templating is one of three ways to get discovery data into a proxy, and it is worth knowing why you picked it. Rendering plus reload gives you the proxy's full configuration language at the cost of a reload on every change. Resolving service names through Consul's DNS interface avoids reloads but gives up per-instance metadata and inherits resolver caching behaviour. A runtime API — a socket that mutates server state in place without rewriting the config file — avoids both, where the proxy offers one. Templating is the general answer that works with any proxy, and its price is exactly the failure mode above: the file is authoritative, and a bad file is instantly authoritative too. envconsul is the same machinery aimed at process environment rather than files: it populates environment variables from KV and starts a child process, restarting or signalling it when values change — useful for applications that read config only at startup and cannot be taught to watch anything.
- If Consul itself is unreachable, what does the rendered file look like?Unchanged. Failed queries are retried with backoff and consul-template keeps serving the last successful render, so the proxy carries on with a possibly stale but valid backend list. That is why an empty file is diagnostic: it means Consul answered with an empty set — unhealthy instances, a token that lost read access, or a filter matching nothing — rather than being unavailable.
- Why does the wait stanza matter during a rolling deploy?Each catalog change would otherwise trigger its own render and reload. Twenty instances cycling through registration produce twenty reloads in seconds, each spawning a fresh worker generation while old ones drain, which spikes memory and connection counts. The wait stanza sets a quiescence window — render after min seconds of calm, and no later than max — so a burst of churn collapses into one or two reloads.
- How would you test a template change before it reaches production?Render it out of band: -dry prints the result to stdout without writing, and -once does a single pass and exits, which is what a CI job runs against a fixture Consul. Diff the output against the current production file and run the proxy's own config test over it. A template is the code that generates your routing table and deserves review and a test like any other.
- When would you not use consul-template for this at all?When the reload cost dominates the benefit — very high catalog churn, or long-lived connections a reload disrupts. The alternatives are resolving service names through Consul's DNS interface, which avoids reloads but loses per-instance metadata and inherits resolver caching, or a proxy runtime API that mutates server state in place. Templating stays the general answer because it works with any proxy's full config language.
saying these in an interview costs you the question
- Blames a Consul outage for a file rendered with zero backends
- Reloads the proxy without validating the rendered config first
- Leaves no guard against a template rendering an empty list
- Reloads on every catalog change with no quiescence window
- Edits the rendered file by hand and is surprised it reverts