Austin Morlan


Code Contact LinkedIn



Easy SSL Websites with Docker and LetsEncrypt

Introduction


Like I mentioned in the previous post about a self-hosted life, I’ve grown fond of Docker for website management. There’s something very satisfying about running some commands and getting the same result every time (i.e., purely functional) and knowing there’s no stateful cruft hanging around from previous iterations. Enter Docker.

Docker creates containers that are reproducible based on a file that tells it exactly what to do so that it does the same thing every time. So for something like Nextcloud, its Dockerfile tells it how to install itself with specific sources and commands and so it should produce the same output every time you process the Dockerfile.

There are a few reasons why this is useful from a website administration perspective.

Easy Deployment


It’s relatively easy to deploy things. For example, Nextcloud. A normal Nextcloud install would require spinning up a webserver like Nginx, a database like MySQL, putting all of the proper files in their proper locations, and then configuring things how you like them. And that’s just Nextcloud.

When you’ve got Nextcloud, Firefly III, Gitea, Hugo, and more, it’s nice to have all of the installation and configuration done by Docker instead of by hand.

Easy SSL


Using a LetsEncrypt Nginx proxy container, it’s easy to get SSL certs for each of your subdomains and have them renew automatically without any hassle. You just configure Docker appropriately once and you’re good to go.

Easy Updating


Normally you’d have to manually update all of your services, hoping nothing breaks when you do. Not to mention updating your actual server itself and all of the underlying packages that go with it (e.g., if you’re running on Ubuntu and do an apt upgrade, fearing nothing breaks your site).

With Docker (specifically Docker Compose) you tell it to update without fear of anything breaking because the images have everything they need to run inside of them and have already been tested by the maintainer. Aside from maybe a database (which you can also Dockerize), they’re self-contained.

Easy Backups


If you properly set up Docker to use volumes for each of your containers, then all of their stateful information (i.e., the data that must persist across deletions and creations of your container) can be put onto a separate drive or partition which can be backed up along with the Docker Compose files. You could then wipe your entire machine, remount the partition, install Docker, run a single command, and everything will be back the way it was before.

How?


Let’s use Nextcloud as an example.

I’ll assume that you can figure out how to install Docker and Docker Compose, that you can read the Docker docs if you’re confused, and that you know how to create a domain name and make it point to the IP address of your server. I primarily just to want to show how to integrate Let’s Encrypt with Nextcloud, because that was the biggest hurdle for me.

First you need to get nginx-proxy which will work in concert with the other containers to properly direct requests to different containers:

mkdir -p /volumes/nginx
docker network create ssl-proxy
curl https://raw.githubusercontent.com/jwilder/nginx-proxy/master/nginx.tmpl > /volumes/nginx/nginx.tmpl

Then you need to create a Docker Compose file that describes Nextcloud, its database, and the SSL helpers. Here’s an example named docker-compose.yml.

version: '3'

services:
  nginx:
    image: nginx:1.15.8-alpine
    labels:
        com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true"
    container_name: nginx
    restart: always
    ports:
      - "{YOUR_IP}:80:80"
      - "{YOUR_IP}:443:443"
    volumes:
      - /volumes/nginx/conf.d:/etc/nginx/conf.d
      - /volumes/nginx/vhost.d:/etc/nginx/vhost.d
      - /volumes/nginx/html:/usr/share/nginx/html
      - /volumes/nginx/certs:/etc/nginx/certs:ro
      - /volumes/nginx/htpasswd:/etc/nginx/htpasswd:ro
    logging:
      options:
        max-size: "4m"
        max-file: "10"

  docker-gen:
    image: jwilder/docker-gen:0.7.3
    command: -notify-sighup nginx -watch -wait 5s:30s /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf
    container_name: docker-gen
    restart: always
    volumes:
      - /volumes/nginx/conf.d:/etc/nginx/conf.d
      - /volumes/nginx/vhost.d:/etc/nginx/vhost.d
      - /volumes/nginx/html:/usr/share/nginx/html
      - /volumes/nginx/certs:/etc/nginx/certs:ro
      - /volumes/nginx/htpasswd:/etc/nginx/htpasswd:ro
      - /volumes/nginx/nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro
      - /var/run/docker.sock:/tmp/docker.sock:ro
    logging:
      options:
        max-size: "2m"
        max-file: "10"

  letsencrypt:
    depends_on:
      - nginx
      - docker-gen
    image: jrcs/letsencrypt-nginx-proxy-companion:v1.9.1
    container_name: letsencrypt
    restart: always
    volumes:
      - /volumes/nginx/conf.d:/etc/nginx/conf.d
      - /volumes/nginx/vhost.d:/etc/nginx/vhost.d
      - /volumes/nginx/html:/usr/share/nginx/html
      - /volumes/nginx/certs:/etc/nginx/certs:rw
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      NGINX_DOCKER_GEN_CONTAINER: docker-gen
      NGINX_PROXY_CONTAINER: nginx
    logging:
      options:
        max-size: "2m"
        max-file: "10"

