(Updated: )
/ #docker #node #docker-compose 

Docker Compose Local HTTPS with nginx or Caddy and mkcert

A solution to serve your Dockerized application on Docker Compose with TLS/SSL locally by leveraging a HTTPS reverse-proxy and mkcert.

Examples with Caddy and nginx are at: github.com/HugoDF/docker-compose-local-https

Table of contents:

Table of Contents

Why run your project with HTTPS locally?

In most instances, your production site will run with TLS/SSL enabled, ie. over HTTPS. This tends not to be a concern of your application, ie the SSL termination is dealt with at the ingress/reverse-proxy/load-balancer level.

However your application may rely on the assumption that it’s running over an SSL connection. For example, the Set-Cookie header has a Secure attribute which means the cooke will only be sent over a TLS/SSL connection. That means that locally, you would need the code to fork to enable sending of cookie over non TLS/SSL connection, in other words if environment = local => don``'``t enable Secure.

An approach backed by the 12-factor app

SSL/TLS/HTTPS is not a concern of the application. It’s a concern of the deployment platform.

“Dev/prod parity” is the 12-factor principles that concern us here.

It boils down to “our production application is running over HTTPS (with SSL/TLS), we should run locally, dev and on all hosted environments over HTTPS (with SSL/TLS)”. Having a switch somewhere that says “this is local, run over HTTP” is a breach of the “Dev/prod parity” principle.

In other words, assuming we’re hosted on a PaaS like Heroku/Dokku, behind a reverse-proxy like nginx/Caddy or a load balancer offering (Google Cloud Load Balancing, AWS ELB) in production. Those systems handle certificates and SSL termination for us, locally we should have code/infrastructure in place to do the same.

Why we can’t use Let’s Encrypt

The official line from Let’s Encrypt is

[For local development section] The best option: Generate your own certificate, either self-signed or signed by a local root, and trust it in your operating system’s trust store. Then use that certificate in your local web server. See below for details.

See letsencrypt.org/docs/certificates-for-localhost/

Let’s Encrypt is designed to automatically generate trusted certificates for domains where an application is hosted. To do so, you have to prove that you control the domain… which isn’t possible (or true) for localhost.

mkcert for locally-trusted certificates

mkcert is “a simple zero-config tool to make locally trusted development certificates with any names you’d like.”.

mkcert’s premise is that since you’re in control of your machine, you can hook into the system root (trusted root certification authority) store, add a certificate authority (which only exists locally) and use that to generate certificates which your machine will trust.

Using certificates from real certificate authorities (CAs) for development can be dangerous or impossible (for hosts like localhost or 127.0.0.1), but self-signed certificates cause trust errors. Managing your own CA is the best solution, but usually involves arcane commands, specialized knowledge and manual steps.

mkcert automatically creates and installs a local CA in the system root store, and generates locally-trusted certificates.

From the mkcert docs

To install mkcert, see github.com/FiloSottile/mkcert#installation for your platform.

Once installed, you should run: mkcert -install

Skip to the code

Prerequisites

  • Docker Desktop (or Docker + Docker Compose)
  • mkcert
  • Modify your /etc/hosts file to have the following entry (foo.test is the domain we’re going to be serving traffic over HTTPS for).
127.0.0.1 foo.test

About Caddy and nginx

Caddy is the HTTP/2 web server with automatic HTTPS. https://caddyserver.com/

nginx is a web server which can also be used as a reverse proxy, load balancer, mail proxy and HTTP cache. Wikipedia

Caddy’s selling point is that it comes with good defaults and it is easy to configure. It’s a HTTP web-server that defaults to HTTP/2 and HTTPS, which are modern best-practices. It can automatically generate certificates for you using Let’s Encrypt.

nginx is more mature and edges Caddy out on performance. However its configuration syntax can be a pain, its defaults aren’t at the cutting edge: SSL termination (HTTPS) and HTTP/2 have to be manually enabled, the Let’s Encrypt integration has to be configured.

For local development either one of these will do, we’re not likely to be under ridiculous load and both Caddy and nginx have docker-gen based proxy solutions that generate the configuration files from Docker container labels instead of having to write our own nginx.conf’s and Caddyfile’s.

In the following sections, we’ll see how to set Caddy up with a Caddyfile, as well as docker-gen based solutions.

A dummy Dockerized Node application to serve over HTTPS

The application we’re going to be reverse-proxying to is a simple Node application which you can find at github.com/HugoDF/docker-compose-local-https/tree/master/caddy/app, github.com/HugoDF/docker-compose-local-https/tree/master/caddy-gen/app and github.com/HugoDF/docker-compose-local-https/tree/master/nginx/app.

It’s a single file “Hello World” using Node.js’ http module (index.js):

const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello world');
});

