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 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 protocol .

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

Get trusted SSL certificates for free Fully containerize SSL management and configuration Use HTTPS in both production and development 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.

Dockerfile-nginx ... # install essential Linux packages RUN apt-get update -qq && apt-get -y install apache2-utils curl # where we store everything SSL-related ENV SSL_ROOT /var/www/ssl # where Nginx looks for SSL files ENV SSL_CERT_HOME $SSL_ROOT/certs/live # copy over the script that is run by the container COPY config/containers/web_cmd.sh /tmp/ ... # substitute variable references in the Nginx config template for real values from the environment # put the final config in its place RUN envsubst '$RAILS_ROOT:$SSL_ROOT:$SSL_CERT_HOME' < /tmp/docker_example.nginx > /etc/nginx/conf.d/default.conf # Define the script we want run once the container boots # Use the "exec" form of CMD so Nginx shuts down gracefully on SIGTERM (i.e. `docker stop`) CMD [ "/tmp/web_cmd.sh" ] # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/config/containers/Dockerfile-nginx 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 . . . # install essential Linux packages RUN apt - get update - qq && apt - get - y install apache2 - utils curl # where we store everything SSL-related ENV SSL_ROOT / var / www / ssl # where Nginx looks for SSL files ENV SSL_CERT _ HOME $SSL_ROOT / certs / live # copy over the script that is run by the container COPY config / containers / web_cmd . sh / tmp / . . . # substitute variable references in the Nginx config template for real values from the environment # put the final config in its place RUN envsubst '$RAILS_ROOT:$SSL_ROOT:$SSL_CERT_HOME' < / tmp / docker_example . nginx > / etc / nginx / conf . d / default . conf # Define the script we want run once the container boots # Use the "exec" form of CMD so Nginx shuts down gracefully on SIGTERM (i.e. `docker stop`) CMD [ "/tmp/web_cmd.sh" ] # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/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.