services:
  nextcloud-db:
    container_name: nextcloud-db
    image: mariadb:10.3.12
    restart: unless-stopped
    volumes:
      - /volumes/nextcloud-db:/var/lib/mysql
    environment:
      MYSQL_DATABASE: nextcloud
      MYSQL_ROOT_PASSWORD: {PASSWORD_1}
      MYSQL_USER: user
      MYSQL_PASSWORD: {PASSWORD_2}

  nextcloud:
    depends_on:
      - nextcloud-db
      - letsencrypt
    container_name: nextcloud
    image: nextcloud:15.0.2
    restart: unless-stopped
    volumes:
      - /volumes/nextcloud:/var/www/html
      - /volumes/nextcloud/config:/var/www/html/config
      - /volumes/nextcloud/custom_apps:/var/www/html/custom_apps
      - /volumes/nextcloud/data:/var/www/data
    environment:
      NEXTCLOUD_ADMIN_USER: user
      NEXTCLOUD_ADMIN_PASSWORD: {PASSWORD_3}
      NEXTCLOUD_DATA_DIR: /var/www/data
      VIRTUAL_HOST: {DOMAIN_NAME}
      LETSENCRYPT_HOST: {DOMAIN_NAME}
      LETSENCRYPT_EMAIL: {YOUR_EMAIL}
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: user
      MYSQL_PASSWORD: {PASSWORD_2}
      MYSQL_HOST: nextcloud-db

networks:
  default:
    external:
       name: ssl-proxy

This makes use of five different Docker images:

nginx, docker-gen, and letsencrypt-nginx-proxy-companion all work together to not only request (and renew) SSL certs for each container’s domain name, but also to direct different domain names to different Docker containers. So a request to {DOMAIN_NAME} will be directed to the Nextcloud container.

nextcloud is the container with all of the required Nextcloud files.

mariadb is the database that the Nextcloud container uses.

A few things to notice:

With that, you can then run:

docker-compose -f docker-compose.yml up -d

This will download all of the images and then create the containers. It takes some time to request the certs but after a few minutes you should then be able to go to https://{DOMAIN_NAME} and it will load up Nextcloud.

More


That’s just the beginning. You can leave the nginx, docker-gen, and letsencrypt-nginx-proxy-companion containers untouched and then continue to add on more containers for other services you want (e.g., Gitea, Firefly, etc). Most services have been Dockerized, either officially or by someone else. Just make sure you have:

depends_on:
  - letsencrypt

and

VIRTUAL_HOST: {SOME_DOMAIN_NAME}
LETSENCRYPT_HOST: {SOME_DOMAIN_NAME}
LETSENCRYPT_EMAIL: {YOUR_EMAIL}

for each container you add.

Backing Up for DigitalOcean


If you’re using DigitalOcean and have all of your persistent data (i.e., your Docker volumes) on a DigitalOcean storage volume, you can take periodic snapshots of that volume in case something ever goes wrong. You could do it manually whenever, but I’m lazy so I wrote a Python script that uses a library to connect to DigitalOcean’s API and take a snapshot if it’s been over a week since the last snapshot:

import digitalocean
import time
import datetime
import sys


API_KEY = {YOUR_API_KEY}
DROPLET_ID = {DROPLET_ID}
VOLUME_ID = {VOLUME_ID}
TIME_EXCEED = 7


manager = digitalocean.Manager(token=API_KEY)
droplet = manager.get_droplet(DROPLET_ID)
volume = manager.get_volume(VOLUME_ID)

print
print "##############################"
print "GETTING LIST OF SNAPSHOTS"
print "##############################"
print

snaps = volume.get_snapshots()
snaps.sort(key=lambda r: r.created_at)

for i,snap in enumerate(snaps):
    print "[" + str(i) + "] " + snap.created_at


print
print "##############################"
print "CHECKING IF SNAPSHOT NEEDED"
print "##############################"
print

today_time = datetime.datetime.utcnow()
last_snap_time = datetime.datetime.strptime(snaps[-1].created_at, "%Y-%m-%dT%H:%M:%SZ")

time_diff = today_time - last_snap_time
time_since_last_snap = int(time_diff.total_seconds() / 86400)

print "Days since last snap: " + str(time_since_last_snap)

if time_since_last_snap < TIME_EXCEED:
    print
    print "No snapshot needed"
else:
    print
    print "Snapshot needed"


    print
    print "##############################"
    print "CHECKING DROPLET STATUS"
    print "##############################"
    print

    if droplet.status == "active":
        print "Droplet: ON"
    elif droplet.status == "off":
        print "Droplet: OFF"
    else:
        print "Droplet: " + droplet.status


    if droplet.status == "active":
        print
        print "Shutting down droplet..."

        droplet.shutdown()

        time.sleep(30)


    droplet = manager.get_droplet(DROPLET_ID)

    if droplet.status != "off":
        print
        print "Could not shutdown droplet"
        sys.exit(1)


    print
    print "##############################"
    print "TAKE A SNAPSHOT"
    print "##############################"
    print

    if len(snaps) == 10:
        print "Deleting oldest snap: " + snaps[0].created_at
        print

        snaps[0].destroy()

        time.sleep(5)


    snap_name = str(time.strftime("%Y%m%d%H%M%S", time.gmtime()))

    print "Creating snapshot named " + snap_name

    volume.snapshot(snap_name)

    time.sleep(5)


    print
    print "##############################"
    print "GET LIST OF NEW SNAPSHOTS"
    print "##############################"
    print

    new_snaps = volume.get_snapshots()
    new_snaps.sort(key=lambda r: r.created_at)

    for i,snap in enumerate(new_snaps):
        print "[" + str(i) + "] " + snap.created_at


    time.sleep(5)


    print
    print "##############################"
    print "POWER ON DROPLET"
    print "##############################"
    print

    droplet.power_on()

    time.sleep(30)

    droplet = manager.get_droplet(DROPLET_ID)

    if droplet.status != "active":
        print "Could not power on droplet"