Skip to content →

Let’s Encrypt a Dockerized Rails Application

If you have, or plan to launch, a public facing website that asks users to log in then you need to be using HTTPS. Users expect security, and it’s the least you can provide if you’re asking them for their data. That said, SSL 1 is traditionally a big pain in the ass. It’s not easy to setup and a trusted certificate can cost hundreds of dollars. Luckily all that has changed thanks to Let’s Encrypt and their ACME protocolLet's Encrypt

As mentioned on the about page, “Let’s Encrypt is a free, automated, and open certificate authority (CA), run for the public’s benefit.” In other words, Let’s Encrypt issues trusted SSL certificates for nothing, and renews them automatically. That’s an incredible service for the web. In this tutorial I’ll teach you how to use Let’s Encrypt with a Rails application that runs in a Docker container. Afterwards all your application’s connections will be secure, and your certificates will be renewed (if necessary) on every container restart.

Tutorial goals

  1. Get trusted SSL certificates for free
  2. Fully containerize SSL management and configuration
  3. Use HTTPS in both production and development
  4. Minimize differences in Docker image between development and production

Goal #1 is inherent in Let’s Encrypt. Goal #2 means we want SSL certificate retrieval and renewal baked into our Docker image (i.e. we don’t want our containers to rely on anything from the host). Goal #3 is so we can be sure HTTPS doesn’t interfere with our application. Goal #4 is to maintain consistency across containers. Let’s Encrypt is great for production but not for development. Development is easier if we use self signed certificates. We want our Docker image to handle both cases but in as similar a manner as possible.

Companion application

This tutorial assumes you already have a dockerized Rails app similar to what I’ve written about before. SSL is complicated, so I tried to simplify this tutorial by highlighting edits to configuration files instead of posting full versions. The full versions are in the companion application. Check it out for reference, and to make modifications to your application easier.

Step 1: update the Nginx Dockerfile

The web server (Nginx) will handle our HTTPS interaction, so it makes sense to put everything SSL-related into our web server image. To do that update config/containers/Dockerfile-nginx.

Lines 7, 10, & 13 are new. Lines 4, 19, & 23 are edits to existing lines. Before you can successfully docker-compose build you’ll need to create config/containers/web_cmd.sh. For now the only contents of the file should be the previous argument to CMD, nginx -g daemon off;.

Step 2: update the Nginx configuration file

Since Nginx runs our SSL we need to update its configuration file, config/containers/nginx.conf.

All lines here are new. Entries in the top server block would be added to your existing server block. They basically “turn on” SSL for your main web server and configure it to listen to port 443. The bottom server block is new. It listens on port 80 and forces all application requests through HTTPS. The only requests it allows to be unencrypted are those made during the ACME protocol handshake.

Step 3: add Let’s Encrypt to the web server image

Edit config/containers/web_cmd.sh. Remove all lines and paste in the following:

You will need to make minor changes:

  1. Replace “example.com” on lines 11 and 54 with your domain
  2. Update the -subj argument on line 27. For a description of each field see this page. At the very least set /CN to $DOCKER_HOST (i.e. the IP your Docker daemon is running on).

This is the script that our web container runs. Read the comments carefully to understand what each line does. In a nutshell, the script sets up $SSL_ROOT with our preferred ACME client, letsencrypt.sh, and it generates untrusted, self-signed certificates in $SSL_CERT_HOME. The self-signed certificates are used during development, and in any container that has the environment variable CA_SSL set to false. For containers with CA_SSL set to true, letsencrypt.sh is run to retrieve or renew trusted SSL certificates. The trusted certificates are then copied over the self-signed certificates so Nginx can provide proper HTTPS.

Step 4: configure Docker Compose

As we’ve seen web_cmd.sh uses the environment variable CA_SSL (“Certificate Authority SSL”) to determine whether or not letsencrypt.sh is run. Also, nginx.conf expects to bind to both port 443 and 80 on the host. Since our container environments are managed by Docker Compose we can edit docker-compose.yml to ensure these settings.

Here we use Docker Compose’s environment directive to define CA_SSL for our web container. The value should be “false” for every environment except production. We also add “443:443” under the ports directive to map port 443 on the web container to port 443 on the host. Doing so makes HTTPS for our application accessible to the outside world.

Step 5: volumize SSL files

It’s important to be aware that Let’s Encrypt limits the number of certificates granted from their production servers (see the “Certificates/FQDN” section of the rate limits page). New certificates with the same set of FQDNs will only be issued at most five times per week. Since Docker containers do not persist data by default you could easily hit this limit if you deploy to production frequently. To avoid the limit we need to store our retrieved certificates so they persist between containers. This can be done using data volumes, specified by the volumes directive in docker-compose.yml.

Persisting the SSL certificates does more than help us avoid the Let’s Encrypt issuance limits. It also speeds up our container boot time. As you’ll notice the first time you start your revised web container the create_pems() function of web_cmd.sh takes a long time to complete. When we use volumes to store our certificates web_cmd.sh will skip the call to create_pems() after its first run, thereby saving minutes. In addition letsencrypt.sh will use the expiration dates of existing certificates to determine whether or not they need to be renewed. If the certificates are older then 60 days then letsencrypt.sh contacts the ACME server to renew them. Otherwise it does nothing, and saves a network request.

Step 6: update Rails environment files

There is only one tweak that needs to be made to Rails. For the environments that you plan to use HTTPS update the environment files with the following (development.rb and production.rb at least):

