docker, nginx, and letsencrypt - website setup for that sweet, sweet HTTPS!

docker, nginx, and letsencrypt - website setup for that sweet, sweet HTTPS!

I’ve used letsencrypt in the past for free certs, but I have not successfully utilized it since moving over to docker/kestrel/nginx. That all changed today, and I had a hell of a time figuring out what I’m doing to get it working.

This whole unix, docker, nginx, stuff is pretty new (to me), so maybe it’s just something simple I was missing the whole time. Nonetheless, I’m hoping this will help someone else, or me several months down the road if I decide to do it again.

Original Setup

I have a .net core website, being hosted via kestrel, running on docker, with a reverse proxy via nginx. Up until now, that reverse proxying from nginx was only working over http/port 80. I don’t know a whole lot about reverse proxies, but from the sounds of it, it can take in requests, and forward them to a specific location on behalf of the requester. In my case, the nginx container receives http requests, and nginx forwards that request onto my kestrel hosted .net core site. Is that right? Hopefully!

As mentioned previously, the nginx was only working with http traffic, and I was having a lot of trouble getting it working with https, the original configuration is as follows:

docker-compose:

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
version: '3.6'
services:

kritner-website-web:
image: ${DOCKER_REGISTRY}/kritnerwebsite
expose:
- "5000"
networks:
- frontend
restart: always
container_name: kritnerwebsite_web
kritner-website-nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ../src/nginx/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- kritner-website-web
networks:
- frontend
restart: always
container_name: kritnerwebsite_nginx

networks:
frontend:

In the docker-compose file, i’m using two separate containers — the website, which exposes port 5000 (on the docker network, not publicly), and nginx which operates on port 80.

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
worker_processes 4;

events { worker_connections 1024; }

