Software in context


How to Develop Ghost Themes with Docker

16th March 2016

Ghost is my blogging platform of choice. Why?

  1. It's a healthy open-source Node project. See here.
  2. The admin UI is simple.
  3. You can write with Markdown.

Also, there's an official Docker image for it! But having a Docker image doesn't necessarily mean you have a consistent way to develop and package arbitrary Ghost sites. Such a setup is what I present in this post. The result is a bit of a weird contortion around Ghost's idiosyncrasies, but has been totally worth the hours it took to develop: the headache of getting a new Ghost instance set up for development and deployment is completely removed.

A starter repository for the setup is available on GitHub.

The requirements for the setup are as follows:

  1. Ability to publish Ghost theme as an NPM package
  2. Deploy as a Docker container based on Ghost image
  3. Blog config file checked into source
  4. Develop in a Docker container based on Ghost image
  5. Live code reloading during theme development
  6. Persistent development instances of Ghost blogs

This may seem like a lot to ask, but is all completely achievable with a single Dockerfile, a particular project structure, and some light bash scripting.

The Setup

Note: The use-case assumed in this post is that you're developing or modifying a Ghost theme and maintaining the Ghost blog where this theme is activated. But the examples below are easily extended to a situation where you are only developing themes, or only maintaining a blog with a pre-existing theme. Most of my blogs use themes developed by others, but I like to fork them and maintain them myself in case any tweaks are needed.

1. Project structure

For every theme/blog combination, I maintain two code repositories; a standard one for the theme itself and another one for the blog data. Chances are you already have a Ghost theme repository, but the latter is unique to this setup. It's going to allow us to completely avoid making manual file changes on the blog production server, and house all of the setup's Docker machinery. We'll get into the details shortly.

(1) Theme Repository

We'll refer to this as my-ghost-theme. This is your standard Ghost theme, checked into source code. There's a package.json, a bunch of .hbs files, and probably some JavaScript. If you're on top of things, there's a front-end build tool like Grunt or Gulp.

(2) Blog Repository

We'll spend the rest of this post discussing the contents of this repository, which we'll refer to as my-ghost-blog. It's essentially a copy of what lives in /var/lib/ghost, with a couple of differences: (1) certain directories like apps, images, and data are ignored (see .gitignore in starter project), and (2) some Docker stuff and bash scripts are added. It's worth sketching out the file structure:

|    |
|    |
|    |
|    |
|    |
|    |

A starter version of this repository is available on GitHub, with instructions for what to change. We'll step through some of these files now, others will be covered in the ensuing sections.