server.listen(process.env.PORT)

The Dockerfile is similarly simple. It’s a mix of best-practices like setting a WORKDIR, setting NODE_ENV to production using node:10 (not Alpine 😛 because that explodes the moment you have dependencies with C/C++), and not-so-good-practices like hard-coding the PORT in the Dockerfile (at least it’s not hardcoded in the app).

FROM node:10

WORKDIR /node/app

COPY ./index.js /node/app

ENV NODE_ENV production

ENV PORT 8080
EXPOSE 8080

CMD ["node", "index.js"]

Running the app using PORT=5000 node index.js and curl-ing it results in the following:

$ curl http://localhost:5000
Hello world

The goal is to now be able to run curl https://foo.test and get the same response.

We’ll be using this same application for all the examples.

With Caddy and a Caddyfile

Certificates

Caddy expects the certificates for domain.tld to be under the filenames domain.tld.pem (for the public key, ie. certificate) and domain.tld-key.pem (for the private key, ie. the key).

We’re going to be manually writing the Caddy tls directive, so the format doesn’t matter too much, but we’ll follow the Caddy convention for storing certificates. For docs on this directive see https://caddyserver.com/docs/caddyfile/directives/tls.

These are the names that mkcert generates them under as so we can copy them directly. Given mkcert foo.test has run successfully, in the init.sh script we have the following section:

cp ./foo.test.pem ./caddy/certs
cp ./foo.test-key.pem ./caddy/certs

A Caddyfile to forward requests depending on host

The Caddyfile is a text file that configures Caddy. It’s designed to be easy to type and hard to get wrong.

https://caddyserver.com

Since the Caddyfile is a format designed for developer ergonomics, we’ll look at how to set up our Caddy server as a reverse proxy with SSL termination (ie. serving over HTTPS without the application knowing about it) for the foo.test domain.

This file is as follows, you can find it at github.com/HugoDF/docker-compose-local-https/blob/master/caddy/Caddyfile

The proxy / http://web:8080 block tells Caddy to reverse proxy traffic to the web host at port 8080. web will be what we call our application service, Docker Compose deals with the name resolution based on the name of the other services running through Docker Compose.

foo.test {
    log stdout
    # Mkcert - https://github.com/FiloSottile/mkcert
    tls /root/certs/foo.test.pem /root/certs/foo.test-key.pem

    proxy / http://web:8080 {
        transparent
        header_upstream Host  "foo.test"
    }
}

Docker Compose configuration

Here’s the full Docker Compose v3 file to get our Node app running behind Caddy as a reverse proxy using our configuration and certificates.

The 3 important steps to note are:

  1. in volumes, mounting of certs onto /root/certs, which is the location we pointed to in our Caddyfile
  2. in volumes, mounting of Caddyfile to /etc/Caddyfile, so that our Caddyfile gets loaded as the main configuration
  3. in ports, forwarding host port 443 to 2015. Port 2015 is the default port Caddy listens to HTTPS traffic on, we’re binding our machine’s
version: '3'
services:
  caddy:
    image: "abiosoft/caddy:latest"
    volumes:
      - ./certs:/root/certs # to sync mkcert certificates to Caddy
      - ./Caddyfile:/etc/Caddyfile  # to mount custom Caddyfile
    ports:
      - "443:2015"
    depends_on:
     - web

  web:
    build: ./app

About docker-gen

docker-gen is a file generator that renders templates using docker container meta-data.

For our use-case, it means we can configure Caddy and nginx using the labels field on the target container to configure our “virtual hosts”. That means less fiddling with nginx and Caddy configs and more shipping software.

For more information about how that’s achieved for Caddy, see github.com/wemake-services/caddy-gen#usage and for nginx, see github.com/jwilder/nginx-proxy#docker-compose.

With Caddy and docker-gen (no Caddyfile)

Certificates

Caddy expects the certificates for domain.tld to be under the filenames domain.tld.pem (for the public key, ie. certificate) and domain.tld-key.pem (for the private key, ie. the key).

As in the Caddyfile example, we’ll be manually setting Caddy’s tls directive, but we’ll follow the convention (mkcert generates files with this naming convetion as well).

cp ./foo.test.pem ./caddy-gen/certs
cp ./foo.test-key.pem ./caddy-gen/certs

Virtual Host forwarding with docker-gen using labels Using the caddy-gen image means we don’t need a Caddyfile, we just use labels through Docker Compose, here’s the docker-compose.yml (see it at github.com/HugoDF/docker-compose-local-https/blob/master/caddy-gen/docker-compose.yml).

