DEV Community

Cover image for Automatic SSL with Let's Encrypt & Nginx
Adam K Dean
Adam K Dean

Posted on • Edited on

Automatic SSL with Let's Encrypt & Nginx

See update summary at bottom of post for changelog.

Note: December 2020 saw the release of v2 of the letsencrypt-nginx-proxy-companion project. I've updated this article to reflect that but will leave the old v1 code in the footer. If you need to upgrade your existing machines in situ, please refer to the nginx-proxy/docker-letsencrypt-nginx-proxy-companion repository.


Since 2016, certificate authority Let's Encrypt have offered free SSL/TLS certificates in a bid to make encrypted communications on the web ubiquitous. If you've ever bought a certificate, you'll know they're usually quite expensive, the process for verifying them is a pain in the gluteus maximus, and then they expire while you're on holiday causing an outage.

With Let's Encrypt, all of these problems fade away, thanks to the Automated Certificate Management Environment (ACME) protocol that enables you to automate of the verification and deployment of certificates, saving you money and time. ACME is an interesting topic in it's own right, and you can read more about the various verification methods (called challenges) here, but today I'm going to show you how to easily setup a reverse proxy with automagical certificate generation, verification, and deployment.

The first thing we're going to do is create a user defined bridge network called service-network. I'm bad at naming things but this seems applicable.

docker network create --subnet 10.10.0.0/24 service-network
Enter fullscreen mode Exit fullscreen mode

Fig A

Next, we'll setup a reverse proxy. A reverse proxy simply accepts requests and proxies them onto another service based on routing rules such as which hostnames should go to which service containers. I imagine it looks something a little bit like this:

Reverse Proxy (as I imagine it)

We'll use an image by Jason Wilder, jwilder/nginx-proxy. This is a well maintained project with a lot of documentation. We map the ports 80 and 443 into the container so that we can handle both HTTP and HTTPS connections. We have a number of volumes too, three are standard docker volumes, and one is the docker daemon UNIX socket.

You might notice that I often use long-form arguments in scripts. While we could easily save space and have this on one line, it doesn't help others who may have to maintain scripts in the future.

docker run \
  --detach \
  --restart always \
  --publish 80:80 \
  --publish 443:443 \
  --name nginx-proxy \
  --network service-network \
  --volume /var/run/docker.sock:/tmp/docker.sock:ro \
  --volume nginx-certs:/etc/nginx/certs \
  --volume nginx-vhost:/etc/nginx/vhost.d \
  --volume nginx-html:/usr/share/nginx/html \
  jwilder/nginx-proxy
Enter fullscreen mode Exit fullscreen mode

Fig B

Ok, so now we have our reverse proxy, next we need to setup the Let's Encrypt companion, for which we'll be using Yves Blusseau's image jrcs/letsencrypt-nginx-proxy-companion. I've been using this flawlessly now for almost a year.

We map the same volumes to this container, though no ports are published. We set the NGINX_PROXY_CONTAINER environment variable to match the name of our proxy container, and that's about it. This container will run a process whenever new service containers are detected, generating certificates and keeping them up to date.

docker run \
  --detach \
  --restart always \
  --name nginx-proxy-letsencrypt \
  --network service-network \
  --volumes-from nginx-proxy \
  --volume /var/run/docker.sock:/var/run/docker.sock:ro \
  --volume /etc/acme.sh \
  --env "DEFAULT_EMAIL=mail@yourdomain.tld" \
  jrcs/letsencrypt-nginx-proxy-companion
Enter fullscreen mode Exit fullscreen mode

Fig C

Finally, it's time to add a service container. We'll use the tutum/hello-world image, quite a popular one for testing. For this we're going to need a hostname with the DNS configured. We'll pretend to use hello-world.example.com and pretend that we've setup an A record pointing to the IP address of this server.

docker run \
  --detach \
  --restart always \
  --name hello-world \
  --network service-network \
  --env VIRTUAL_HOST=hello-world.example.com \
  --env LETSENCRYPT_HOST=hello-world.example.com \
  --env LETSENCRYPT_EMAIL="youremail@example.com" \
  tutum/hello-world
