Nginx configuration is code. It has logic, edge cases, and bugs. But unlike most code, it almost never has tests. The usual workflow is: edit nginx.conf, reload, click around, ship it. When production breaks, you find out then.
In my homelab I run a static website behind nginx with several non-trivial features: a CDN with aggressive caching, theme switching via sub_filter and cookies, TLS termination, and www. redirect logic. I kept breaking one thing when changing another. So I wrote tests. This is what I learned.
You don't need to run nginx to test most of what matters. The config file is a text file. You can open it, read it, and assert things about it. Is the directive present? Is the location block in the right order? Does the Jinja2 template render the right output for a given set of variables?
This is the approach: read config files as strings, assert on their content with pytest. No Docker, no running process, no ports, no integration environment needed for the bulk of the tests.
These are not hypothetical. Each incident happened and went unnoticed until something broke in production:
charset_types directive. Nginx stopped appending ; charset=utf-8 to the CSS Content-Type header. The CDN cached the response without a charset; some clients failed to interpret the UTF-8 content correctly.sub_filter target after a rename: renamed style.css → style-mono.css; the sub_filter directive still targeted the old filename; theme switching silently stopped working for all visitors with a cached cookie.location ~* block above the static assets block caused it to match .css files first, skipping the cache headers entirely.Set-Cookie at server level: during a refactor, the cookie-setting logic was left outside location /; every static asset response started setting a cookie, which prevented the CDN from caching any of them.All four were caught by text-based tests before the next deploy — no nginx process, no Docker, no network.
Install pytest and Jinja2 (for Ansible templates), then declare your config file paths once at the top of the test file:
pip install pytest jinja2
from pathlib import Path
REPO = Path(__file__).parent.parent
NGINX_DEFAULT = REPO / "docker/website/nginx-default.conf.j2"
NGINX_THEME_MAP = REPO / "docker/website/nginx-theme-map.conf"
NGINX_PROXY_CONF = REPO / "docker/website/nginx-proxy.conf.j2"
def test_nginx_default_conf_exists():
assert NGINX_DEFAULT.exists()
def test_nginx_theme_map_exists():
assert NGINX_THEME_MAP.exists()
Catching a missing file in CI beats a failed deploy at 2am. This is also the only place you'll ever need to update if a file moves.
Read the file, assert a string is in it. This covers about 80% of the test suite. Be specific enough that a deletion breaks the test, but not so brittle that cosmetic whitespace changes do.
class TestNginxCaching:
def test_static_location_sets_max_age_30_days(self):
assert "max-age=2592000" in NGINX_DEFAULT.read_text()
def test_static_location_sets_immutable(self):
assert "immutable" in NGINX_DEFAULT.read_text()
def test_charset_utf8_directive_present(self):
assert "charset utf-8;" in NGINX_DEFAULT.read_text()
def test_sub_filter_once_off(self):
assert "sub_filter_once off" in NGINX_DEFAULT.read_text()
The charset test is what would have caught the first bug above. A directive that takes one line to remove takes one test to protect.
Nginx selects location blocks in a defined priority order: exact match (=), then preferential prefix (^~), then regex (~ and ~*), then longest prefix, then the default /. Regex locations always beat a plain location / prefix — regardless of where they appear in the file. File order only matters when multiple regex blocks could match the same request: nginx picks the first one listed.
So the test below is not a correctness guard (the regex block wins over location / either way), but a convention guard. When you have several regex blocks — one for static assets, one for API paths — their relative order determines which one fires for ambiguous URLs. Enforcing a canonical order keeps that logic stable as the file grows:
def test_static_location_before_root_location(self):
content = NGINX_DEFAULT.read_text()
static_pos = content.index("location ~*")
root_pos = content.index("location /")
assert static_pos < root_pos
The test also documents intent: the static assets block is meant to be evaluated first. It won't catch a new regex block inserted above it that accidentally matches .css files — but it does catch the common mistake of appending a new block at the end of the file without thinking about order, which is what caused the third bug above.
Some constraints are about absence. Set-Cookie must only appear in location /, not in the static assets block — otherwise every image and CSS file sets a cookie, which prevents the CDN from caching any of them:
def test_set_cookie_not_in_static_location(self):
content = NGINX_DEFAULT.read_text()
static_start = content.index("location ~*")
root_start = content.index("location /", static_start)
static_block = content[static_start:root_start]
assert "Set-Cookie" not in static_block
The same pattern catches Cache-Control leaking into the HTML block, where it should not appear:
def test_cache_header_not_in_root_location(self):
content = NGINX_DEFAULT.read_text()
root_pos = content.index("location /")
root_block = content[root_pos:]
assert "Cache-Control" not in root_block
Ansible templates are Jinja2 files. Rather than testing the raw template syntax, render the template with different variable sets and assert on the output. This lets you test multiple scenarios from a single template — with and without a www. alias, for example:
from jinja2 import Environment as JinjaEnv
def _render_proxy(extra_domains: list[str]) -> str:
source = NGINX_PROXY_CONF.read_text()
return (
JinjaEnv()
.from_string(source)
.render(
website_domain="benni.tech",
website_extra_domains=extra_domains,
)
)
class TestWebsiteProxyWwwRedirect:
def test_with_www_redirects_to_bare_domain(self):
rendered = _render_proxy(["www.benni.tech"])
assert "https://benni.tech" in rendered
def test_with_www_redirect_is_301(self):
rendered = _render_proxy(["www.benni.tech"])
lines = [ln for ln in rendered.splitlines()
if "www.benni.tech" in ln or "301" in ln]
assert any("301" in ln for ln in lines)
def test_without_extra_domains_no_www_block(self):
rendered = _render_proxy([])
assert "www.benni.tech" not in rendered
You can test every branch of a Jinja2 conditional without deploying anything.
Some invariants span multiple files. My nginx config uses sub_filter to rewrite CSS filenames for theme switching. The filename in the sub_filter directive must exactly match the filename referenced in the HTML <link> tags — and these live in different files. This test enforces that agreement:
def test_sub_filter_targets_style_clean_css(self):
# Must match the filename used in HTML <link> tags
assert "sub_filter '/style-clean.css'" in NGINX_DEFAULT.read_text()
def test_theme_map_default_is_clean(self):
assert "default /style-clean.css;" in NGINX_THEME_MAP.read_text()
This is what catches the second bug above. When I renamed style.css to style-mono.css, the sub_filter target was updated but the HTML templates were not. The mismatch was invisible until a visitor with a stale cookie reported that theme switching did nothing. The consistency test would have caught it at commit time.
$ pytest tests/test_website.py -v
tests/test_website.py::TestNginxCaching::test_charset_utf8_directive_present PASSED
tests/test_website.py::TestNginxCaching::test_static_location_before_root_location PASSED
tests/test_website.py::TestNginxCaching::test_set_cookie_not_in_static_location PASSED
tests/test_website.py::TestWebsiteProxyWwwRedirect::test_with_www_redirect_is_301 PASSED
...
172 passed in 0.31s
172 tests in 0.31 seconds. No Docker, no nginx process, no network. They run in CI on every commit.
Text-based tests cannot verify that nginx actually serves the right response, that TLS terminates correctly, or that the CDN behaves end-to-end. For that you need integration tests against a real nginx instance or smoke tests against production. But text-based tests catch the structural mistakes — wrong file, missing directive, wrong order, wrong filename — fast and with no infrastructure. If you haven't tested your nginx config yet, patterns 2 and 4 are the highest-value starting point; you can write your first three tests in under five minutes.