A linter for nginx configuration files with WASM plugin support, autofix, and a browser-based Web UI.
- 30+ built-in rules covering security, best practices, style, syntax, and deprecation
- Autofix — automatically fix problems with
--fix - WASM plugin system — extend with custom rules written in Rust and compiled to WebAssembly
- Web UI — lint interactively in the browser with real-time feedback
- Ignore comments — suppress specific warnings with inline annotations
- Configurable — customize rules, severity, and options via
.nginx-lint.toml - JSON output — machine-readable output for CI integration
- GitHub Actions integration — inline PR annotations with
--format github-actions
For a comprehensive getting started guide covering installation, configuration, CI integration, and more, see docs/guide.md or run nginx-lint guide.
You can also try it in the browser: Demo
# Lint a configuration file
nginx-lint /etc/nginx/nginx.conf
# Automatically fix problems
nginx-lint --fix /etc/nginx/nginx.conf
# Show why a rule exists
nginx-lint why server-tokens-enabled
# List all available rules
nginx-lint why --list# Lint a configuration file (replace TARGET_PATH with your config path)
TARGET_PATH=/etc/nginx/nginx.conf docker run --rm -v "$(dirname "$TARGET_PATH"):$(dirname "$TARGET_PATH"):ro" ghcr.io/walf443/nginx-lint:latest "$TARGET_PATH"
# Automatically fix problems (mount as read-write)
TARGET_PATH=/etc/nginx/nginx.conf docker run --rm -v "$(dirname "$TARGET_PATH"):$(dirname "$TARGET_PATH")" ghcr.io/walf443/nginx-lint:latest --fix "$TARGET_PATH"nginx-lint [OPTIONS] [FILE]...
nginx-lint <COMMAND>
| Flag | Description |
|---|---|
-o, --format <FORMAT> |
Output format: errorformat (default), json, or github-actions |
--fix |
Automatically fix problems |
-c, --config <FILE> |
Path to configuration file |
--context <CONTEXT> |
Parent context for partial configs (e.g., http,server) |
--plugins <DIR> |
Directory containing custom WASM plugins |
--color / --no-color |
Force or disable colored output |
--no-fail-on-warnings |
Only fail on errors, not warnings |
-v, --verbose |
Show verbose output |
--profile |
Show time spent per rule |
config — Configuration file management
nginx-lint config init # Generate default .nginx-lint.toml
nginx-lint config init -o custom.toml # Custom output path
nginx-lint config validate # Validate configurationwhy — Show detailed documentation for a rule
nginx-lint why server-tokens-enabled # Explain a rule
nginx-lint why --list # List all rulesGenerate a default configuration file with:
nginx-lint config initThis creates .nginx-lint.toml:
[color]
ui = "auto" # "auto", "always", or "never"
error = "red"
warning = "yellow"
[rules.server-tokens-enabled]
enabled = true
[rules.indent]
indent_size = "auto" # or a number like 4
[rules.deprecated-ssl-protocol]
allowed_protocols = ["TLSv1.2", "TLSv1.3"]
# Support non-standard directives from extension modules
[rules.invalid-directive-context]
additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
[parser]
block_directives = ["rtmp", "application"]See the rules list for all available rules, or run nginx-lint why --list locally.
Suppress warnings using nginx-lint:ignore comments. Both a rule name and a reason are required.
Comment on the line before:
# nginx-lint:ignore server-tokens-enabled required by monitoring system
server_tokens on;Inline comment:
server_tokens on; # nginx-lint:ignore server-tokens-enabled required by monitoring systemWhen linting partial configuration files (e.g., included snippets), specify the parent context:
# nginx-lint:context http,server
location /api {
proxy_pass http://backend;
}This is equivalent to --context http,server on the command line.
nginx-lint automatically follows include directives and lints the included files as well. Both absolute paths and glob patterns (e.g. include /etc/nginx/conf.d/*.conf;) are supported.
In production, nginx configs often include files from a directory that is populated at runtime via symlinks (e.g. sites-enabled/), while the actual source files live elsewhere (e.g. sites-available/). You can configure path mappings in .nginx-lint.toml so that nginx-lint reads from the correct location:
[[include.path_map]]
from = "sites-enabled"
to = "sites-available"With this configuration, an include sites-enabled/*.conf; directive will be resolved as sites-available/*.conf during linting.
Key behaviors:
- Component-level matching —
fromis matched against exact path segments, sosites-enabledwill not matchasites-enabledorsites-enabled-old. - Multi-segment values —
from = "nginx/sites-enabled"matches consecutive path components. - Chained application — Multiple
[[include.path_map]]entries are applied in declaration order, with each mapping receiving the output of the previous one.
# Chained example: sites-enabled → sites-available → conf
[[include.path_map]]
from = "sites-enabled"
to = "sites-available"
[[include.path_map]]
from = "sites-available"
to = "conf"Try the Web UI online without installation: Demo
Start the browser-based linting interface locally:
nginx-lint web --openThe Web UI provides:
- Real-time linting as you type
- Interactive fix buttons for each issue
- "Fix All" to apply all fixes at once
- Rule documentation with bad/good examples
- In-browser configuration editing
- Runs entirely client-side via WebAssembly
You can use nginx-lint-action to run nginx-lint in your GitHub Actions workflow with inline PR annotations:
- uses: walf443/nginx-lint-action@v1
with:
files: /etc/nginx/nginx.confAlternatively, use --format github-actions directly to produce workflow commands:
nginx-lint --format github-actions /etc/nginx/nginx.confLoad custom WASM plugins from a directory:
nginx-lint --plugins ./my-plugins /etc/nginx/nginx.confEach .wasm file in the directory is loaded as a plugin, in file name order. A
file carries one rule, or several: a component built against the
plugin-rules world exports a list of rules, which the host loads as though
each had come from its own file. Every SDK builds that world (a single rule
is a list of one). Components built against the original plugin world,
one rule per file, keep loading — with a warning: a future major release
will stop loading them, and rebuilding with a current SDK is the fix.
There is an nginx-lint-plugin SDK for four languages, each with a worked
example beside it, and a builder for Lua scripts:
| Language | SDK | Example |
|---|---|---|
| Rust | crates/nginx-lint-plugin |
plugins/builtin/ (one rule each), plugins/rust/security-rules (two rules in one component) |
| TypeScript | nginx-lint-plugin (plugins/typescript/nginx-lint-plugin) |
plugins/typescript/server-tokens-enabled-ts |
| Python | nginx-lint-plugin (plugins/python/nginx-lint-plugin) |
plugins/python/server-tokens-enabled-py |
| Go | plugins/go/nginx-lint-plugin |
plugins/go/server-tokens-enabled-go |
| Lua | nginx-lint-plugin-sdk binary (plugins/nginx-lint-plugin-sdk) |
plugins/lua/server-tokens-enabled-lua |
Every library SDK can run a plugin against the real parser from its own test
suite; a Lua plugin's check runs only under nginx-lint test-plugins. Go
plugins additionally need --allow-wasi-plugins, for the reason described
below.
Lua needs no toolchain at all: nginx-lint-plugin-sdk build my_rule.lua turns a
script into a plugin by writing it into a prebuilt Lua runtime that the binary
carries, and the result runs under the default sandbox. See
plugins/nginx-lint-plugin-sdk/README.md for the script API.
test-plugins runs every plugin in a directory against the examples it carries
in its own spec — the ones nginx-lint why renders — so there is nothing to
point it at but the directory:
nginx-lint test-plugins --plugins ./my-pluginsFor each plugin it requires the bad example to be reported, the good example to be clean, and the fixes to resolve the bad example. A rule with no autofix skips the last one rather than failing it. It exits 1 if any check fails, and 2 if a plugin could not be loaded — the linter warns and carries on there, which is right for linting and wrong for a command asked to check the plugins.
Findings are matched by the plugin's own rule name, so several plugins can
share a directory, and a rule that is disabled by default or turned off in
.nginx-lint.toml is still testable — which --rule-only cannot do.
If the plugin follows the fixture layout the SDKs document, point at it too:
nginx-lint test-plugins --plugins . --fixtures tests/fixturesEach <case>/error/nginx.conf has to be reported and each
<case>/expected/nginx.conf has to be clean; a case may declare only one of
the two. The cases are written for one rule. When the directory loads more
than one — a component can carry several — each rule's cases live one level
down, under a directory named after the rule (tests/fixtures/<rule>/<case>/);
a rule without one gets the example checks only.
Plugins run as WebAssembly components with no WASI: no filesystem, no network,
no environment, no clock. They see only the configuration the linter hands them,
and a plugin importing wasi:* fails to load. Memory is capped at 256 MB and a
single check has a 10-second deadline.
Some toolchains cannot produce a plugin without WASI imports at all — Go, via
componentize-go, adapts a wasip1 module, so its runtime pulls in stdio,
environment, clocks and randomness even for a plugin that only reads the config
it is given. --allow-wasi-plugins loads those:
nginx-lint --plugins ./my-plugins --allow-wasi-plugins /etc/nginx/nginx.confIt links a subset of WASI backed by an empty context, so it grants no
filesystem, network, environment or terminal access, and wasi:sockets/* is
never linked at all. What every plugin loaded does gain is a clock and
randomness — so a plugin can behave differently from one run to the next — and
the ability to block indefinitely inside a WASI call: the 10-second deadline
interrupts wasm execution, not host calls, so a plugin that subscribes to a
duration it chooses holds the thread for exactly that long. Both are reasons to
leave the flag off unless a plugin needs it.
A project whose plugins need it on every run can set it in .nginx-lint.toml
instead:
[plugins]
allow_wasi_plugins = trueThe flag and the setting are OR'd, and there is deliberately no way to say no
from the command line. Which plugins to load stays a command line decision —
[plugins] has no directory key, and never will — so a configuration file
discovered in a checked-out repository cannot cause any WebAssembly to run. It
can only widen what a plugin you already asked for with --plugins is granted.
# Default build (CLI + builtin plugins)
cargo install --path .
# With web server support
cargo install --path . --features web-server
# Build with embedded WASM plugins instead of native (requires WASM toolchain)
make build-plugins
cargo install --path . --no-default-features --features cli,wasm-builtin-plugins| Feature | Description |
|---|---|
cli |
Command-line interface (default); includes plugins |
native-builtin-plugins |
Compile builtin plugins as native Rust (default) |
wasm-builtin-plugins |
Embed builtin WASM plugins in the binary (requires make build-plugins) |
plugins |
Loading external WASM plugins with --plugins; part of cli, on its own for library use |
web-server |
Built-in web server for browser UI |
wasm |
WebAssembly target support |
The binary also contains third-party crates under their own licenses;
nginx-lint license prints their notices (kept in
licenses/THIRD-PARTY-NOTICES, regenerated with
make build-licenses). nginx-lint-plugin-sdk license does the same for
the plugin SDK binary and the Lua runtime it embeds in plugins.
Each GitHub release also
carries a CycloneDX SBOM beside every archive
(nginx-lint-<target>.cdx.json, nginx-lint-plugin-sdk-<target>.cdx.json).
The one for nginx-lint over-reports: it also lists crates used only by the
builtin plugins' tests (testcontainers and its dependencies), which the
binary does not contain. The one for nginx-lint-plugin-sdk under-reports: it
lists Rust crates only, so the C code it carries is missing — Lua, compiled
into the binary through mlua, and the embedded Lua runtime (Lua, wasi-libc,
LLVM compiler-rt) that also goes into every plugin it builds.
nginx-lint-plugin-sdk license names those components.