Home Nginx and Let's Encrypt in Docker in a single command
Post
Cancel

Nginx and Let's Encrypt in Docker in a single command

If you have searched in the past for a Dockerized solution containing Let’s Encrypt certificate generation inside an nginx in a simple, automated manner, you might have come across with a lot of posts containing sidecar patterns, and complicated ways of setting up an nginx containers, including multi step and manual actions to get nginx up and running.

Well, in this post we will see how to accomplish this in a single container, without going into processes envolving key generation and substitution.

For the majority of uses, the container we will build should suffice, without going into over engineering, and why we shouldn’t be spawning multiple processes inside a single docker container. Even official nginx spawns two processes to run.

Problem

We want to run a single command to run nginx and letsencrypt (certbot actually), and have our nginx have valid ssl certificates, and we don’t want to mess with renewals, or getting the certificates from volumes to other hosts, or keeping them around, as they are disposable as well, and you will have a valid TLS certification on your website.

Building the container

Since we will be using nginx we must first start by using an official docker image for nginx, we will also need certbot to create the acme challenges required to have valid certificates, and a few others.

1
2
3
4
5
6
7
8
9
10
FROM nginx:1.15-alpine 
RUN apk add inotify-tools certbot openssl
WORKDIR /opt
COPY entrypoint.sh nginx-letsencrypt
COPY certbot.sh certbot.sh
COPY default.conf /etc/nginx/conf.d/default.conf
COPY ssl-options/ /etc/ssl-options
RUN chmod +x nginx-letsencrypt && \
    chmod +x certbot.sh 
ENTRYPOINT ["./nginx-letsencrypt"]

Packages:

inotify-tools so we have access to inotifywait to watch our certificates and trigger some actions when they change.

Given that configuration is loaded into memory when nginx starts, we must ensure we have some certificates, even though dummy, when nginx starts so it doesn’t complain about the missing files, and that’s why we will need openssl.

certbot is what we will need to actually emit valid ssl certificates, and its available as a package in alpine, but do bare in mind this is an expensive package, given certbot is written in python, even if the package itself is a small one, its dependencies are not, believe me I’ve tried to create a multi-stage with pyinstaller to create a single binary from certbot, but was unable to make it work at the time of writing. This will make our nginx image a bit larger.

Scripts:

From the Dockerfile above we have that we will be copying our entrypoint script and a cerbot one.

entypoint.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/sh
# Create a self signed default certificate, so Ngix can start before we have
# any real certificates.

#Ensure we have folders available

if [[ ! -f /usr/share/nginx/certificates/fullchain.pem ]];then
    mkdir -p /usr/share/nginx/certificates
fi

### If certificates don't exist yet we must ensure we create them to start nginx
if [[ ! -f /usr/share/nginx/certificates/fullchain.pem ]]; then
    openssl genrsa -out /usr/share/nginx/certificates/privkey.pem 4096
    sl genrsa -out /usr/share/nginx/certificates/privkey.pem 4096
    openssl req -new -key /usr/share/nginx/certificates/privkey.pem -out /usr/share/nginx/certificates/cert.csr -nodes -subj \
    "/C=PT/ST=World/L=World/O=${DOMAIN:-ilhicas.com}/OU=ilhicas lda/CN=${DOMAIN:-ilhicas.com}/EMAIL=${EMAIL:-info@ilhicas.com}"
    openssl x509 -req -days 365 -in /usr/share/nginx/certificates/cert.csr -signkey /usr/share/nginx/certificates/privkey.pem -out /usr/share/nginx/certificates/fullchain.pem
fi

### Send certbot Emission/Renewal to background
$(while :; do /opt/certbot.sh; sleep "${RENEW_INTERVAL:-12h}"; done;) &

### Check for changes in the certificate (i.e renewals or first start) and send this process to background
$(while inotifywait -e close_write /usr/share/nginx/certificates; do nginx -s reload; done) &

### Start nginx with daemon off as our main pid
nginx -g "daemon off;"

Ok, so there’s a lot to understand, but here is where things get nasty for docker purists, and I must agree up to a point, as we will be using multiple 3 processes in our entrypoint, dettaching two of them and leaving the nginx as the main process.

If you are in doubt regarding having multiple processes, docker has some documentation on it. Docker Docs for multiple processes in a service

We are using multiple processes to avoid manual and human processes, sometimes we need to be pragmatic, but bare in mind this is totally doable in multi services. I’ve explained before how to do it with haproxy ssl termination

The point being here we use a kiss approach to the person running this service, and avoiding manual intervention and certificates and volume sharing mess.

So as we can see from the comments in code above, we are calling a script named certbot.sh invokation to the background, will get that in a bit.

We also see that we are using inotifywait to be a watchguard of the folder we will be using to keep the certificates nginx will use, and that whenever that folder changes, we will trigger an nginx reload so that it can reevaluate the certificates and start serving the new certificates.

certbot.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if [[ ! -f /var/www/certbot ]]; then
    mkdir -p /var/www/certbot
fi
certbot certonly \
        --config-dir "${LETSENCRYPT_DIR:-/etc/letsencrypt}" \
		--agree-tos \
		--domains "$DOMAIN" \
		--email "$EMAIL" \
		--expand \
		--noninteractive \
		--webroot \
		--webroot-path /var/www/certbot \
		$OPTIONS || true