This setting enables HTTP Strict Transport Security (HSTS). In other words, it will make Rails include a header with every response that tells the browser to only communicate with the site via HTTPS. The setting will also mark your application’s cookies as secure so that they work over HTTPS.

HTTPS in practice

At this point you should be able to docker-compose build your app and run it in development mode. When you browse to $DOCKER_HOST you’ll now see a scary message from your browser alerting you that “your connection is not private”.

Chrome connection without Let's Encrypt

You do not want to deal with this warning regularly. To avoid it you’ll need to trust the self-signed certificate that web_cmd.sh generated. Mac users should do that by following this tutorial. Linux and Windows friends, you’ll need to find an equivalent article.

Once you’re running with HTTPS in development you should deploy your images to production. In production letsencrypt.sh will do its work and fetch SSL certificates. By default web_cmd.sh configures letsencrypt.sh to communicate with the Let’s Encrypt staging servers. The staging servers do not have the same rate limits as the production servers, and the certificates they issue are untrusted, so you’ll see the same message you saw in development about your connection not being private. Unlike development, however, you don’t want to trust these certificates. Instead bypass the warning and click around your app to make sure everything is working properly. Once you’re happy comment out line 14 of web_cmd.sh and redeploy. Before starting your container run docker volume rm docker-example-ssl on your server so that letsencrypt.sh is forced to retrieve certificates. Now browse to your site and rejoice in seeing the welcoming green padlock that indicates your connection is secure.

Chrome connection secured by Let's Encrypt

Conclusion

As web developers it is our responsibility to protect our user’s data and privacy. Let’s Encrypt helps us accomplish this task easily, transparently, and for free. If you find Let’s Encrypt useful and support their mission to secure every website in the world with HTTPS then please consider a donation to the organization that keeps this great service online. Remember, someone is paying money to make this happen, and a tax-deductible donation is way better than paying for a trusted SSL certificate from your domain name provider.

Got questions or feedback? I want it. Drop your thoughts in the comments below or hit me @ccstump. You can also follow me on Twitter to be aware of future articles.

Thanks for reading!

Addendum

6/15/17 Updated self-signed cert config in web_cmd.sh to make Chrome happy

8/12/16 Updated web_cmd.sh to give full path to letsencrypt.sh when running –cron

7/9/16 Update to use v0.2.0 of letsencrypt.sh

Footnotes

  1. the modern protocol is actually TLS, but I’ll refer to it by the more common SSL

Published in devops

13 Comments

  1. Pavel Nosov Pavel Nosov

    Thanks, great article as usual!

    I’m a newb at docker and CI, so I had some troubles with following these exact steps, here are some minor things I had to make:

    1. On step 3, you need to make config/containers/web_cmd.sh executable, otherwise build will fail to run it. I did it with chmod a+x config/containers/web_cmd.sh
    2. On step 4, exposing port 443 must also be added to docker-compose.production.yml . I forgot to do that, and got ERR_CONNECTION_REFUSED, which greatly confused me.
    3. When removing volume with staging certificates with docker volume rm docker-example-ssl, you might run into Unable to remove volume, volume still in use error. There will be id of container that uses the volume in square brackets after the error message. You must remove it first with docker rm -v CONTAINER_ID to be able to remove the volume.

    Beside these, everything went really great and smooth!

    • Great points Pavel, thanks for adding them to the article.

  2. John John

    A few hours earlier with the
    Addendum
    7/9/16 Update to use v0.2.0 of letsencrypt.sh

    Would have been perfect for me 🙂 I was going to leave a comment that it seems like we need to use v0.020 (after learning the hard way), but saw the addendum.

    • Yeah I just ran into a renew cert problem yesterday. After an hour or so of struggling I tried a script upgrade and it worked. Definitely need v0.2.0 now.

  3. Milan Rawal Milan Rawal

    Thanks a lot ! again Chris, for this awesome post.

    • Glad you find it useful

      • Milan Rawal Milan Rawal

        I did all things the right way, but I’m getting error as:-
        “”detail”: “Provided agreement URL [https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf] does not match current agreement URL [https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf]”.
        I think the issue is related to version of letsencrypt.sh, any idea on resolving this issue.

  4. Milan Rawal Milan Rawal

    I found the solution we need to change the source for updated version of letsencrypt.sh in line no. 20 as:
    “curl https://raw.githubusercontent.com/lukas2511/letsencrypt.sh/master/letsencrypt.sh” > letsencrypt.sh
    But now, I’m getting error during validating the challenge the error is as:
    + Requesting challenge for airapp.cloudapp.net…
    + ERROR: An error occurred while sending post-request to https://acme-v01.api.letsencrypt.org/acme/new-authz (Status 400)
    Details:
    {
    “type”: “urn:acme:error:rejectedIdentifier”,
    “detail”: “Policy forbids issuing for name”,
    “status”: 400
    }

    • Sorry Milan, I’m not sure what that error is. Stable v0.2.0 works fine for me.

  5. Juanjo Mata Juanjo Mata

    Looks like you pick up a couple of config items from config.sh but letsencrypt.shdoesn’t load that file for me automatically. Known issue? Did their implementation change? I ended up just using the default path to get things working …

    • Looks like the master branch of letsencrypt.sh has the file named “config”. In v2.0 (which the tutorial uses) and below it’s called “config.sh” .

    • Thanks! Redirects are in place so the old URLs are still valid.

Leave a Reply

Your email address will not be published. Required fields are marked *