I recently reconfigured my website to use Docker instead of installing everything manually. The main challenges I wanted to overcome are automating the certificate generation, sandboxing everything enough to not cause security issues, issuing wildcard certs with DNS challenges, and doing it all through docker to make updates and migrations consistent and easy.

I’ve seen several guides on setting up nginx and certbot using docker, however almost all of them use the HTTP acme challenge instead of the DNS challenge, which is easier to set up, assuming your DNS server is supported. DNS challenges are also required for issuing wildcard certs.

This guide is not supposed to hold your hand but rather function as a reference for setting up this scenario. It is expected that you have basic experience with all these things already.

Basic setup

You’ll need several prerequisites for this. First and foremost, you’ll need access to a linux server running your distro of choice. You’ll want to make sure your DNS is already set up to point at it, and configure it up to the point that you’d start doing your traditional installs of stuff like nginx and certbot. Your DNS service will also need to be one of the ones supported here. You will also want to check the docs for your appropriate plugin to make sure you get your API keys configured properly.

Beyond that, you’ll want to have docker and docker-compose.

For context, I am using ubuntu server 18.04 on a linode VPS, and the docker apt repos.

Getting certs

The first thing we want to do is get our certs. We can do this using the letsencrypt docker image and docker-compose. I’ll start with my docker-compose.yml and break it down from there.

version: '3'
services:
  certbot:
    image: certbot/dns-linode
    volumes:
      - /data/certbot/conf:/etc/letsencrypt
    command: "certonly
              -n
              --agree-tos
              -m mail@mikesbytes.org
              --dns-linode
              --dns-linode-credentials /etc/letsencrypt/linode.ini
              --dns-linode-propagation-seconds 1000
              -d \"*.mikesbytes.org\" -d mikesbytes.org"

We can see there’s a number of things happening here. First, is the image. You can see that I’m using the dns-linode image instead of the regular certbot image. Depending on which DNS plugin you need to use, you will need to select the appropriate image. You can find a list of them on the certbot dockerhub page.

Obviously, we don’t want our certs to live inside the container or nothing can use them. I’ve exported a volume to /data/certbot/conf/ which will allow us to read the certs from other locations. There are some permissions issues here, but we will solve those soon.

The next thing to look at is the options for certbot. We want to use certonly since we don’t want to install configs for nginx etc. We also need to specify the non-interactive options with -n --agree-tos -m email@here.com. For the DNS options, these might vary based on which service you use, but they should be fairly similar. The credentials file is stored in /data/certbot/conf as linode.ini, and mind looks something like this:

dns_linode_key = XXXXXXXXXXXXXXXXXXXXXXXXX
dns_linode_version = 4
rspserver = https://acme-v02.api.letsencrypt.org/directory

This config will vary based on your provider, check the docs. One important thing you will want to include however is that rspserver line. This specifies the acme V2 challenge API, which will allow you to issue wildcard certs. This may not be necessary in the future but my previous attempts failed without this line. One very very important step is to set the permissions on this since it contains your API keys. I set mine with chown 700. Certbot will complain if you have anything less, as it should.

The second to last flag is the propogation time, which allows us some leeway for DNS records to propogate. Once again, check the docs for your plugin.

Finally, the domains. We want to grab our base domain, in my case mikesbytes.org and the wildcard, *.mikesbytes.org. That way if we want to host another site or service under a subdomain, we don’t have to generate new certs.

Once you have that stuff set up, you’ll want to run docker-compose up and wait for your certs to generate.

Running NGINX in docker as a non-root user

Now that we have our certs, we want to configure our actual site. We could easily spin up a plain old nginx image, however doing this is not the most secure as it will run as the root user

Let’s take a look at the docker-compose.yml I’ve created for nginx

version: '3'
services:
  nginx:
    build:
      dockerfile: Dockerfile
      context: ./nginx-docker-container
    ports:
      - "80:8080"
      - "443:4430"
    volumes:
      - /data/certbot/conf:/etc/letsencrypt
      - /data/nginx:/etc/nginx/conf.d
      - /data/www:/var/www

The first thing to note is that this does not use the stock nginx image. We have to modify it to make it work as a non-root user, but before that we need to actually create the non-root user. I created a user called www with the UID/GID of 1001 on my system. We’ll want to run nginx as this user. There is a user config option for docker-compose, however it won’t work with the stock nginx image, so let’s take a look at the Dockerfile I created in the nginx-docker-container folder.

FROM nginx:stable 
 
COPY ./nginx.conf /etc/nginx/nginx.conf 
 
RUN useradd -u 1001 www 
 
RUN touch /var/run/nginx.pid && \ 
  chown -R www:www /var/run/nginx.pid && \ 
  chown -R www:www /var/cache/nginx 

USER www

We can see this does a few things. First, it copies a custom nginx.conf file in, which looks like this

worker_processes  1; 
error_log  /var/log/nginx/error.log warn; 
pid        /var/run/nginx.pid; 
 
events { 
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

You can modify this however you want. Don’t put your actual site configs in here though, those go in .../nginx/conf.d.

It also adds the www user to the container, and modifies the permissions on the appropriate files for access. It also directs the container to run as that user.

Configuring a site

At this point, nginx should run, but it won’t have your site. We need to configure one. I create a file in /data/nginx/default.conf that looks like this:

server {
    listen       8080;
    server_name  mikesbytes.org;
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen      4430 ssl;
    server_name mikesbytes.org;

    ssl_certificate /etc/letsencrypt/live/mikesbytes.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mikesbytes.org/privkey.pem;

    location / {
        root /var/www/htdocs;
        index index.html;
    }
}

Some things to note here are the ports. Since we aren’t running as root, we can’t bind to lower number ports, but we redirected them in docker-compose. You’ll want to modify the server_names, and cert paths. This config also auto-redirects HTTP requests to HTTPS.

At this point you’re almost there, however if you try to run your nginx server with docker-compose, you’ll get permission denied errors on your certs because the default perms disallow reads for non-root users. The easiest fix for this is to run chmod 755 /data/certbot/conf/live which will grant read perms to other users. A slightly better option would be to create a certs group on your system, add the root user and your www user to it, chown the live folder with root:certs, and enable only group read instead of group and other read.

At this point, you should be able to spin up your nginx container and browse to your site, which leaves only one more thing.

Cert renewals

The full docker-compose file I use looks like this:

version: '3' 
services: 
  nginx:
    build:
      dockerfile: Dockerfile
      context: ./nginx-docker-container
    ports:
      - "80:8080"
      - "443:4430"
    volumes:
      - /data/certbot/conf:/etc/letsencrypt
      - /data/nginx:/etc/nginx/conf.d
      - /data/www:/var/www
    command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
  certbot:
    image: certbot/dns-linode
    volumes:
      - /data/certbot/conf:/etc/letsencrypt
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

You can see there’s some junk in the command and entrypoint options. These will have certbot check for renewals every 12 hours, and nginx will refresh it’s config every 6 hours. I got these from the guide located here.

And with that, we have basically the setup you’re currently browsing this site on.