if [[ -f "${LETSENCRYPT_DIR:-/etc/letsencrypt}/live/$DOMAIN/privkey.pem" ]]; then
    cp "${LETSENCRYPT_DIR:-/etc/letsencrypt}/live/$DOMAIN/privkey.pem" /usr/share/nginx/certificates/privkey.pem
    cp "${LETSENCRYPT_DIR:-/etc/letsencrypt}/live/$DOMAIN/fullchain.pem" /usr/share/nginx/certificates/fullchain.pem
fi

So this script is running in the background every 12 hours by default, and its responsible for emiting and renewing the certificates, copying them from the letsencrypt folder to the location nginx will be using to serve.

Given how certbot works, we must copy the location of the files, as if we were to serve directly from the ${LETSENCRYPT_DIR} certbot would see there were already some files in there from our dummy certificates creation, and would create a symlink to another ${DOMAIN}.001 location that would complicate our logic, and that’s why we will used a fixed location to serve our certificates, given that we wan’t this nginx to have simple reverse proxy/ load balancer configurations, and not a multi domain approach, with multiple certificates.

From the above we see that we have a --webroot-path , and this is the folder where certbot will create the challenges files to prove ownership of domain.

Configuration

Ok, so far, we have seen the scripts we are using to serve nginx and emit certificates, but what is the actual configuration of nginx.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
  listen 80;
  server_name serverName;
  
  location /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }
  location / {
        return 301 https://$host$request_uri;
  }      
}
server {
    listen 443 ssl;
    server_name serverName;
    ssl_certificate /usr/share/nginx/certificates/fullchain.pem;
    ssl_certificate_key /usr/share/nginx/certificates/privkey.pem;
    include /etc/ssl-options/options-nginx-ssl.conf;
    ssl_dhparam /etc/ssl-options/ssl-dhparams.pem;
}

So by looking at our configuration, we see that we are serving the location for the acme-challenge from what we defined in the certbot --webroot-path

And whenever certbot runs, it will ask letsencrypt to come to the domain under that location to validate the challenge, that’s why its important to have nginx already running when certbot runs, and why we need to already have certificates at the chosen location for it to start.

1
2
ssl_certificate /usr/share/nginx/certificates/fullchain.pem;
ssl_certificate_key /usr/share/nginx/certificates/privkey.pem;

Given that certbot recommends the nginx-ssl-options we are making it fix in our docker image, as well as the dhparams and that’s the content of the files we are also copying under ssl-options and their contents being

options-nginx-ssl.conf

The source of the original file being options-ssl-nginx.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
# This file contains important security parameters. If you modify this file
# manually, Certbot will be unable to automatically provide future security
# updates. Instead, Certbot will print and log an error message with a path to
# the up-to-date file that you will need to refer to when manually updating
# this file.

ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;

ssl_ciphers "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS";

And for the ssl-dhparams.pem

The source of the original file is certbot dh params

-----BEGIN DH PARAMETERS-----
MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz
+8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a
87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7
YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi
7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD
ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg==
-----END DH PARAMETERS-----

Ok so now that we have all that we need, let’s build the container.

1
docker build -t . andreilhicas/nginx-letsencrypt

Running the Container

Ok you might have noticed in our scripts we are using a few configurable environment variables.

Simple run to just emit certificates.

Consider that your domain must be poniting to the server where you will be running this command, and that configuration is out of the scope of this post.

1
docker run -e DOMAIN=my.domain.example.com -e EMAIL=my.email@my.provider.example.com -p 80:80 -p 443:443 -d andreilhicas/nginx-letsencrypt 

And that’s all you need, the container will start with self-signed certificates, cerbot will emit new valid certificates, the file watcher from inotifywait will trigger the reload, and nginx will gracefully reload with a valid TLS/SSL certificate signed by Let’s Encrypt Certificate Authority.

Keeping the certificates in the host

1
docker run -v ./certs:/etc/letsencrypt -e DOMAIN=my.domain.example.com -e EMAIL=my.email@my.provider.example.com -p 80:80 -p 443:443 -d andreilhicas/nginx-letsencrypt 

Configuring Nginx with my own configuration

1
docker run -v ./my-custom-conf:/etc/nginx/conf.d/default.conf -e DOMAIN=my.domain.example.com -e EMAIL=my.email@my.provider.example.com -p 80:80 -p 443:443 -d andreilhicas/nginx-letsencrypt 

Beware that in order to keep things running correctly with certbot your custom configuration must keep the following directives

1
2
3
  location /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }

As well as the following ceritficate declaration

1
2
3
ssl_certificate /usr/share/nginx/certificates/fullchain.pem;
ssl_certificate_key /usr/share/nginx/certificates/privkey.pem;
include /etc/ssl-options/options-nginx-ssl.conf;

Using docker hub directly or forking github

As always I’ve created a github companion repository that you can fork at will, or even propose a Pull request

docker-nginx-letsencrypt

Or you may always run the docker image directly with the above command under the running the container section, as they have been written using the name of the image I’ve created previously and pushed to hub.docker.com under andreilhicas/nginx-letsencrypt

Or by running the specific image version andreilhicas/nginx-letsencrypt:1.15-alpine used in this post.

That’s it.

Thank you for reading, and hope it helped you somehow.

This post is licensed under CC BY 4.0 by the author.