Enter fullscreen mode Exit fullscreen mode

Fig D

So let's take a look at what's going on here. We set up a proxy container which listens on ports 80 and 443. We then set up an ACME certificate container which will manage certs for us. Then we created a hello world container. We set the environment variable VIRTUAL_HOST to our hostname, and this is picked up by the proxy in order to route requests through to this container. The requests are routed through our user defined bridge service-network. We also set the environment variables LETSENCRYPT_HOST and LETSENCRYPT_EMAIL which are picked up by the ACME container and used when acquiring the certificate. FYI, the two hostnames will always have to match (VIRTUAL_HOST and LETSENCRYPT_HOST).

All we have to do is add these three variables to a container, and it'll be detected by the proxy and ACME containers and in short order, it'll work.

Now a few things to note. Port discovery — how does the proxy know which port to use? The hello-world image we use exposes a port in the Dockerfile with EXPOSE 80. This always takes precedence, so if the image has an exposed port, this will be used. But if your Dockerfile doesn't define an exposed port, or if you perhaps configure the port at runtime with an environment variable, e.g. HTTP_PORT=8000, then you can use the --expose argument to let the proxy know which port to use. If there are multiple ports available, you can specify which to use with the environment variable VIRTUAL_PORT.

Just to reiterate, Dockerfile EXPOSE takes precedence over --expose argument.

So now, when you hit https://hello-world.example.com, the DNS server returns an A record with your public IP, the browser then connects to this IP address on port 443 and in turn, the host routes that connection through to the nginx-proxy container. This terminates the SSL and proxies the request through to the hello-world container as a plain HTTP request.

Fig E

Finally, there is another thing we can do. The proxy handles upgrading from HTTP to HTTPS, which is great, but sometimes you want to handle www/apex domain redirects. You can configure this in some domain registrar control panels, but then you end up with a different IP address for your hostname.

There is a better solution, and this time the image we'll use this time is actually one of mine, adamkdean/redirect.

The way it works is super simple. You simply setup another container with the VIRTUAL_HOST set to the domain you want to redirect away from and configure it's destination. Take for example we have example.com and we want it to always redirect to www.example.com because we love the www.

We have our main image like so, bound to www.example.com.

docker run \
  --detach \
  --restart always \
  --name example-website \
  --network service-network \
  --env VIRTUAL_HOST=www.example.com \
  example/website
Enter fullscreen mode Exit fullscreen mode

We then create the redirect companion container, bound to example.com, with the environment variable REDIRECT_LOCATION being set to our preferred destination and REDIRECT_STATUS_CODE set as applicable.

docker run \
  --detach \
  --restart always \
  --name example-redirect \
  --network service-network \
  --env VIRTUAL_HOST=example.com \
  --env REDIRECT_LOCATION="http://www.example.com" \
  --env REDIRECT_STATUS_CODE=301 \
  adamkdean/redirect
Enter fullscreen mode Exit fullscreen mode

What happens now is requests for example.com hit this redirect container, which responds with REDIRECT_STATUS_CODE and the REDIRECT_LOCATION.

We can of course use Let's Encrypt with these.

docker run \
  --detach \
  --restart always \
  --name example-website \
  --network service-network \
  --env VIRTUAL_HOST=www.example.com \
  --env LETSENCRYPT_HOST=www.example.com \
  --env LETSENCRYPT_EMAIL="youremail@example.com" \
  example/website

docker run \
  --detach \
  --restart always \
  --name example-redirect \
  --network service-network \
  --env VIRTUAL_HOST=example.com \
  --env LETSENCRYPT_HOST=example.com \
  --env LETSENCRYPT_EMAIL="youremail@example.com" \
  --env REDIRECT_LOCATION="https://www.example.com" \
  --env REDIRECT_STATUS_CODE=301 \
  adamkdean/redirect
Enter fullscreen mode Exit fullscreen mode

