About Ghost

If you haven’t used Ghost before, it’s a platform for people who “just want to blog”. I’ve used it for a few years, partly because the default themes look fantastic, but mainly because you can blog in Markdown!

The official Docker image

The official Ghost Docker image is rather large and there are a few minor things that I hoped to update. Those things are:

  1. The image is about 360MB, I would like a smaller one.
  2. You need to maintain data volumes for /var/lib/ghost
  3. There’s not an easy way to configure the ghost installation

There is nothing wrong with the official image image. I’ve used it a bunch of times to test things out, it’s fantastic and I intend to continue using it.

Getting Started

The following sections will rely on external services like AWS SES and AWS S3. Make sure you have the proper credentials, roles, and policies setup so that you can fully leverage these services. I have not included instructions on how to setup SMTP users in SES or users for accessing S3 in this post.

Shrinking the Image

The official Docker image for ghost depends on the official Node.js image, which is built on Debian. The Debian image starts at 125 MB, which is a little bigger than I’d like to see.

The first thing I looked for was a base Node.js container with Alpine Linux. Alpine Linux has a base image of 4.7 MB. I found mhart/alpine-node which does a full compile of Node.js. This is a great option as a base image. I decided to just use the nodejs-lts package from the edge Alpine Linux repositories to handle the Node.js dependency, since it removes an external dependency:

FROM alpine:3.3

RUN apk update && \
  apk upgrade && \
  apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/main nodejs-lts

This image comes out at 32.63 MB.

We can either ship this as an image we depend on and include it in our ghost build, or we can have it setup as an all-in-one. For this example, we’re just going to use the all-in-one approach.

Lets install Ghost as well:

FROM alpine:3.3

ENV GHOST_VERSION 0.7.9
ENV GHOST_USER ghost

