I recently decided to work on organizing my digital life and thought that a personal website would be a decent start. I wanted something simple that could serve as a centralized index of my different public profiles and also provide a place to document various projects.

This post details the setup that I landed on using a Hugo static index page and a Ghost blog all running in Docker and served through a Traefik reverse proxy. To follow this guide you'll need a server with docker and docker-compose installed.

Traefik

This setup uses a Traefik frontend to manage the hostnames of the backend services and serve everything through https with Let's Encrypt. Traefik is pretty simple to set up to do all of these things. In your home directory, create a folder called traefik.

Inside that folder, we'll have three files: docker-compose.yml for the container configuration, traefik.toml for Traefik's configuration, and acme.json for certificate storage.

First, we'll create a common network that all of our services will have access to. This network will allow Traefik to communicate with both our webserver and our blog containers.

docker network create web

Now, open docker-compose.yml in an editor and enter the following:

version: '3'

services:
  traefik:
    image: traefik:latest
    command: --docker
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./traefik.toml:/traefik.toml
      - ./acme.json:/acme.json
    networks:
      - web
    restart: always

networks:
  web:
    external: True

This compose file defines the Traefik container. We'll expose ports 80 and 443 on the host machine for http and https. This setup defines three volumes to provide Traefik with the information it needs. The first volume is a standard addition that allows Traefik to listen to docker activity and dynamically update routing as services come online. The remaining two volumes map your Traefik configuration and certificate files to files with the same name in the container. We also give Traefik access to our external web network. When we add our other services, they will all be able to talk to eachother on web without touching any external networks.

Create the traefik.toml file and add the following contents.

debug = false

logLevel = "ERROR"
defaultEntryPoints = ["https", "http"]

insecureSkipVerify = false

[entryPoints]
  [entryPoints.http]
  address = ":80"
    [entryPoints.http.redirect]
    entryPoint = "https"
  [entryPoints.https]
  address = ":443"
  [entryPoints.https.tls]
  minVersion = "VersionTLS12"

[retry]

[docker]
endpoint = "unix:///var/run/docker.sock"
domain = "example.com"
watch = true
exposedByDefault = false

[acme]
email = "name@example.com"
storage = "acme.json"
entryPoint = "https"
onHostRule = true
  [acme.httpChallenge]
  entryPoint = "http"

This file is a bit larger but is fairly standard. Basically we're telling Traefik to listen on port 80 but redirect everything to https on port 443. We require a minimum TLS version to prevent insecure connections. The [docker] section describes Traefik's interface to docker through the socket that we mapped earlier. Finally the [acme] section tells Traefik to get certificates from Let's Encrypt for https and store them in the acme.json file. Replace example.com with the desired domain name and name@example.com with a valid email. The email address will be provided to Let's Encrypt, but you most likely will not receive any emails from them.

Finally, create the acme.json file and set its permissions with

sudo touch acme.json && chmod 600 acme.json

This file is where Traefik will store the certificate information that it pulls from Let's Encrypt. Now Traefik is ready to go. The following command starts Traefik in the background.

docker-compose up -d

Hugo

Hugo is a static site generator with a pretty nice selection of free templates. I found the Coder theme in particular to fit what I wanted for my own website, so this guide will use that theme. You could swap that theme out for your own choice fairly easily.

Start by installing Hugo by following the instructions here. Now, in your home directory create a folder called site and inside that folder create another folder called index. In a terminal, move into the index directory and run

hugo new site dev

This command will create a folder called dev containing a barebones environment for building your static page. Now, download the theme from here and extract it to index/dev/themes/hugo-coder. Create a file called config.toml in index/dev/. This file will contain all of the settings that define your website. The Coder theme that we're using here gives one example. I'm including a slightly smaller example below that removes the page footer and extra links. Modifying these settings will change the pages that Hugo ultimately generates.

baseurl = "http://www.example.com"
title = "johndoe"
theme = "hugo-coder"
languagecode = "en"
defaultcontentlanguage = "en"

paginate = 20
canonifyurls = true
pygmentsstyle = "bw"
pygmentscodefences = true
pygmentscodefencesguesssyntax = true
disqusShortname = "yourdiscussshortname"

