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 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.
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.
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:
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.
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.
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):
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
Footnotes
- the modern protocol is actually TLS, but I’ll refer to it by the more common SSL
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.
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.
Thanks a lot ! again Chris, for this awesome post.
Glad you find it useful
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.
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.
Looks like you pick up a couple of config items from
config.sh
butletsencrypt.sh
doesn’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” .
Hey Chris,
Nice tutorial, just wanna let you know that letsencrypt.sh repository moved to another one:
https://github.com/lukas2511/dehydrated
Thanks! Redirects are in place so the old URLs are still valid.
Hey Chris,
I have done the configurations same as you mentioned above. I don’t know why I am getting this error
web | 2018/08/18 14:36:14 [emerg] 5#5: BIO_new_file(“/var/www/ssl/certs/live/fullchain.pem”) failed (SSL: error:02001002:system library:fopen:No such file or directory:fopen(‘/var/www/ssl/certs/live/fullchain.pem’,’r’) error:2006D080:BIO routines:BIO_new_file:no such file)
Can you please help me with this error?
Thanks
Sorry for the trouble. For some reason fullchain.pem, which is supposed to be written to /var/www/ssl/certs/live, doesn’t exist. You need to debug lines 76-79 of web_cmd.sh (as seen in the article). Line 76 should generate the certs and line 79 should copy them to /var/www/ssl/certs/live. Sorry but that’s about all I can tell you; it has something to do with your setup that takes place when starting your container.
I’m getting the same error as you are, Umair. I’ll let you know if I can figure it out.