Nginx Best Practices Made Easy With H5BP Nginx Server Configs
Last updated:
What Are Nginx Best Practices?
Some best practices for Nginx configuration include:
- Security;
- Performance & stability;
- 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 :
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 nginx-selfsigned.crt /etc/nginx/certs/default.crt
COPY 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:
- 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
- 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:
# ----------------------------------------------------------------------
# | 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:
- ECDHE-RSA-CHACHA20-POLY1305 cipher suite is used, which is considered secure.
- HTTP2 is used.
- The content-type is correctly set to text/html.
- A referer policy header is set.
- A x-content-type-options header is set.
Summary Of Applying Best Practices So Far
What we have done so far is:
- Configure Nginx server and run it as unprivileged user.
- 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
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
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.