Docker Compose Local HTTPS with nginx or Caddy
Tags: docker, webdev, https, letsencrypt
Published: 2019-03-25 08:03:52

---
title: Docker Compose Local HTTPS with nginx or Caddy
published: true
tags: docker, webdev, https, letsencrypt
canonical_url: https://codewithhugo.com/docker-compose-local-https/
---

A solution to serve your Dockerized application on Docker Compose with TLS/SSL locally by leveraging a HTTPS reverse-proxy and [mkcert](https://github.com/FiloSottile/mkcert).

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


##  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”](https://12factor.net/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”](https://12factor.net/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/](https://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](https://github.com/FiloSottile/mkcert#mkcert)

To install `mkcert`, see [github.com/FiloSottile/mkcert#installation](https://github.com/FiloSottile/mkcert#installation) for your platform.

Once installed, you should run:
`mkcert -install`

## Skip to the code

### Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop) (or Docker + Docker Compose)
- [mkcert](https://github.com/FiloSottile/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).

```sh
127.0.0.1 foo.test
```

### About Caddy and nginx


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


> [nginx](https://www.nginx.com/) 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](https://github.com/jwilder/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](https://github.com/HugoDF/docker-compose-local-https/tree/master/caddy/app), [github.com/HugoDF/docker-compose-local-https/tree/master/caddy-gen/app](https://github.com/HugoDF/docker-compose-local-https/tree/master/caddy-gen/app) and [github.com/HugoDF/docker-compose-local-https/tree/master/nginx/app](https://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`):

```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).


```dockerfile
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:


```sh
$ 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/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:


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

#### A Caddyfile to forward requests depending on host


> The [Caddyfile](https://caddyserver.com/tutorial/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](http://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.


```nginx
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 


```yaml
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](https://github.com/wemake-services/caddy-gen#usage) and for nginx, see [github.com/jwilder/nginx-proxy#docker-compose](https://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).


```sh
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](https://github.com/wemake-services/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](https://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](https://github.com/wemake-services/caddy-gen/pull/19)) to add the ability to set `virtual.tls` but the updated image hasn’t been pushed to the Docker registry.


```yaml
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:


```sh
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](https://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.


```yaml
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.



[Download free do whatever you want high-resolution photos from Markus Spiske](https://unsplash.com/@markusspiske?utm_medium=referral&amp;utm_campaign=photographer-credit&amp;utm_content=creditBadge)