In this case, the initial request for example.com hits the proxy, which terminates the SSL and proxies it through to the example-redirect container, which responds with a 301 to www.example.com. The next request which is now for www.example.com hits the proxy and is proxied through to the example-website container, something like this:

Fig F

I hope this helps you. This setup can work for single sites or for a number of sites. I use this in situations where setting up a large container platform is a little bit overkill (kubernetes for a blog is fine, right guys?)

It's super easy to update and deploy, and having used it for over a year now, it's working great. Thanks for reading.


Update (Feb 21, 2020): as requested, here is a docker-compose.yml version.

version: "3"

services:
  web:
    image: example/website
    expose:
     - 8000
    environment:
      HTTP_PORT: 8000
      VIRTUAL_HOST: www.example.com
      LETSENCRYPT_HOST: www.example.com
      LETSENCRYPT_EMAIL: "example@example.com"
    networks:
      service_network:

  web-redirect:
    image: adamkdean/redirect
    environment:
      VIRTUAL_HOST: example.com
      LETSENCRYPT_HOST: example.com
      LETSENCRYPT_EMAIL: "example@example.com"
      REDIRECT_LOCATION: "https://www.example.com"
    networks:
      service_network:

  nginx-proxy:
    image: jwilder/nginx-proxy
    ports:
      - 80:80
      - 443:443
    container_name: nginx-proxy
    networks:
      service_network:
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - nginx-certs:/etc/nginx/certs
      - nginx-vhost:/etc/nginx/vhost.d
      - nginx-html:/usr/share/nginx/html

  nginx-proxy-letsencrypt:
    image: jrcs/letsencrypt-nginx-proxy-companion
    environment:
      NGINX_PROXY_CONTAINER: "nginx-proxy"
    networks:
      service_network:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - nginx-certs:/etc/nginx/certs
      - nginx-vhost:/etc/nginx/vhost.d
      - nginx-html:/usr/share/nginx/html

networks:
  service_network:

volumes:
  nginx-certs:
  nginx-vhost:
  nginx-html:
Enter fullscreen mode Exit fullscreen mode

Update (Feb 28, 2020):

I realised that I made a mistake when talking about VIRTUAL_PORT. I've updated the document but just to clarify, you tell the proxy which port to use with the EXPOSE command in a Dockerfile and the --expose argument on the Docker command line interface. If there are multiple ports exposed, you can then use the VIRTUAL_PORT environment variable to signal which exposed port to use.

Sorry about the mix up!


Update (Apr 22, 2020):

Added some missing backslashes to the secure redirect snippets. Oops!


Update (Dec 11, 2020):

The v1 code for letsencrypt-nginx-proxy-companion.

docker run \
  --detach \
  --restart always \
  --name nginx-proxy-letsencrypt \
  --network service-network \
  --volume /var/run/docker.sock:/var/run/docker.sock:ro \
  --volume nginx-certs:/etc/nginx/certs \
  --volume nginx-vhost:/etc/nginx/vhost.d \
  --volume nginx-html:/usr/share/nginx/html \
  --env NGINX_PROXY_CONTAINER="nginx-proxy" \
  jrcs/letsencrypt-nginx-proxy-companion:v1.13.1
Enter fullscreen mode Exit fullscreen mode

If you enjoyed this article, you might enjoy my next one Debugging Docker Containers, where we delve into a few ways that you can work out just what is going on inside these black boxes we call containers.

A note on injecting docker.sock: the docker daemon UNIX socket (/var/run/docker.sock) gives access to docker, which in other words, is root access. Be sure you know what you're giving this too whenever you're running third party images.

Top comments (21)

Collapse
 
ssherlock profile image
ssherlock

To echo everybody else, thank you for a very clear and easy to read article on this subject. I guess there's only one way for me to find out but adding a docker-compose.yml file for the above would have been the icing on the cake (for me at least)

Collapse
 
adamkdean profile image
Adam K Dean

I'm glad you found it clear and easy to read. Apologies for serving cake without icing. I've added a docker-compose setup for this to the bottom of the post. Hope that helps.

