miti.sh/posts/2025-06-22-test-nginx-conf-directives.md

13 KiB

{ title: "Test nginx Configuration Directives" blurb: "Write tests for nginx.conf directives and run them against a test server." } $index

Introduction

nginx config file nginx.conf can contain any number of important directives (redirects and rewrites, for example) that need to be verified for correctness. We can write specs for directives and run them against a running test server to ensure they are correct.

We'll use...

Setup

Since we require LuaRocks, we'll use a Buildpack tag, which comes with it already installed.

$ docker pull openresty/openresty:bookworm-buildpack

Start a server on localhost:

$ docker run --rm -it -p 80:80 openresty/openresty:bookworm-buildpack

Visit localhost in browser. We should see the OpenResty splash page.

OpenResty default nginx index page

Get nginx running

First, let's prepare the directory layout.

$ mkdir -p logs/ conf/conf.d/ html/

Next, we copy over the default nginx config file.

$ docker run --rm -it -w /opt -v $PWD:/opt openresty/openresty:bookworm-buildpack \
cp /etc/nginx/conf.d/default.conf /opt/conf.d/

Then, we edit default.conf to change root /usr/local/openresty/nginx/html; to root /var/www;:

::: filename-for-code-block conf/conf.d/default.conf :::

    location / {
-       root /usr/local/openresty/nginx/html;
+       root /var/www;
        index  index.html index.htm;

Now, let's add an index file.

::: filename-for-code-block html/index.html :::

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title></title>
  </head>
  <body>
    hello world!
  </body>
</html>

Last, we start nginx:

$ docker run --rm -it -p 80:80 \
-v $PWD/conf/conf.d:/etc/nginx/conf.d -v $PWD/html:/var/www \
openresty/openresty:bookworm-buildpack

Test an HTTP request

Then, in another console:

$ curl -v localhost
*   Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.88.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: openresty/1.27.1.2
< ...
< 
<!DOCTYPE html>
<html lang="en">
  ...
  <body>
    hello world!
  </body>
</html>

If we want to write a test for that, we need some packages from LuaRocks. Let's add a Dockerfile.

Add a Dockerfile

FROM openresty/openresty:bookworm-buildpack

WORKDIR /opt/app

RUN luarocks install busted
RUN luarocks install luajit-curl
RUN luarocks install luasocket

Now let's build our image:

$ docker build -t test-nginx .

Write the test

Let's first make a new directory for our 'specs'.

$ mkdir spec

Our test makes a cURL request against our test server:

::: filename-for-code-block spec/nginx_spec.moon :::

http = require "luajit-curl-helper.http"

req = (url) ->
  request = http.init url
  st = request\perform!
  error request\lastError! if not st
  request

describe "http://localhost", ->
  it "sends /index.html", ->
    request = req "http://localhost"
    assert.same request\statusCode!, 200
    assert.same request\statusMessage!, "OK"
    assert.same request\body!\match("<body>%s+(.-)%s+</body>"), "hello world!"

Run the test suite

Start the test server:

$ ct=$(docker run --rm -d \
-v $PWD/conf/conf.d:/etc/nginx/conf.d \
-v $PWD/html:/var/www \
-v $PWD:/opt/app \
test-nginx)

Start the test run:

$ docker exec -t $ct busted
1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds

Stop the test server.

$ docker exec $ct openresty -s stop

Create a Makefile

Ok, we now have a number of long docker commands, let's create a Makefile to make running them easier.

Makefile

image = test-nginx
loopback = 127.0.0.1

image-build:
	docker build -t $(image) .

image-rm:
	docker image rm $(image)

test:
	@ct=$(shell docker run --rm -d \
	   -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
	   -v $(PWD)/html:/var/www \
	   -v $(PWD):/opt/app \
	   --network no-internet \
	   --add-host=domain.abc=$(loopback) \
	   $(image)); \
	docker exec -t $$ct busted; \
	docker exec $$ct openresty -s stop

Now we can run tests by running make test.

$ make test
●●
2 successes / 0 failures / 0 errors / 0 pending : 0.008812 seconds

Configure the domain name

Instead of localhost we'd like to use an actual domain name. We can do this with the --add-host option. But before we do that, we want to make sure our container does not have access to the internet, otherwise we might unintentionally get a response from a domain's server on the internet rather than from our test server.

$ docker network create --internal no-internet

Now we can start the test server with our host:

$ ct=$(docker run --rm -d \
-v $PWD/conf/conf.d:/etc/nginx/conf.d \
-v $PWD/html:/var/www \
-v $PWD:/opt/app \
--network no-internet \
--add-host=domain.abc=127.0.0.1 \
test-nginx)

Update our test:

::: filename-for-code-block spec/nginx_spec.moon :::

-describe "http://localhost", ->
+describe "http://domain.abc", ->
   it "sends /index.html", ->
-     request = req "http://localhost"
+     request = req "http://domain.abc"
      assert.same request\statusCode!, 200

Run the tests.

$ docker exec -t $ct busted
1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds

Stop the test server.

$ docker exec -t $ct openresty -s stop

Ensure the test container is offline

describe "test environment", ->
  it "can't connect to the internet", ->
    assert.has_error (-> req "http://example.org"),
      "Couldn't resolve host name"

Test an HTTP redirect

We want our server to redirect all http requests to https.

Write the test

Our test:

describe "http://domain.abc", ->
  it "redirects to https", ->
    request = req "http://domain.abc"
    assert.same request\statusCode!, 301
    assert.same request\statusMessage!, "Moved Permanently"
    assert.same request\header!.Location, "https://domain.abc/"
$ make test
●●◼
2 successes / 1 failure / 0 errors / 0 pending : 0.010449 seconds

Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3
http://domain.abc redirects to https
spec/nginx_spec.moon:24: Expected objects to be the same.
Passed in:
(number) 301
Expected:
(number) 200

Configure nginx

server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen       443 ssl;
    server_name  domain.abc;
    ssl_certificate /etc/ssl/certs/domain.abc.pem;
    ssl_certificate_key /etc/ssl/private/domain.abc.pem;

Generate self-signed SSL/TLS certs for testing

Make self-signed certs in Dockerfile:

RUN openssl req -x509 -newkey rsa:4096 -nodes \
      -keyout /etc/ssl/private/domain.abc.pem \
      -out /etc/ssl/certs/domain.abc.pem \
      -sha256 -days 365 -subj '/CN=domain.abc' \
      -addext "subjectAltName=DNS:domain.abc"

Rebuild the image:

$ make image-rm image-build

Run tests:

$ make test
●◼●
2 successes / 1 failure / 0 errors / 0 pending : 0.009618 seconds

Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3
http://domain.abc sends /index.html
spec/nginx_spec.moon:17: Expected objects to be the same.
Passed in:
(number) 200
Expected:
(number) 301

It's our other test breaking, now. Fix spec:

describe "https://domain.abc", ->
  it "sends /index.html", ->
    request = req "https://domain.abc"

Run tests:

$ make test
●●●
3 successes / 0 failures / 0 errors / 0 pending : 0.017065 seconds

👍

Test subdomain reverse proxy to a unix socket

Let's say we have a running service that connects to a unix socket. We want to proxy the requests through nginx so that our service can respond to https requests but can leave handling SSL/TLS to nginx.

Configure nginx

Our nginx config file might look something like this:

server {
    listen 443 ssl;
    server_name git.domain.abc;

    location / {
        client_max_body_size 1024M;
        proxy_pass http://unix:/run/gitea/gitea.socket;
        proxy_set_header Connection $http_connection;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Add subdomain to SSL/TLS certs

-addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc"

Add a test socket server

Copied and modified from here):

::: filename-for-code-block spec/unixstreamsrvr.moon :::

socket = require "socket"
socket.unix = require "socket.unix"
u = assert socket.unix.stream!
assert u\bind "/run/gitea/gitea.socket"
assert u\listen!
assert u\settimeout 1
c = assert u\accept!

while true
  m = assert c\receive!
  break if m == ""
  print m

Write the test

::: filename-for-code-block spec/nginx_spec.moon :::

describe "https://git.domain.abc", ->
  it "reverse-proxy's a subdomain request to a unix socket", ->
    socket = fname: "unixstreamsrvr.moon", dir: "/run/gitea", owner: "nobody"
    basepath = debug.getinfo(1).short_src\match"^(.*)/[^/]*$" or "."
    seconds = 0.1

    os.execute "install -o #{socket.owner} -d #{socket.dir}"
    cmd = "su -s /bin/bash -c 'moon %s' %s"
    server = io.popen cmd\format "#{basepath}/#{socket.fname}", socket.owner
    os.execute "sleep #{seconds}" -- wait for server to start
    f = io.popen "find #{socket.dir} -type s -ls", "r"
    result = with f\read "*a"
      f\close!
    assert.truthy result\match "nobody%s+nogroup.+#{socket.dir}/gitea.socket"

    req "https://git.domain.abc"

    reqheader = with server\read "*a"
      server\close!

    assert.truthy reqheader\match "Host: git.domain.abc"

Edit Makefile:

::: filename-for-code-block Makefile :::

--add-host=git.domain.abc=$(loopback) \

Rebuild image:

$ make image-rm image-build

Run tests:

$ make test
●●●●
4 successes / 0 failures / 0 errors / 0 pending : 0.131619 seconds

Conclusion

Using these tools, we can verify that our nginx configuration is working the way we intend.

Bonus!: Issues Ran Into Just Making This Post

$host$request_uri

::: filename-for-code-block renderers/markdown.moon :::

dollar_temp = "0000sitegen_markdown00dollar0000"

So, when two cosmo selectors had no space between them, it was ambiguous to which selector the numbers belonged.

0000sitegen_markdown00dollar0000.10000sitegen_markdown00dollar0000.2 

Solution was to change the first 0 to a letter z:

z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2 

Because dollar_temp is a private attribute, I had to copy every function from the sitegen markdown renderer. Which makes my renderer a copy with some additions.

::: filename-for-code-block spec/renderers_spec.moon :::

import escape_cosmo, unescape_cosmo from require "renderers.markdown"

it "escapes and unescapes adjacent cosmo selectors", ->
 str = "$one$two"
 escaped = escape_cosmo str
 assert.same escaped,
   "z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2"
 assert.same str, (unescape_cosmo escape_cosmo str)

$$ct

z000sitegen_markdown00dollar0000.1

Because . is not a valid character for a variable name, when this string is syntax-highlighted, it gets split, like this:

<span>z000sitegen_markdown00dollar0000</span>.1

The string is then 'unescaped', but because the string got split by the closing </span> tag, it will no longer match the pattern and the 'unescape' fails.

The solution was to change the . in the dollar temp pattern to _, which is a valid character in a variable name. Update the $one$two spec escaped string.

::: filename-for-code-block spec/renderers_spec.moon :::

it "escapes and unescapes double dollar signs", ->
  out = flatten_html render [[
```Makefile
  $$name
```]]

  assert.same [[<div class="highlight"><pre><span></span><code><span class="py-w"></span><span class="py-nv">$$name</span></code></pre></div>]], out