We need one of these not just because we're in Node-land, but because our target Ghost theme will be listed here as a dependency for production mode, e.g.:

  "dependencies": {
    "my-ghost-theme": "^0.0.1"


This is the Ghost config.js for your blog. Configure as per the documentation.


Although Ghost will drop a copy of Casper here and start with it activated, it's nice to check in a copy so that the bootstrapped state of the blog is evident.


This one is most consequential for achieving the Docker setup, however, /themes/my-ghost-theme is not checked into source. When initializing this repository, clone my-ghost-theme into /themes, but ignore it with .gitignore. Why? As you'll see later, we'll be mounting the entire /my-ghost-blog directory into the Docker container at /var/lib/ghost as all of our blog data. There are two constraints at play here: (1) themes are supposed to live within this directory in Ghost, and (2) Docker won't let us "layer" mounted volumes; so we can't mount the blog data first, and then separately mount our theme over a subdirectory of that volume from another more convenient location. Further discussion available on this GitHub issue.

Because I like to have all of my projects (cloned git repositories) living in a single directory called ~/source, I set up a soft link with ln -s from ~/source/my-ghost-theme to ~/source/my-ghost-blog/themes/my-ghost-theme.

* Note: You can optionally check your theme in here and work out of a single repository. This is the way to go if you don't want to publish your theme as an NPM package. The tradeoff is that you'll have a front-end project embedded in a back-end one, and other annoyances described here. I haven't worked out all of the implications for development in this situation.

2. Docker Tooling

This section covers the heavy lifting of the setup, which is contained in Dockerfile and /.docker/ of my-ghost-blog Essentially, the official Ghost Docker image (not to be confused with this old one) does not allow us to achieve all of the setup's requirements, so we create a thin Docker image over it, and modify the container's entry-point script.


FROM ghost:0.7.5

# 1. move this entire repo into a temp directory
ADD . /docker_tmp  
WORKDIR /docker_tmp

# 2. overwrite the default config with our own
RUN cp config.js /usr/src/ghost/config.example.js

# 3. download and install the target theme
RUN npm install --production  
RUN cp -R node_modules/* /usr/src/ghost/content/themes

# 4. clean-up
WORKDIR /usr/src/ghost  
RUN rm -r /docker_tmp

# 5. use our custom entry-point script
COPY .docker/ /  

# 6. port and startup
EXPOSE 2368  
CMD ["npm", "start"]  

This Dockerfile will produce a single image to be used for development and production. Understanding the entry-point script should clarify some of these steps…


This script is invoked when the container starts and is mostly a copy of the official Ghost Docker image repository. Instead of reproducing the entire script here, I'll address the relevant parts. It helps to know that my-ghost-blog (this repository) will be mounted onto the running container at $GHOST_CONTENT when this script runs. Also $GHOST_SOURCE is a bunch of defaults for $GHOST_CONTENT that ships with the Ghost Docker image.

# upgrade target theme
if [ ${NODE_ENV} == "production" ]; then  
    rm -rf "$GHOST_CONTENT/themes/${TARGET_THEME}"
    cp -R "$GHOST_SOURCE/content/themes/${TARGET_THEME}" "$GHOST_CONTENT/themes/${TARGET_THEME}"

I added the above section, which runs when in production mode. First it removes our target theme from $GHOST_CONTENT, then moves a fresh copy into $GHOST_SOURCE. The entry-point script later copies things from $GHOST_SOURCE to $GHOST_CONTENT on startup, so we piggyback on this process to force our theme to upgrade. Later we'll specify $TARGET_THEME and $NODE_ENV as environment variables in the docker run command.

# upgrade config file
if [ -e "$GHOST_CONTENT/config.js" ]; then  
    rm "$GHOST_CONTENT/config.js"

We use a similar trick to upgrade the blog's config file. First it's removed from $GHOST_CONTENT if it exists (above), and then a pre-existing block (below) detects this and copies over a fresh config.example.js from $GHOST_SOURCE. But in Step 2 of the Dockerfile, we moved our latest config.js file to /usr/src/ghost/config.example.js in anticipation of this, effectively forcing the config file to upgrade.

# create a correct config file out of config.example.js if needed
if [ ! -e "$GHOST_CONTENT/config.js" ]; then  
    sed -r '
        s!path.join\(__dirname, (.)/content!path.join(process.env.GHOST_CONTENT, \1!g;
    ' "$GHOST_SOURCE/config.example.js" > "$GHOST_CONTENT/config.js"

3. Development

We've got our project structure and Docker stuff in place, and requirement #3—Blog config file checked into source—is checked off. We'll now be able to knock out requirements 4-6 with a couple of bash scripts, which are really just wrappers for Docker commands. These scripts should be run from the root of your my-ghost-blog clone.


docker build -t my-ghost-blog:0.1.0 .  

Of course you could run this from the command line, but in practice I put all my build steps in scripts because things like the Docker image name and tag can be made dynamic, and easily integrate with a CI/CD tool—I use GoCD. Once you've built the image, run it for development:


docker stop my-ghost-blog_DEVELOPMENT  
docker rm my-ghost-blog_DEVELOPMENT  
docker run -d --name my-ghost-blog_DEVELOPMENT -e NODE_ENV=development -p 2368:2368 -v `pwd`:/var/lib/ghost my-ghost-blog:0.1.0  

First we stop and remove a pre-existing development container (expect a benign error if one does not exist), then start a new one based on the image we just built. The most important part is -v `pwd`:/var/lib/ghost, where the my-ghost-blog directory is mounted onto the container as the blog content.

Your local Ghost blog should now be available at http://{DOCKER_IP}/my-ghost-blog:2368, where DOCKER_IP is the IP address of your Docker machine, or localhost, and my-ghost-blog is whatever you've specified as the blog's development domain in config.js. Now lets discuss what we've achieved:

  1. Develop in a Docker container based on Ghost image—You never had to install Ghost on your development host. You can have multiple Ghost blogs running in parallel, even on different versions of Ghost, and they are all self-contained and self-sufficient.
  2. Live code reloading during theme development—Since the my-ghost-theme is volume-mounted into the container as part of my-ghost-blog, you can make changes to your theme's source code, and they'll be reflected on the development site without having to rebuild or restart the container. I like to run gulp watch in my-ghost-theme and work on it like any other front-end project.
  3. Persistent development instances of Ghost blogs—All of the state for your development Ghost blog is contained in the my-ghost-blog directory. After first starting the container, you should see the familiar apps, _images, and data directories pop up. This means you can upload images and work on posts without touching any other in-development blogs. Also, all the blog data persists when you re-build or restart the container.

4. Deployment

Finally, I'll share the script that's run on the production host to update the blog container. I assume the following:

  1. Your theme my-ghost-theme is published as an NPM package. (Requirement #1)
  2. The package.json in my-ghost-blog specifies this theme as its only dependency.
  3. You've run ./scripts/
  4. You've also pushed the built Docker image, and pulled it onto the production host.


docker stop my-ghost-blog_PRODUCTION  
docker rm my-ghost-blog_PRODUCTION  
docker run -d --name my-ghost-blog_PRODUCTION -e TARGET_THEME=my-ghost-theme -e NODE_ENV=production -p 80:2368 -v /usr/share/ghost-blogs/my-ghost-blog:/var/lib/ghost my-ghost-blog:0.1.0  

The first time this is run, you'll have to create an empty directory at /usr/share/ghost-blogs/my-ghost-blog on the production host. The necessary ENV for production mode is passed in, and we serve up the blog on port 80.

So we've achieved Requirement #2: Deploy as a Docker container based on Ghost image. Aside from not having to install Ghost on the production host, a big advantage here is that you'll never have to manually muck around with production blog data. If there's a change to config.js, it goes through the source code of my-ghost-blog, and makes it to the production server through the Docker deployment process. Same story for theme changes: when these happen, bump the version number of my-ghost-theme, re-publish it the package, and then upgrade the dependency in my-ghost-blog's package.json.


As stated above, this setup has saved me a lot of time, especially now that I'm running several Ghost blogs. It works well with continuous deployment and falls within the approach to NodeJS projects developed here. However there is room for improvement:

  1. The Ghost init process is kinda wonky. It would be nice if the project could mitigate the need for the acrobatics required to achieve the above features.
  2. The official has changed since this setup was developed. The wrapper should adapt accordingly, and may even be simplified greatly.
  3. I've tested this with Ghost 0.7.5, but we're up to 0.7.8 now.
  4. Extend the setup to work with multiple in-development themes. It is hard-coded for a single theme.
  5. Take advantage of additional Docker features like data-volumes and compose.
Caleb Sotelo

Caleb Sotelo

I'm a Software Engineer and Director of OpenX Labs. I try to write about software in a way that helps people truly understand it. Follow me @calebds.

View Comments