Tevin Jeffrey
Tevin Jeffrey

Thoughts and stories.

Tevin Jeffrey
Author

My name is Tevin Jeffrey and I'm a Software Engineer. This is my blog about android, software architecture, user interfaces, user experience and sometimes video games.

Share


Twitter


Tevin Jeffrey

How to setup a Ghost blog with Docker

Tevin JeffreyTevin Jeffrey
Ghost is a platform dedicated to one thing: Publishing. It's beautifully designed, completely customisable and completely Open Source. Ghost allows you to write and publish your own blog, giving you the tools to make it easy and even fun to do.

Self hosting software is hassle. There is a significant setup, maintenance and security overhead most techies just don't want to deal with. It's why so many Saas and Paas companies pop up every other week. Ghost is no exception, they offer a Ghost Pro option were they host the blog on your behalf for $19/month. Ghost is also, of course, open source, giving you the option to host the blog yourself.

Ghost, like anything hot and new, runs on NodeJS. I've heard stories of the veritable dumpster fire that is the Javascript community nowadays, so I prefer to keep it at an arm's length. Close enough to see the new cool stuff but not so close as to burn myself if it explodes. Ghost's Github page lists these steps for installation:

  1. Download the latest release of Ghost
  2. Unzip, and fire up terminal
  3. npm install --production
  4. Start Ghost!

Seems simple enough right? All you really need is npm, Node Package Manager. To install npm you need Node.js. Node (and the software industry as a whole) has a bit of a problem. There isn't just one stable, supported, up-to-date version of Node.js. There's quite a few.

wide

Ghost's install page says only Node.js versions 0.10.x, 0.12.x and >=4.2 <5.* are supported. If you were to apt-get install nodejs or already have an incompatible version of Node.js installed on your machine, you'll meet a number of annoying, indecipherable errors during installation. There are usually tools to solve the problem of having multiple versions on a piece of software on a machine. Node.js has Node Version Manager.

Rather than jumping down that rabbit hole, you can use Docker.

Installing with Docker

Docker isn't without it's flaws, but it can really soothe the pains of versioning and dependency hell.

This tutorial will make use of the 3rd party Ghost image, zzrot/alpine-ghost. The official Ghost image completely lacks any configuration options, and I would not recommend it for anything but development.

To run this container with the predefined defaults use this command:

$ docker run -p 80:2368 zzrot/alpine-ghost

We're trying to run a production grade blog here, so let's customize it a bit.

$ docker run --name blog -p 80:2368 -d \
    -e PROD_DOMAIN=http://tevindev.xyz \
    -e NODE_ENV=production \
    -v /var/lib/myghostblog:/var/lib/ghost \
    zzrot/alpine-ghost

The -p flag creates a port mapping rule like hostPort:containerPort. So it follows that we are mapping the host's port 80 to the container's port 2368. The PROD_DOMAIN env sets out blog's domain name and NODE_ENV=production tells Ghost to configure itself for a production environment.

If you do not provide a data volume on your host machine, data created by Ghost will be lost when you stop the container. It's a good idea to mount a data volume from your host machine where data persist across container restarts. Here we are using /var/lib/myghostblog as the data volume.

This is all that's necessary for a basic blog, but let's go a step forward by setting up mail. Ghost includes support for mailing through Gmail, Amazon, MailGun and much more since it's backed by the nodeMailer library. I've found that Mailgun is the easiest to setup.

$ docker run --name blog -p 80:2368 -d \
    -e PROD_DOMAIN=http://tevindev.xyz \
    -e NODE_ENV=production \
    -e PROD_MAIL_TRANSPORT=SMTP \
    -e PROD_MAIL_SERVICE=Mailgun \
    -e PROD_MAIL_USER=postmaster@tryghosttest.mailgun.org \
    -e PROD_MAIL_PASS= 25ip4bzyjwo1 \
    -v /var/lib/myghostblog:/var/lib/ghost \
    zzrot/alpine-ghost

At the moment, the only thing Ghost uses email for is sending you an email with a new password if you forget yours. It’s not much, but don’t underestimate how useful that feature is if you ever happen to need it.

After starting the container, visit the url as defined in your PROD_DOMAIN env and you should be greeted with a functioning Ghost blog.

wide

Configuring with NGINX

Passing traffic through a reverse proxy like NGINX has a number of advantages. The two that benefits us the most here are name-based virtual servers and SSL/TLS termination.

By configuring a name or ip address based virtual server we can direct traffic from multiple domain names through the same port. The configurations we defined above will bind the container to the host machine's port 80, preventing any other process from doing the same.

A TLS termination proxy is useful in that it frees your application from the burden of needing to know how to handle TLS connections. It provides a centralized place for securing and protecting against attacks.

I highly recommend my post on how to setup NGINX with automatic HTTPS. We are going to extend that article by setting our new blog to make use of that service.

From that article, you'll recall that setting up our blog for NGINX requires our container to be on the same network as nginx-proxy. In addition, you must supply the VIRTUAL_HOST, LETSENCRYPT_HOST and LETSENCRYPT_EMAIL environment variables.

VIRTUAL_HOST and LETSENCRYPT_HOST will be the full qualified domain name of your blog. In the case on the examples shown, ours will be tevindev.xyz.

Pulling it all together, the command to launch our blog will look a little something like this:

$ docker run --name blog --expose 2368 -d \
    -e PROD_DOMAIN=https://tevindev.xyz \
    -e NODE_ENV=production \
    -e PROD_MAIL_TRANSPORT=SMTP \
    -e PROD_MAIL_SERVICE=Mailgun \
    -e PROD_MAIL_USER=postmaster@tryghosttest.mailgun.org \
    -e PROD_MAIL_PASS=25ip4bzyjwo1 \
    -e VIRTUAL_HOST=tevindev.xyz \
    -e "LETSENCRYPT_HOST=tevindev.xyz" \
    -e "LETSENCRYPT_EMAIL=personalemail@gmail.com" \
    -v /var/lib/myghostblog:/var/lib/ghost \
    --net=nginx-net \
    zzrot/alpine-ghost

Note that the url in PROD_DOMAIN now uses https. In addition, we are using the --net (--network in Docker 1.12+) flag to connect our container to the same network as NGINX.

If you'd like to use Docker Compose, here's a docker-compose.yml defining the container.

$ cat docker-compose.yml
version: '2'

services:  
  ghost-blog:
    container_name: blog
    image: 'zzrot/alpine-ghost'
    environment:
        - PROD_DOMAIN=https://tevindev.xyz
        - NODE_ENV=production
        - PROD_MAIL_TRANSPORT=SMTP
        - PROD_MAIL_SERVICE=Mailgun
        - PROD_MAIL_USER=postmaster@tryghosttest.mailgun.org
        - PROD_MAIL_PASS=25ip4bzyjwo1
        - VIRTUAL_HOST=tevindev.xyz
        - LETSENCRYPT_HOST=tevindev.xyz"
        - LETSENCRYPT_EMAIL=tevindev.xyz@gmail.com"
    restart: always
    volumes:
      - /var/lib/myghostblog:/var/lib/ghost
    expose:
      - "2368"

networks:  
  default:
    external:
      name: nginx-net

Within seconds of starting your container, nginx-proxy will generate a proxy configuration that looks like this:

upstream tevindev.xyz {  
    ## Can be connect with "nginx-net" network
    # blog
    server 172.18.0.4:2368;
}
server {  
    server_name tevindev.xyz;
    listen 80 ;
    access_log /var/log/nginx/access.log vhost;
    return 301 https://$host$request_uri;
}
server {  
    server_name tevindev.xyz;
    listen 443 ssl http2 ;
    access_log /var/log/nginx/access.log vhost;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers 'ECDHE-ECDSA-...truncated';
    ssl_prefer_server_ciphers on;
    ssl_session_timeout 5m;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;
    ssl_certificate /etc/nginx/certs/tevindev.xyz.crt;
    ssl_certificate_key /etc/nginx/certs/tevindev.xyz.key;
    ssl_dhparam /etc/nginx/certs/tevindev.xyz.dhparam.pem;
    add_header Strict-Transport-Security "max-age=31536000";
    include /etc/nginx/vhost.d/default;
    location / {
        proxy_pass http://tevindev.xyz;
    }
}

This configuration will redirect HTTP traffic to HTTPS. The SSL cipher configuration is based on mozilla nginx intermediate profile which should provide compatibility with clients back to Firefox 1, Chrome 1, IE 7, Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7. The configuration also enables HSTS, and SSL session caches.

Now, when you visit your blog's domain, you should be connected over a secure TLS connection and your server should present a valid and trusted certificate from Let's Encrypt. Visit PROD_DOMAIN/ghost to begin configuring your your brand new, self-hosted Ghost blog.

wide

A bit on Docker networks

As an exercise, let's look at the nginx-net network. As you may recall, a container can be added to a network with the --net flag or with command docker network connect CONTAINER. Each container on the network is assigned an IP address so that they may communicate.

$ docker network inspect nginx-net
[
    {
        "Name": "nginx-net",
        "Id": "86acd4edf102a2a5f9aaa6ff2638e5372eeffd9dd39070ee97ee1ac252a29492",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1/16"
                }
            ]
        },
        "Internal": false,
        "Containers": {
            "0201227422b3606d9866cf8ec9f9f5353bb2d823f34a5379078a1f68d661d54b": {
                "Name": "letsencrypt-nginx-proxy",
                "EndpointID": "fa288386d9326352533e72c59949910fb5a54816866003028eaf639a3f91d046",
                "MacAddress": "02:42:ac:12:00:03",
                "IPv4Address": "172.18.0.3/16",
                "IPv6Address": ""
            },
            "07eedfe456e6a93af92c7de7f51a1d57fef41f4267c9974deecbcee38d6f2c01": {
                "Name": "nginx-proxy",
                "EndpointID": "1c85112a709e94e71bc80b01e9f88e0d0f2256d572b46e5393ae0c0228aa0f93",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            },
            "60e93e091c409c652a37131b613b4126efee570e3244207d1d2ae564b12c5f77": {
                "Name": "blog",
                "EndpointID": "4184537aef29cd5a8f83a3c9a52ef03792da21ed259d054f5c2260ee0451f8b6",
                "MacAddress": "02:42:ac:12:00:04",
                "IPv4Address": "172.18.0.4/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

Here the nginx-net network has 3 containers connected to it in the 172.18.0.0/16 subnet. We can see that the container named blog has an IPv4Address of 172.18.0.4. If you can recall, nginx-proxy generated a configuration for an upstream server at 172.18.0.4:2368. Each container in the network can immediately communicate with other containers in the network. Though, the nginx-net network itself isolates the containers from external networks.

Tevin Jeffrey
Author

Tevin Jeffrey

My name is Tevin Jeffrey and I'm a Software Engineer. This is my blog about android, software architecture, user interfaces, user experience and sometimes video games.

Comments