Nginx Best Practices Made Easy With H5BP Nginx Server Configs

Profile picture of Arjan Schouten, author of the blog post.
Author Arjan Schouten

Last updated:

Applying best practices for Nginx is made easier by the H5BP open source project .

What Are Nginx Best Practices?

Some best practices for Nginx configuration include:

  1. Security;
  2. Performance & stability;
  3. Error handling & logging.

How To Use H5BP?

Download the H5BP server configs :

git clone https://github.com/h5bp/server-configs-nginx --depth 1

The server-configs-nginx/ directory contains:

nginx.conf
mime.types
h5bp/
conf.d/
custom.d/ # this directory can be created for customizations, \
        # create a file in this directory with extension .conf and it \
        # will be loaded by nginx.conf

Create 2 files to test the config:

cd server-configs-nginx
echo '<html><head><link rel="stylesheet" type="text/css" src="main.css"/></head><body>Hello world</body></html>' > index.html 
echo "body:{color: #999}" > main.css

For this tutorial, we use a self-signed certificate so we can also apply TLS best practices. Install OpenSSL for your system and run:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout nginx-selfsigned.key -out nginx-selfsigned.crt -subj '/CN=localhost'

Let's create a docker image based on Nginx unprivileged :

Dockerfile
FROM nginxinc/nginx-unprivileged:1.25-alpine
# Remove the default configuration.
RUN rm -rf /etc/nginx/*
COPY h5bp /etc/nginx/h5bp
COPY conf.d /etc/nginx/conf.d
COPY nginx-selfsigned.crt /etc/nginx/certs/default.crt
COPY nginx-selfsigned.key /etc/nginx/certs/default.key
COPY nginx.conf \
    mime.types \
    /etc/nginx
COPY --chown=101:101 nginx-selfsigned.crt /etc/nginx/certs/default.crt
COPY --chown=101:101 nginx-selfsigned.key /etc/nginx/certs/default.key
COPY index.html main.css /var/www/example.com/public/

Build and run the docker container:

docker build -t demo-nginx-h5bp . && \
          docker run --rm --name demo-nginx-h5bp -p 8080:8080 -p 443:443 demo-nginx-h5bp

Expected output:

/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: /etc/nginx/conf.d/default.conf differs from the packaged version
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
[warn] 1#1: the "user" directive makes sense only if the master process runs with super-user privileges, ignored in /etc/nginx/nginx.conf:8
nginx: [warn] the "user" directive makes sense only if the master process runs with super-user privileges, ignored in /etc/nginx/nginx.conf:8
[emerg] 1#1: open() "/var/run/nginx.pid" failed (13: Permission denied)
nginx: [emerg] open() "/var/run/nginx.pid" failed (13: Permission denied)

We have 2 issues:

  1. A warning about the user directive: H5BP defines the www-data user in nginx.conf:8. Configuring a user is a best practice when Nginx is started with a privileged user. However, since we use the unprivileged docker image, the process is already started with an unprivileged user. Remove the line since it isn't needed in our example. Keep the line as it is now and create the user when you start Nginx with a privileged user.
    sed -i 's/user www-data;//g' nginx.conf
  2. An error, the process has no permission on /var/run/nginx.pid . As stated in the documentation of the Nginx unprivileged base image , /var/run/nginx.pid should be changed to /tmp/nginx.pid .
    sed -i 's//var/run/nginx.pid//tmp/nginx.pid/g' nginx.conf

Now, start the container again:

docker build -t demo-nginx-h5bp .
docker run --rm --name demo-nginx-h5bp -p 8080:8080 -p 443:443 demo-nginx-h5bp

Output:

/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: /etc/nginx/conf.d/default.conf differs from the packaged version
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up

Some best practices are now correctly applied by using the default set of H5BP's Nginx config. Besides that, we use an unprivileged image which is a security best practice.

The container is running and we can check that on http://localhost:8080. An empty response is returned because we haven't configured the server directive yet.

Configuring Nginx Server Directive

So far, we have only applied the best practices that are part of nginx.conf . We now focus on the Nginx server {} directive.

H5BP configures a default server in conf.d/no-ssl.default.conf .

The default server configs send back an HTTP 444 response. The HTTP 444 response indicates that a request should be closed without sending a response.

We need to enable the same thing for TLS (https). Rename the conf.d/.default.conf file to conf.d/default.conf (note the dot in the filename).

mv conf.d/.default.conf conf.d/default.conf

Now we should create a conf file for serving the 2 static files, index.html and main.css , we created before. In H5BP's conf.d/templates/ directory , you can find 2 examples of how to configure the server directive .

We will use the template with TLS configured. It will load a basic config and configures TLS.

mv conf.d/templates/example.com.conf conf.d/

Open the conf.d/example.com.conf and change it to your needs:

example.com.conf
# ----------------------------------------------------------------------
# | Config file for example.com host                                   |
# ----------------------------------------------------------------------
#
# This file is a template for an Nginx server.
# This Nginx server listens for the `example.com` host and handles requests.
# Replace `example.com` with your hostname before enabling.

# Choose between www and non-www, listen on the wrong one and redirect to
# the right one.
# https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/#server-name-if
server {
  listen [::]:443 ssl http2;
  listen 443 ssl http2;

  server_name www.example.com;

  include h5bp/tls/ssl_engine.conf;
  include h5bp/tls/certificate_files.conf;
  include h5bp/tls/policy_balanced.conf;

  return 301 $scheme://example.com$request_uri;
}

server {
  # listen [::]:443 ssl http2 accept_filter=dataready;  # for FreeBSD
  # listen 443 ssl http2 accept_filter=dataready;  # for FreeBSD
  listen [::]:443 ssl http2;
  listen 443 ssl http2;

  # The host name to respond to
  server_name example.com;

  include h5bp/tls/ssl_engine.conf;
  include h5bp/tls/certificate_files.conf;
  include h5bp/tls/policy_balanced.conf;

  # Path for static files
  root /var/www/example.com/public;

  # Custom error pages
  include h5bp/errors/custom_errors.conf;

  # Include the basic h5bp config set
  include h5bp/basic.conf;
}

Save the file and start again with docker:

docker build -t demo-nginx-h5bp . && \
          docker run --rm --name demo-nginx-h5bp -p 8080:8080 -p 443:443 demo-nginx-h5bp

It should have started correctly. With curl we can see that the server is serving our example website:

curl -v -H 'Host: example.com' https://localhost/index.html --insecure

Note, we need the Host header because Nginx is configured to only respond on the example.com domain. Change the domain to your domain and of course, use a valid TLS certificate from for example Letsencrypt in production.

*   Trying 127.0.0.1:443...
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=NL; ST=Some-State; O=Internet Widgits Pty Ltd
*  start date: Sep 16 11:49:04 2022 GMT
*  expire date: Sep 16 11:49:04 2023 GMT
*  issuer: C=NL; ST=Some-State; O=Internet Widgits Pty Ltd
*  SSL certificate verify result: self signed certificate (18), continuing anyway.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /index.html]
* h2h3 [:scheme: https]
* h2h3 [:authority: example.com]
* h2h3 [user-agent: curl/7.82.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x13c012c00)
> GET /index.html HTTP/2
> Host: example.com
> user-agent: curl/7.82.0
> accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 200
< server: nginx
< date: Fri, 16 Sep 2022 12:26:26 GMT
< content-type: text/html; charset=utf-8
< content-length: 106
< last-modified: Fri, 16 Sep 2022 10:23:31 GMT
< etag: "63244ea3-6a"
< expires: Fri, 16 Sep 2022 12:26:26 GMT
< cache-control: max-age=0
< referrer-policy: strict-origin-when-cross-origin
< x-content-type-options: nosniff
< x-frame-options: DENY
< accept-ranges: bytes
<
<html><head><link rel="stylesheet" type="text/css" src="main.css"/></head><body>Hello world</body></html>
* Connection #0 to host localhost left intact

Some of the things that we see which are configured by using H5BP's config files:

  1. ECDHE-RSA-CHACHA20-POLY1305 cipher suite is used, which is considered secure.
  2. HTTP2 is used.
  3. The content-type is correctly set to text/html.
  4. A referer policy header is set.
  5. A x-content-type-options header is set.

Summary Of Applying Best Practices So Far

What we have done so far is:

  1. Configure Nginx server and run it as unprivileged user.
  2. Serve a static website with default best practices and TLS.

More Plug-And-Play Configs By H5BP

H5BP defines more config files that you might want to use. However, these options require more care because it poses more risk when used incorrectly (for example, accidentally caching files you don't want to cache client side).

Apply Web Performance Config

In the web_performance directory, you can find some files that improve performance.

Use those with caution because it can have unexpected results.

Cache File Descriptors

The file h5bp/web_performance/cache-file-descriptors.conf configures the cache of file descriptors so Nginx can start sending files faster. You might not want to enable this when you change files very often. For static file serving, this feature is worth enabling!

To enable this, create a new file conf.d/enable.conf

conf.d/enable.conf
include h5bp/web_performance/cache-file-descriptors.conf;

Restart Nginx and test the performance improvement yourself.

Content Transformation

Next is the file h5bp/web_performance/content_transformation.conf . This file modifies the Cache-Control header to disable transforming content by intermediaries such as proxies. Proxies and other intermediaries might transform content to for example reduce bandwidth usage. If you don't want this you can set the header: Cache-Control: no-transform .

To enable this, add it to your conf.d/{domain}.conf file: conf.d/example.com.conf

conf.d/example.com.conf
include h5bp/web_performance/content_transformation.conf;

Pre-Compressed Content

Compressing content requires some CPU power. We could save some CPU power by pre-compressing static files.

Pre-compress the HTML file by running:

gzip --best -k index.html main.css

Add the files to the Docker image

FROM nginxinc/nginx-unprivileged:1.23-alpine
      
...
COPY index.html.gz main.css.gz /var/www/example.com/public

Now add the following to conf.d/include.conf :

h5bp/web_performance/pre-compressed_content_gzip.conf;

Let's test

curl -v -H 'Host: example.com' -H 'Accept-encoding: gzip' \
https://localhost/index.html --insecure

Output:

< HTTP/2 200
...
< content-type: text/html; charset=utf-8
< content-length: 119
...
< content-encoding: gzip
...

Besides GZIP, H5BP includes a file to enable Brotli compression.

To enable Brotli, you need a Brotli nginx module which is beyond this blog post.

(if you ask yourself why there is no gzip compression without pre-compressed content, the index.html is only 106 bytes. GZIP compression is only done when the file is over 256 bytes .)

Summary

H5BP Nginx server configs can be used to easily configure an Nginx web server with defaults that fit most use cases. You only need to tailor a few specific things for your website.

Share this article

Was this article helpful?

Checkout the ExcellentWebCheck services

ExcellentWebCheck's goal is to improve the online user experience. The tools of ExcellentWebCheck help to detect and improve usability problems on your website.