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.
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
or127.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.
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:
- in
volumes
, mounting ofcerts
onto/root/certs
, which is the location we pointed to in our Caddyfile - in
volumes
, mounting ofCaddyfile
to/etc/Caddyfile
, so that our Caddyfile gets loaded as the main configuration - 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:
- in the
caddy
container’svolumes
, we mount our hostdocker.sock
to the container’sdocker.sock
, this is a docker-gen convention to be able to read Docker events (eg. changes to the labels) - in the
caddy
container’svolumes
, we mount thecerts
directory to/root/certs
- in the
web
container’slabels
we setvirtual.host=foo.test
,virtual.port=8080
andvirtual.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.
- in
nginx-proxy
container’svolumes
, host’sdocker.sock
is mounted as the container’sdocker.sock
, this is a docker-gen convention to be able to read Docker events (eg. changes to the labels) - in
nginx-proxy
container’svolumes
, certs are mounted toetc/nginx/certs
which is where nginx-proxy expects the certificates in thedomain.tld.crt
/domain.tld.key
format (it automatically enables HTTPS if it sees such files) - in
nginx-proxy
container’sports
, we forward host → container,80:80
and443:443
(the443
forward is important to be able to listen to traffic over TLS) - in
web
container’senvironment
we setVIRTUAL_HOST=foo.test
andVIRTUAL_PORT=8080
, which means when nginx-proxy sees traffic for hostfoo.test
it will forward it toweb
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.
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.
orJoin 1000s of developers learning about Enterprise-grade Node.js & JavaScript