RUN apk update && \
  apk upgrade && \
  apk add --no-cache curl unzip && \
  apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/main nodejs-lts && \
  adduser -D -s /bin/bash $GHOST_USER && \
  curl -sL -o ghost.zip "https://ghost.org/archives/ghost-${GHOST_VERSION}.zip" && \
  unzip ghost.zip && \
  npm install --production && \
  rm ghost.zip && \
  npm cache clean && \
  chown -R $GHOST_USER:$GHOST_USER /content && \
  apk del curl unzip && \
  rm -rf /tmp/*

USER $GHOST_USER
EXPOSE 2368
CMD ["npm", "start"]

Configuring Ghost

I like using environment variables for configuration since it makes management of services across environments a lot easier. So with our build, we’re going to add a config.js file that we’re going to copy into our container. This is a modified version of the default Ghost configuration. We are going to pull in settings from the environment by using commands like the following:

process.env.SITE_URL

We are also specifying a fall back value incase the value is not set in the environment:

process.env.SITE_URL || 'http://my-ghost-blog.com'

This allows you to establish defaults so you don’t have to pass in every setting. Here’s an example config.js that we can copy into our container.

// # Ghost Configuration
// Setup your Ghost install for various [environments](http://support.ghost.org/config/#about-environments).

// Ghost runs in `development` mode by default. Full documentation can be found at http://support.ghost.org/config/

var path = require('path'),
    config;

config = {
    // ### Production
    // When running Ghost in the wild, use the production environment.
    // Configure your URL and mail settings here
    production: {
        url: process.env.SITE_URL || 'http://my-ghost-blog.com',
        database: {
            client: 'postgres',
            connection: {
                host: process.env.PG_HOST || 'localhost',
                user: process.env.PG_USER || 'username',
                password: process.env.PG_PASS || 'password',
                database: process.env.PG_DB || 'databasename',
                port: process.env.PG_PORT || '5432'
            },
        },
        server: {
            host: process.env.SITE_HOST || '0.0.0.0',
            port: process.env.SITE_PORT || '2368'
        }
    },

    // ### Development **(default)**
    development: {
        // The url to use when providing links to the site, E.g. in RSS and email.
        // Change this to your Ghost blog's published URL.
        url: 'http://localhost:2368',

        // Example mail config
        // Visit http://support.ghost.org/mail for instructions
        // ```
        //  mail: {
        //      transport: 'SMTP',
        //      options: {
        //          service: 'Mailgun',
        //          auth: {
        //              user: '', // mailgun username
        //              pass: ''  // mailgun password
        //          }
        //      }
        //  },
        // ```

        // #### Database
        // Ghost supports sqlite3 (default), MySQL & PostgreSQL
        database: {
            client: 'sqlite3',
            connection: {
                filename: path.join(__dirname, '/content/data/ghost-dev.db')
            },
            debug: false
        },
        // #### Server
        // Can be host & port (default), or socket
        server: {
            // Host to be passed to node's `net.Server#listen()`
            host: '0.0.0.0',
            // Port to be passed to node's `net.Server#listen()`, for iisnode set this to `process.env.PORT`
            port: '2368'
        },
        // #### Paths
        // Specify where your content directory lives
        paths: {
            contentPath: path.join(__dirname, '/content/')
        }
    }
};

module.exports = config;

Now our Dockerfile looks like this:

FROM alpine:3.3

ENV GHOST_VERSION 0.7.9
ENV GHOST_USER ghost

RUN apk update && \
  apk upgrade && \
  apk add --no-cache curl unzip && \
  apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/main nodejs-lts && \
  adduser -D -s /bin/bash $GHOST_USER && \
  curl -sL -o ghost.zip "https://ghost.org/archives/ghost-${GHOST_VERSION}.zip" && \
  unzip ghost.zip && \
  npm install --production && \
  rm ghost.zip && \
  npm cache clean && \
  chown -R $GHOST_USER:$GHOST_USER /content && \
  apk del curl unzip && \
  rm -rf /tmp/*

COPY config.js /config.js

USER $GHOST_USER
EXPOSE 2368
CMD ["npm", "start", "--production"]

When running the container now, we can pass in --env-file with a flat file that establishes environment variables for our container.

Dealing with Mail

Use a service for mail. I stopped running my own SMTP servers years ago. Services like Mailgun and AWS SES can solve the mail delivery very easily for you. Ghost supports both Mailgun and SES out of the box. In this example, I’m going to show how you can setup SES using environment variables that you pass in. Put this configuration inside the production configuration section shown in the last section:

        mail: {
            from: process.env.MAIL_FROM || 'no-reply@my-ghost-blog.com',
            transport: 'SMTP',
            options: {
                host: process.env.SMTP_HOST || 'YOUR-SES-SERVER-NAME',
                port: process.env.SMTP_PORT || 465,
                service: 'SES',
                auth: {
                    user: process.env.SMTP_USER || 'YOUR-SES-ACCESS-KEY-ID',
                    pass: process.env.SMTP_PASS || 'YOUR-SES-SECRET-ACCESS-KEY'
                }
            }
        },

Once you’ve added this to your Dockerfile, you can re-build your image to add SES support. No additional changes are needed for your Dockerfile at this point.

Dealing with Data

The standard approach for managing assets with Ghost is to use data volumes. I wanted to use S3 to manage objects so that if my blog becomes hugely popular, I can offload assets to S3 as opposed to having to manage data volumes and all of the issues that can come with that across hosts. I don’t want to manage a shared filesystem either. So to solve this problem, I’m using ghost-s3-compat.

The install is simple, add the following command to your Dockerfile to install the package:

npm install --save ghost-s3-compat

Next, create an index.js that looks like this:

'use strict';
module.exports = require('ghost-s3-compat');

Place this file here: /content/storage/ghost-s3/index.js

Update the config.js file we’ve been building, and add a storage section to the production environment:

        storage: {
            active: 'ghost-s3',
            'ghost-s3': {
                accessKeyId: process.env.S3_KEYID || 'derpaderp',
                secretAccessKey: process.env.S3_ACCESSKEY || 'derpaderp',
                bucket: process.env.S3_BUCKET || 'ghostbucket',
                region: process.env.S3_REGION || 'us-east-1'
            }
        },

Now here’s what the updated Dockerfile looks like:

FROM alpine:3.3

ENV GHOST_VERSION 0.7.9
ENV GHOST_USER ghost

RUN apk update && \
  apk upgrade && \
  apk add --no-cache curl unzip && \
  apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/main nodejs-lts && \
  adduser -D -s /bin/bash $GHOST_USER && \
  curl -sL -o ghost.zip "https://ghost.org/archives/ghost-${GHOST_VERSION}.zip" && \
  unzip ghost.zip && \
  npm install --production && \
  npm install --save ghost-s3-compat && \
  rm ghost.zip && \
  npm cache clean && \
  chown -R $GHOST_USER:$GHOST_USER /content && \
  apk del curl unzip && \
  rm -rf /tmp/*

COPY config.js /config.js
COPY index.js /content/storage/ghost-s3/index.js

USER $GHOST_USER
EXPOSE 2368
CMD ["npm", "start", "--production"]

Summary

I was able to get the image size down from 359.4 MB to 189.4 MB, which is almost half the size. Additionally, we’ve added some cool features that can make Ghost easier to manage.

If you just want to check out the repo to start playing with this, go here:

https://github.com/brint/alpine-ghost-s3

The documentation in the repo includes some notes on how certain errors will manifest themselves. Happy blogging!