http {
sendfile on;

upstream app_servers {
server kritner-website-web:5000;
}

server {
listen 80;

location / {
proxy_pass [http://app_servers](http://app_servers);
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
}

In the config file, we’re setting up an upstream server with the same name that we’re calling our container service from the docker-compose file kritner-website-web:5000.

Note, all the above can be found at this commit point on my website’s repository.

Enter HTTPS

Letsencrypt is a certificate authority that offers free certs to help secure your website. Why is HTTPS via TLS important? Well, there’s a whole lot to that, and how it works, but the basic idea is the users traffic is encrypted on either end prior to being sent to the other end. This means if you’re on public wifi, and on https, someone that was “sniffing the wire” so to speak, would see that traffic is occurring, but not the content of said traffic, since both ends are encrypting/decrypting said traffic with the same encryption key. If you were on an http site, this traffic would be sent back and forth in plain text — meaning your data is in danger of being eavesdropped on! Maybe I’ll write a bit more about encryption at some point (note to self) — especially since it’s something I’m doing as my day job!

So letsencrypt — it’s a service I’ve used before, and there are various implementations to try to make it as easy as possible to use. Through research for this post, I happened upon:

ACME Client Implementations - Let’s Encrypt - Free SSL/TLS Certificates

Although I hadn’t really found this page until just now, maybe it would have been useful prior to my adventure beginning. I wanted to use letsencrypt along with my docker container website, and nginx, with as little maintenance as possible, as letsencrypt certificates are only good for 90 days. In my research, I happened upon a docker image linuxserver/letsencrypt that promises to utilize nginx, letsencrypt certificate generation, AND auto renewal. Sounds awesome! While the documentation of the image seems mostly adequate, at least I would assume, for someone well versed in all of this process; I found it to be lacking. The whole setup process took me some time to figure out, hence this post, to hopefully help out the next person, or me in the future!

Linux Server.IO logo

Struggles

The things I most struggled with when getting this linuxserver/letsencrypt image up and working were:

  • How docker volumes “work” and their relationship with this container
  • How to set volumes up to utilize my configuration (related to above point) — I was initially having a lot of trouble figuring out why my settings changed on the container were being changed back on reloading of said container (because that’s what they’re supposed to do)
  • How to set up the correct nginx configuration — where to put it, and what to put in it.

Docker volumes

Docker volumes (doc):

Volumes are the preferred mechanism for persisting data generated by and used by Docker containers. While bind mounts are dependent on the directory structure of the host machine, volumes are completely managed by Docker. Volumes have several advantages over bind mounts

The letsencrypt has a lot of configuration to go along with it, it took a while for me to realize, but I needed a volume that mapped from a directory on the docker host to a specific directory on the letsencrypt image. I eventually accomplished this in the compose file like so:

1
2
3
volumes:
- ${DOCKER_KRITNER_NGINX}:/config
- ./nginx.conf:/config/nginx/site-confs/default

The first item in the array (${DOCKER_KRITNER_NGINX}:/config) takes a new environment variable that maps the host directory (defined in the variable) to the /config within the docker container itself. This means that the docker host (at the env var path) will contain the same config as the docker container at the secondary portion of the volume mapping (/config)

The second item (./nginx.conf:/config/nginx/site-confs/default) maps my local repositories nginx.conf file (the file where I set up the reverse proxy) to override the /config/nginx/site-confs/default file on the docker host and container.

The full list of files that I ended up needing to modify for my particular situation was:

  • /config/dns-conf/dnsimple.ini
  • /config/nginx/site-confs/default

The dnsimple.ini configuration was add my api key, and the …/default houses the nginx configuration.

The final default configuration I ended up with is:

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
upstream app_servers {
server kritnerwebsite:5000;
}

## Version 2018/09/12 - Changelog: https://github.com/linuxserver/docker-letsencrypt/commits/master/root/defaults/default

# listening on port 80 disabled by default, remove the "#" signs to enable
# redirect all traffic to https
server {
listen 80;
server_name kritnerwebsite;
return 301 https://$host$request_uri;
}

# main server block
server {
listen 443 ssl;

# enable subfolder method reverse proxy confs
include /config/nginx/proxy-confs/*.subfolder.conf;

# all ssl related config moved to ssl.conf
include /config/nginx/ssl.conf;

# enable for ldap auth
#include /config/nginx/ldap.conf;

client_max_body_size 0;

location / {
proxy_pass http://app_servers;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}

}

# enable subdomain method reverse proxy confs
include /config/nginx/proxy-confs/*.subdomain.conf;
# enable proxy cache for auth
proxy_cache_path cache/ keys_zone=auth_cache:10m;

There are a few changes from the default that was there, which I’ll try to highlight next.

1
2
3
upstream app_servers {
server kritnerwebsite:5000;
}

Above is pretty cool, since docker has its own internal DNS (I guess?) you can set up an upstream server by the containers name, in my case “kritnerwebsite” (note i changed it from earlier in the post, which was “kritner-website-web”).

1
2
3
4
5
6
7
# listening on port 80 disabled by default, remove the "#" signs to enable
# redirect all traffic to https
server {
listen 80;
server_name kritnerwebsite;
return 301 https://$host$request_uri;
}

Uncommented out this section from the default, applied my server_name of “kritnerwebsite”

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
# main server block
server {
listen 443 ssl;

# enable subfolder method reverse proxy confs
include /config/nginx/proxy-confs/*.subfolder.conf;

# all ssl related config moved to ssl.conf
include /config/nginx/ssl.conf;

# enable for ldap auth
#include /config/nginx/ldap.conf;

client_max_body_size 0;

location / {
proxy_pass http://app_servers;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}

}

In the above, it’s mostly from the “default” save for “location” and everything within that object. Here, we’re setting up the reverse proxy to forward requests to “/” (anything) to our http://app_servers (kritnerwebsite as per our upstream).

docker-compose.yml

Our docker compose file didn’t change a whole lot from the initial, but there were a few notable changes, which I’ll also get into describing:

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
version: '3.6'
services:

nginx:
image: linuxserver/letsencrypt
ports:
- "80:80"
- "443:443"
volumes:
- ${DOCKER_KRITNER_NGINX}:/config
- ./nginx.conf:/config/nginx/site-confs/default
depends_on:
- kritnerwebsite
networks:
- frontend
container_name: nginx
environment:
- PUID=1001 # get on dockerhost through command "id <user>""
- PGID=1001
- EMAIL=kritner@gmail.com
- URL=kritner.com
- SUBDOMAINS=www
- TZ=America/NewYork
- VALIDATION=dns # using dns validation
- DNSPLUGIN=dnsimple # via dnsimple, note there is additional configuration require separate from this file
# - STAGING=true # this should be uncommented when testing for initial success, to avoid some rate limiting

kritnerwebsite:
image: ${DOCKER_REGISTRY}/kritnerwebsite
networks:
- frontend
expose:
- "5000"
restart: always
container_name: kritnerwebsite

networks:
frontend:

for the new parts:

1
2
nginx:
image: linuxserver/letsencrypt

Using a different image — linuxserver/letsencrypt instead of nginx. This image has nginx included, but also certbot, along with a cronjob to run certbot at application start.

1
2
3
ports:
- "80:80"
- "443:443"

Now we’re using both http and https ports (though note, we’re redirecting http calls to https via the nginx config.

1
2
3
volumes:
- ${DOCKER_KRITNER_NGINX}:/config
- ./nginx.conf:/config/nginx/site-confs/default

Already discussed earlier in the post, using these volumes to properly set up the nginx configuration, with our dnsimple api key, as well as our reverse proxying to the kritnerwebsite.

1
2
3
4
5
6
7
8
9
10
environment:
- PUID=1001 # get on dockerhost through command "id <user>"
- PGID=1001
- EMAIL=kritner@gmail.com
- URL=kritner.com
- SUBDOMAINS=www
- TZ=America/NewYork
- VALIDATION=dns # using dns validation
- DNSPLUGIN=dnsimple # via dnsimple, note there is additional configuration require separate from this file
# - STAGING=true # this should be uncommented when testing for initial success, to avoid some rate limiting

Environment variables needed as per the letsencrypt documentation at:

linuxserver/letsencrypt

  • PUID/PGID — get on dockerhost through command “id
  • Email — well, your email (used for cert expiration emails apparently)
  • URL — the main domain URL
  • subdomains — any subdomains to the URL to be certified
  • TZ — timezone
  • Validation — the type of validation to do — I’m using DNSimple, so i needed DNS in this field. Other options are html, tls-sni
  • dnsplugin — dnsimple — other options are cloudflare, cloudxns, digitalocean, dnsmadeeasy, google, luadns, nsone, rfc2136 and route53 as per the letsencrypt documentation
  • Staging=true — I used this for testing out all my various attempts prior to getting it working. letsencrypt has rate limiting when not running in staging mode (or at least in staging it’s harder to run up against).

all the above changes, experimenting, failing, and then finally succeeding can be found in this pull request:

Nginx by Kritner · Pull Request #24 · Kritner/KritnerWebsite

The final result?

Awww yeah

and from https://www.ssllabs.com/

Not an “A+”, but really not bad for using one pre-built docker image for my HTTPs needs!

Related:

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×