nginx.conf ... server { # expect SSL requests, try to use HTTP2 listen 443 ssl http2; ... # configure SSL ssl_certificate $SSL_CERT_HOME/fullchain.pem; ssl_certificate_key $SSL_CERT_HOME/privkey.pem; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; ssl_dhparam $SSL_CERT_HOME/dhparam.pem; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 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'; ssl_prefer_server_ciphers on; ... location @rails { # prevent infinite request loop proxy_set_header X-Forwarded-Proto $scheme; ... } } server { # many clients will send unencrypted requests listen 80; # accept unencrypted ACME challenge requests location ^~ /.well-known/acme-challenge { alias $SSL_ROOT/.well-known/acme-challenge/; } # force insecure requests through SSL location / { return 301 https://$host$request_uri; } } # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/config/containers/nginx.conf 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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 . . . server { # expect SSL requests, try to use HTTP2 listen 443 ssl http2 ; . . . # configure SSL ssl _ certificate $SSL_CERT_HOME / fullchain .pem ; ssl_certificate _ key $SSL_CERT_HOME / privkey .pem ; ssl_session _ timeout 1d ; ssl_session_cache shared : SSL : 50m ; ssl_session_tickets off ; ssl _ dhparam $SSL_CERT_HOME / dhparam .pem ; ssl_protocols TLSv1 TLSv1 . 1 TLSv1 . 2 ; 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' ; ssl_prefer_server_ciphers on ; . . . location @ rails { # prevent infinite request loop proxy_set _ header X - Forwarded - Proto $scheme ; . . . } } server { # many clients will send unencrypted requests listen 80 ; # accept unencrypted ACME challenge requests location ^ ~ / .well - known / acme - challenge { alias $SSL_ROOT / .well - known / acme - challenge / ; } # force insecure requests through SSL location / { return 301 https : / / $host $request_uri ; } } # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/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:

web_cmd.sh #!/usr/bin/env bash # initialize the letsencrypt.sh environment setup_letsencrypt() { # create the directory that will serve ACME challenges mkdir -p .well-known/acme-challenge chmod -R 755 .well-known # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/domains_txt.md echo "example.com www.example.com" > domains.txt # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/staging.md echo "CA=\"https://acme-staging.api.letsencrypt.org/directory\"" > config.sh # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/wellknown.md echo "WELLKNOWN=\"$SSL_ROOT/.well-known/acme-challenge\"" >> config.sh # fetch stable version of letsencrypt.sh curl "https://raw.githubusercontent.com/lukas2511/letsencrypt.sh/v0.2.0/letsencrypt.sh" > letsencrypt.sh chmod 755 letsencrypt.sh } # creates self-signed SSL files # these files are used in development and get production up and running so letsencrypt.sh can do its work create_pems() { openssl req \ -x509 \ -nodes \ -newkey rsa:1024 \ -keyout privkey.pem \ -out fullchain.pem \ -days 3650 \ -sha256 \ -config <(cat <<EOF [ req ] prompt = no distinguished_name = subject x509_extensions = x509_ext [ subject ] commonName = localhost [ x509_ext ] subjectAltName = @alternate_names [ alternate_names ] DNS.1 = localhost IP.1 = 127.0.0.1 EOF ) openssl dhparam -out dhparam.pem 2048 chmod 600 *.pem } # if we have not already done so initialize Docker volume to hold SSL files if [ ! -d "$SSL_CERT_HOME" ]; then mkdir -p $SSL_CERT_HOME chmod 755 $SSL_ROOT chmod -R 700 $SSL_ROOT/certs cd $SSL_CERT_HOME create_pems cd $SSL_ROOT setup_letsencrypt fi # if we are configured to run SSL with a real certificate authority run letsencrypt.sh to retrieve/renew SSL certs if [ "$CA_SSL" = "true" ]; then # Nginx must be running for challenges to proceed # run in daemon mode so our script can continue nginx # retrieve/renew SSL certs $SSL_ROOT/letsencrypt.sh --cron # copy the fresh certs to where Nginx expects to find them cp $SSL_ROOT/certs/example.com/fullchain.pem $SSL_ROOT/certs/example.com/privkey.pem $SSL_CERT_HOME # pull Nginx out of daemon mode nginx -s stop fi # start Nginx in foreground so Docker container doesn't exit nginx -g "daemon off;" 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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 #!/usr/bin/env bash # initialize the letsencrypt.sh environment setup_letsencrypt ( ) { # create the directory that will serve ACME challenges mkdir - p .well - known / acme - challenge chmod - R 755 .well - known # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/domains_txt.md echo "example.com www.example.com" > domains .txt # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/staging.md echo "CA=\"https://acme-staging.api.letsencrypt.org/directory\"" > config .sh # See https://github.com/lukas2511/letsencrypt.sh/blob/master/docs/wellknown.md echo "WELLKNOWN=\"$SSL_ROOT/.well-known/acme-challenge\"" >> config .sh # fetch stable version of letsencrypt.sh curl "https://raw.githubusercontent.com/lukas2511/letsencrypt.sh/v0.2.0/letsencrypt.sh" > letsencrypt .sh chmod 755 letsencrypt .sh } # creates self-signed SSL files # these files are used in development and get production up and running so letsencrypt.sh can do its work create_pems ( ) { openssl req \ - x509 \ - nodes \ - newkey rsa : 1024 \ - keyout privkey .pem \ - out fullchain .pem \ - days 3650 \ - sha256 \ - config < ( cat << EOF [ req ] prompt = no distinguished_name = subject x509_extensions = x509 _ ext [ subject ] commonName = localhost [ x509 _ ext ] subjectAltName = @ alternate _ names [ alternate _ names ] DNS . 1 = localhost IP . 1 = 127.0.0.1 EOF ) openssl dhparam - out dhparam .pem 2048 chmod 600 * .pem } # if we have not already done so initialize Docker volume to hold SSL files if [ ! - d "$SSL_CERT_HOME" ] ; then mkdir - p $SSL_CERT_HOME chmod 755 $SSL_ROOT chmod - R 700 $SSL_ROOT / certs cd $SSL_CERT_HOME create_pems cd $SSL_ROOT setup_letsencrypt fi # if we are configured to run SSL with a real certificate authority run letsencrypt.sh to retrieve/renew SSL certs if [ "$CA_SSL" = "true" ] ; then # Nginx must be running for challenges to proceed # run in daemon mode so our script can continue nginx # retrieve/renew SSL certs $SSL_ROOT / letsencrypt .sh -- cron # copy the fresh certs to where Nginx expects to find them cp $SSL_ROOT / certs / example .com / fullchain .pem $SSL_ROOT / certs / example .com / privkey .pem $SSL_CERT_HOME # pull Nginx out of daemon mode nginx - s stop fi # start Nginx in foreground so Docker container doesn't exit nginx - g "daemon off;"

You will need to make minor changes:

Replace “example.com” on lines 11 and 54 with your domain 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.

docker-compose.yml, docker-compose.production.yml web: ... # disable let's encrypt by default environment: CA_SSL: "false" # change to "true" for production ... # expose the ports we configured Nginx to bind to ports: - "80:80" - "443:443" # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 web : . . . # disable let's encrypt by default environment : CA_SSL : "false" # change to "true" for production . . . # expose the ports we configured Nginx to bind to ports : - "80:80" - "443:443" # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/docker-compose.yml

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.

docker-compose.yml, docker-compose.production.yml ... # persist SSL certificates in $SSL_ROOT volumes: - docker-example-ssl:/var/www/ssl ... # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/docker-compose.yml 1 2 3 4 5 6 7 8 . . . # persist SSL certificates in $SSL_ROOT volumes : - docker-example-ssl :/var/www/ssl . . . # Full file at https://github.com/cstump/docker_example/blob/lets_encrypt_example/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):

development.rb, production.rb # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true 1 2 # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config . force_ssl = true

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”.

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.

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