[params]
    author = "John Doe"
    info = "Full Stack DevOps and Magician"
    description = "John Doe's personal website"
    keywords = "blog,developer,personal"
    avatarurl = "images/avatar.jpg"

    favicon_32 = "/img/favicon-32x32.png"
    favicon_16 = "/img/favicon-16x16.png"
    footercontent = ""
    hidecredits = true
    hidecopyright = true
    rtl = false

# Social links
[[params.social]]
    name = "Github"
    icon = "fab fa-github fa-2x"
    weight = 1
    url = "https://github.com/johndoe/"
[[params.social]]
    name = "Twitter"
    icon = "fab fa-twitter fa-2x"
    weight = 3
    url = "https://twitter.com/johndoe/"

# Menu links
[[menu.main]]
    name = "About"
    weight = 1
    url = "/about/"

We can add more links and social icons by duplicating the [[params.social]] and [[menu.main]] blocks, respectively. To add more pages, cd to index/dev and run

hugo new pagename.md

and modify the corresponding file. These pages use markdown for formatting so they're pretty easy to set up. When everything is ready, the following command will build the site in the index/public directory.

hugo -d {HOME_PATH}/index/public/

NGINX

Now that the static site is ready, we'll need to set up a webserver to serve these files. I went with NGINX for mine because it seemed to be the easiest to set up and uses very little resources when running.

Return to the site directory and create a file called docker-compose.yml. This file will manage all of the services related to our website but for now we will just set up the Apache server. Add the following to the docker-compose.yml file.

version: "3"
services:

  index:
    image: nginxinc/nginx-unprivileged:latest
    volumes:
      - ./index/public:/usr/share/nginx/html
      - ./index/nginx.conf:/etc/nginx/conf.d/default.conf
    networks:
      - web
    restart: always
    labels:
      - "traefik.backend=index"
      - "traefik.frontend.rule=Host:www.example.com"
      - "traefik.docker.network=web"
      - "traefik.port=9090"

networks:
  web:
    external: true

This compose file pulls the latest version of the NGINX image, nginxinc/nginx-unprivileged, and provides it with our website that we built previously in index/public. Notice that we also give this service access to the web network so that it can communicate with Traefik. The labels: section defines how Traefik will interact with this container. Replace www.example.com with the desired host name -- Traefik will only respond if the hostname is correct.

For NGINX to server our website properly we'll also need a configuration file. Create index/nginx.conf and add the following.

server {
    root /usr/share/nginx/html/;
    index index.html;
    listen 9090;
    error_page 404 /404.html;
    location = /404.html {
        root /usr/share/nginx/html;
        internal;
    }
}

This configuration defines a simple http server that will deliver our static pages on port 9090 in the container. We also use the error_page directive to point NGINX to the 404 page that Hugo created.

Ghost

Finally, we will set up our Ghost blog service. This bit is pretty simple and doesn't require any files beyond our existing docker-compose.yml. The blog data will be kept separate from the index page, so create a blog folder inside site/.

Now add the Ghost container definition to the docker-compose.yml file in the services: section.

  ghost:
    image: ghost:latest
    restart: always
    volumes:
      - ./blog:/var/lib/ghost/content
    environment:
      - url=https://blog.example.com
    networks:
      - web
    labels:
      - "traefik.backend=ghost"
      - "traefik.frontend.rule=Host:blog.example.com"
      - "traefik.docker.network=web"
      - "traefik.port=2368"

This section is pretty similar our Apache setup. We're pulling the latest ghost image and providing it access to our blog folder for storing persistent files. We give the Ghost service access to our web network and define the same Traefik rules as before except now the host is blog.example.com. Ghost also requires the url environment variable to contain the base url for the blog, so we have an environment: section with one variable.

Start it up!

To start Apache and Ghost, run

docker-compose up -d

in the site directory. If everything is working properly, www.example.com will serve up the static Hugo page and blog.example.com will show Ghost's initial setup page.

In the future, to update the images run the following command.

docker-compose down && docker-compose pull && docker-compose up -d

This command brings our services down, checks for new images and pulls them if necessary, and brings everything back up.

Conclusion

This guide steps through a simple static web page and blog implementation using Traefik, Hugo static pages, and Ghost. See my particular implementation on GitHub.