Collapse
 
ssherlock profile image
ssherlock

That's brilliant, thanks. And working a treat!

Thread Thread
 
adamkdean profile image
Adam K Dean

Glad to hear it. I've made a further update regarding --expose / VIRTUAL_PORT just in case that section didn't work exactly as expected!

Collapse
 
chen profile image
Chen

Great, simple explanation of these topics. Much appreciated

Is there a simple way to modify the nginx settings for redirection? It seems a bit overkill to run a dedicate container for a redirection rule in the config file

Collapse
 
adamkdean profile image
Adam K Dean

There have been some suggestions to this in recent weeks, yes. You'll have to take a look through the jwilder/nginx-proxy repo issues but IIRC there are a few discussions there. Glad this was helpful though!

I do have plans to extend it, to add things in like IP source restrictions, but just haven't had the time yet.

Collapse
 
olidroide profile image
olidroide

Thanks Adam, good explanation and this was the architecture than I used, but just yesterday I move all my reverse nginx proxy to traefik. But I'm still loving the simplicity of this solution. Thanks for sharing it 😃

Collapse
 
josenunez profile image
Jose Nunez

Hi. Thanks a lot for this post.
I have tried many different ways to write my docker-compose file, and I still can't make this work. I just copied your example and replaced the vhosts with my own. I can always see them working on HTTP. But, on https, chrome returns NET::ERR_CERT_AUTHORITY_INVALID.
I'm using this tool to check geocerts.com/ssl-checker, and it shows the following error:

  • The hostname (canasta-solidaria.org) does NOT match the Common Name in the certificate (letsencrypt-nginx-proxy-companion). This certificate is currently invalid for this host.

I can see that the Common Name is "letsencrypt-nginx-proxy-companion".
I also get exactly the same error for my domain registry.commonsoft.net.
Would you be able to give me a hand with this?
Thanks!

Collapse
 
carmageddon profile image
Genadi Saltikov

I am running into a similar issue, have you ever resolved it, @josenunez ?
The interesting thing is that it works for older subdomains I already had configured, but adding new ones - running into similar one.

Collapse
 
rginus profile image
rginus

Thanks for the explanation and I use your docker-compose.yml file. Maybe you can change the network name to 'service-network' for better understanding.
I have a question and maybe you can help me. I installed Ubuntu with docker on a VM of VMware ESXi with ip address 192.168.1.81. Now I have another VM on the same host with a website running Windows 2016/IIS with ip address 192.168.1.82. Is it possible to access this webserver via the reverse proxy via SSL?

Collapse
 
dineshrathee12 profile image
Dinesh Rathee

LetsEncrypt have revoked around 3 million certs last night due to a bug that they found. Are you impacted by this, Check out ?

DevTo
[+] dev.to/dineshrathee12/letsencrypt-...

GitHub
[+] github.com/dineshrathee12/Let-s-En...

LetsEncryptCommunity
[+] community.letsencrypt.org/t/letsen...

Collapse
 
sammourad profile image
Sam Mourad

Great article. Keep it up!

Collapse
 
hkanizawa profile image
Hudson Kanizawa

Good one. Thanks!

Collapse
 
wellu profile image
Veli pekka Jutila

I have a project in mind where I want to augment my powerdns.com setup to provide also URL redirection and not just DNS. I would give the domain an IP/A-record that points to the nginx-proxy. Then I would use Ansible? to add a new server address to nginx/conf.d/newdomain_com.conf that redirects to what ever the final destination domain is. Maybe even use url-rewrite to redirect single subdomains or URLs.

Letsencrypt would automatically get a certificate for this newdomain, so also https-redirects would work.

Would this setup be feasible to do with the technique you explaned, but without the service containers, since I only need redirects?

Collapse
 
wrldwzrd89 profile image
Eric Ahnell

Shame my web host isn't ACME-enabled... alas, switching from them to one that is... is also a pain in the gluteus maximus. Ah well. Such is life sometimes!