The 3 key points here are:

  1. in the caddy container’s volumes, we mount our host docker.sock to the container’s docker.sock, this is a docker-gen convention to be able to read Docker events (eg. changes to the labels)
  2. in the caddy container’s volumes, we mount the certs directory to /root/certs
  3. in the web container’s labels we set virtual.host=foo.test, virtual.port=8080 and virtual.tls-email=/root/certs/foo.test.pem /root/certs/foo.test-key.pem.

The labels set on web container configuration allow docker-gen to populate a Caddyfile. The virtual.host populates the block’s host, virtual.port the target’s port, the target service’s host is inferred from the container name. virtual.tls-email is injected as the tls directive. I’ve made a Pull Request that was merged (see it at github.com/wemake-services/caddy-gen) to add the ability to set virtual.tls but the updated image hasn’t been pushed to the Docker registry.

version: '3'
services:
  caddy:
    image: "wemakeservices/caddy-gen:latest"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro # needs socket to read events
      - ./certs:/root/certs # to sync mkcert certificates to Caddy
    ports:
      - "443:2015"
    depends_on:
     - web

  web:
    build: ./app
    labels:
      - "virtual.host=foo.test"
      - "virtual.port=8080"
      - "virtual.tls-email=/root/certs/foo.test.pem /root/certs/foo.test-key.pem"

With nginx and docker-gen

Certificates

Nginx-proxy image expects the certificates for domain.tld to be under the filenames domain.tld.cert (for the public key, ie. certificate) and domain.tld.key(for the private key, ie. the key)

These are not the names that mkcert generates them under as so we have to rename them as we copy. Given mkcert foo.test has run successfully, in the init.sh script we have the following section:

cp ./foo.test.pem ./nginx/certs/foo.test.crt
cp ./foo.test-key.pem ./nginx/certs/foo.test.key

Virtual Host forwarding with docker-gen using labels

Using the jwilder/nginx-proxy image allows us to set the proxy options using labels on the target container. See the docker-compose.yml at github.com/HugoDF/docker-compose-local-https/blob/master/nginx/docker-compose.yml.

It’s quite similar to the caddy-gen example.

  1. in nginx-proxy container’s volumes, host’s docker.sock is mounted as the container’s docker.sock, this is a docker-gen convention to be able to read Docker events (eg. changes to the labels)
  2. in nginx-proxy container’s volumes, certs are mounted to etc/nginx/certs which is where nginx-proxy expects the certificates in the domain.tld.crt/domain.tld.key format (it automatically enables HTTPS if it sees such files)
  3. in nginx-proxy container’s ports, we forward host → container, 80:80 and 443:443 (the 443 forward is important to be able to listen to traffic over TLS)
  4. in web container’s environment we set VIRTUAL_HOST=foo.test and VIRTUAL_PORT=8080, which means when nginx-proxy sees traffic for host foo.test it will forward it to web container port 8080

Note that nginx-proxy picks up the SSL/TLS certificates automatically and uses environment rather than labels.

version: '3'
services:
  nginx-proxy:
    image: jwilder/nginx-proxy
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./certs:/etc/nginx/certs
    depends_on:
      - web

  web:
    build: ./app
    environment:
      - VIRTUAL_HOST=foo.test
      - VIRTUAL_PORT=8080

Comparison of the Caddy and nginx examples

All of the setups trade off on the ability to customise the config, ease of setup and verbosity.

Customisability

The setup with a full-blown Caddyfile obviously wins here, since it’s giving access to all Caddy directives and rules. There’s an obvious place to place any custom rules.

The nginx-proxy version comes a close second due to its documentation of how to add global and per-site rules to the proxy using nginx configs.

Ease of setup

The nginx-proxy version comes out on top since it infers whether to enable SSL/TLS based on the certificate/private key pair with the right name, the documentation is extensive and it was written by the creator of docker-gen. The choice to use environment variables means it’s possible for example to set the port on which the web container listens to based on the VIRTUAL_HOST_PORT environment variable.

The Caddy + Caddyfile setup is solid enough, mainly due to simply leveraging Caddy’s nice configuration format + Docker/Docker Compose volume mounts.

Due to inaccurately named labels caddy-gen comes in last, it’s not a good sign when you have to go dig through the template to figure out whether you can inject things other than emails in virtual.tls-email directive.

Verbosity

The caddy-gen solution is actually the least verbose. Just through code volume.

It also is the least customisable.

I personally rate all 3 solutions. I would probably go for either the nginx-proxy or the Caddy with Caddyfile solution in my projects.

unsplash-logoMarkus Spiske

Author

Hugo Di Francesco

Co-author of "Professional JavaScript", "Front-End Development Projects with Vue.js" with Packt, "The Jest Handbook" (self-published). Hugo runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon, Elsevier and (currently) Eurostar.

Get The Jest Handbook (100 pages)

Take your JavaScript testing to the next level by learning the ins and outs of Jest, the top JavaScript testing library.