Dockerize your Blog with Ghost and NGINX

Ghost Blog Container

So I've been using ghost for the last year or so, I keep wanting to dig into it and customize it but I have about 50,000 things I'm working on all at once (GO A.D.D.).

Docker for Ghost

Recently they have created a docker container definition that is based off of their release tags. The extra cool thing here is you can use this container in combination with -v style volume mapping to upgrade the running container and map the data to a physical disk volume. This maintains site customizations and data for backing up, but allows you not to have to work about binary compatibility of the NODE.js version installed on your host. Each container has the binary dependencies needed to run your stuff.

Getting Started

Here is some scripts and patterns I've created for managing all of the various websites that I'm hosting.

As with all things, my solutions have been created around an implicit/explicit problem space.

Problem Scope:

  1. Hosting Multiple Sites on one machine
  • without exposing different ports externally
  1. Hosting Multiple Secure Sites on one Machine
  • If you don't have a SAN certificate covering all of your domains you're hosting, you will need to serve up site specific certificates on a per IP basis. This is a known limitation of the TLS exchange as it doesn't use the HOST header to determine which certificate should be returned.
  • For subdomains wildcard certificates can be had for fairly reasonable prices. For my purposes I have a *.codedad.net certificate. This makes doing all of the various subdomain security far easier.

Reverse Proxy w/ NGINX

I'm using NGINX to handle the first leg of the request when it comes in. It will handle the SSL termination, and forward the request down into the appropriate Docker Container that is running the site.
Reference: RFC-7239: Forwarded HTTP Extension

From Page 4

The "Forwarded" HTTP header field is an OPTIONAL header field that,
when used, contains a list of parameter-identifier pairs that
disclose information that is altered or lost when a proxy is involved
in the path of the request.

What does this all this mean? The industry has recognized the fact that the internet isn't everything hooked up directly. Indirection exists, and is necessary, so we have a set of things to use to help us maximize hosting density and configure out software the way we want, and still have security.

Moreover, the ghost blog software has been setup to understand that it can execute behind a reverse proxy and therefore supports interpreting X- headers and the associated behavior. If you ever plan to write a website that has to behave with security behind a reverse proxy, these are items you will need to consider.

Startup Ghost Blog without a volume mapping

docker pull ghost:latest

docker run -d -p 6001:2368 --restart=always --name ghost-codedad ghost

This starts a new Ghost blog on your machine listening exposed to port 6001 of the Dockerhost machine, and will happily run you through the beginning configuration.

For right now when you kill and rm the container you'll loose your blog.

Once the container starts, you should be able to navigate to http://localhost:6001 on your docker host machine.

NGINX Setup

Assuming you have NGINX setup already and you understand the whole /etc/nginx/sites-available -> /etc/nginx/sites-enabled using ln -s. If not go read up on NGINX setup, this won't be the scope of this tutorial.

localhost:6001 -> localhost

If you want to be able to go to http://localhost and see your block come up vs http://localhost:6001.

NGINX Config Example:

upstream codedad_net {
	server 127.0.0.1:6001;
	keepalive 8;
}

server {
	listen 80;
	listen [::]:80;

	server_name codedad.net www.codedad.net;

	location / {
		return 301 https://$server_name$request_uri;
	}
}

server {
	listen 443 ssl;

	server_name codedad.net www.codedad.net;

	ssl_session_timeout 5m;

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
        ssl_prefer_server_ciphers on;
        ssl_session_cache shared:SSL:10m;
        add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        ssl_stapling on; # Requires nginx >= 1.3.7
        ssl_stapling_verify on; # Requires nginx => 1.3.7
        resolver 8.8.8.8 8.8.4.4 valid=300s;
        resolver_timeout 5s;

	location / {
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header Host $http_host;
		proxy_set_header X-NginX-Proxy true;
		proxy_pass http://codedad_net/;
		proxy_redirect off;
	}

	location /content/fileshare/ {
		root /var/www/codedad.net;
	}
}

You would have one of these files in /var/nginx/sites-available for every container you have spun up.

The important parts of the config as as follows

The upstream NGINX will direct to. This is the dockerhost ip (127.0.0.1 works if you don't have mulitple IP's or whatever)

upstream codedad_net {  
    server 127.0.0.1:6001;
    keepalive 8;
}

This mapping sets up the default port 80 listener, but immediately redirects the user to HTTPS because that's what I like to do.

server {  
    listen 80;
    listen [::]:80;

    server_name codedad.net www.codedad.net;

    location / {
        return 301 https://$server_name$request_uri;
    }
}

SSL/TLS Settings

server {
	listen 443 ssl;
    ...
}

SSL/TLS security has a lot of settings, but the interesting parts here are the location / { section and the location /content/fileshare/

location / {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
        proxy_pass http://codedad_net/;
        proxy_redirect off;
    }

Passes request to the NON SSL upstream. SSL has been handled/terminated at this point. This also acts like a REVERSE PROXY due to the proxy_redirect off;

proxy_pass http://codedad_net/;
proxy_redirect off;

Beyond Quick Start

Ok this spins everything up in a container with no data persistence (if the container is deleted), but demonstrates how to get it all working together. But the defaults don't really support your site domain name etc...

Ghost has a pretty extensive config file setup and there are examples at https://ghost.org/developers/

Persistence of Blog Content

The Docker hub description covers how to use the container with an existing data volume.

Docker Hub for Ghost

The Docker container for ghost is the complete source code ready to roll as if you installed it fresh. Once you decide to do the volume mapping, things get a little odd.

Even though the container is using the supported version of NODE and all the other bits, your site as persisted to the host needs to have it's bits updated to stay in lockstep with what the container is expecting. This is a little bit of manual process to upgrade your site every time, which fortunately they have a pretty straight forward upgrade process that I've scripted. Mainly it involves removing a core/ folder and replace the index.js. The data is migrated for you during an automated upgrade process.

Here is a script I wrote that handles upgrading the Ghost bits on disk modeled after their upgrade documentation. The script will download the latest version, and does a comparision with your installed directory. It will then remove the correct parts and update them in place for you.

Upgrade Ghost Snippet

Starting the Container with mapped to a host volume

docker run -d -p 6001:2368 --restart=always -v /var/www/codedad.net:/var/lib/ghost --name ghost-codedad ghost

NOTE: This will start the blog in development mode, using a different db etc...

Switching to Production Runtime Configuration

At this point you're ready to roll. You want to put this thing in production mode and try it out.

docker run -d -p 6001:2368 --restart=always -e "NODE_ENV=production" -v /var/www/codedad.net:/var/lib/ghost --name ghost-codedad ghost

You should have a config.js file defined in your /var/www/yourblogbits/config.js

As of 5/28/2016 there are configuration errors in the default container build for production. You will need to add a section in the production config for your site config.js

docker logs ghost-codedad

$ docker logs ghost-codedad
npm info it worked if it ends with ok
npm info using npm@2.14.12
npm info using node@v4.2.6
npm info prestart ghost@0.8.0
npm info start ghost@0.8.0

> ghost@0.8.0 start /usr/src/ghost
> node index

ERROR: Unable to access Ghost's content path:
  EACCES: permission denied, open '/usr/src/ghost/content/apps/33f6b856c1e2e47f'

Check that the content path exists and file system permissions are correct.
Help and documentation can be found at http://support.ghost.org.

npm info ghost@0.8.0 Failed to exec start script
npm ERR! Linux 4.4.11-moby
npm ERR! argv "/usr/local/bin/node" "/usr/local/bin/npm" "start"
npm ERR! node v4.2.6
npm ERR! npm  v2.14.12
npm ERR! code ELIFECYCLE
npm ERR! ghost@0.8.0 start: `node index`
npm ERR! Exit status 235
npm ERR!
npm ERR! Failed at the ghost@0.8.0 start script 'node index'.
npm ERR! This is most likely a problem with the ghost package,
npm ERR! not with npm itself.
npm ERR! Tell the author that this fails on your system:
npm ERR!     node index
npm ERR! You can get their info via:
npm ERR!     npm owner ls ghost
npm ERR! There is likely additional logging output above.
npm ERR! Linux 4.4.11-moby
npm ERR! argv "/usr/local/bin/node" "/usr/local/bin/npm" "start"
npm ERR! node v4.2.6
npm ERR! npm  v2.14.12
npm ERR! path npm-debug.log.7da2e9feb6af80ea1c45d89105a542fd
npm ERR! code EACCES
npm ERR! errno -13
npm ERR! syscall open

npm ERR! Error: EACCES: permission denied, open 'npm-debug.log.7da2e9feb6af80ea1c45d89105a542fd'
npm ERR!     at Error (native)
npm ERR!  { [Error: EACCES: permission denied, open 'npm-debug.log.7da2e9feb6af80ea1c45d89105a542fd']
npm ERR!   errno: -13,
npm ERR!   code: 'EACCES',
npm ERR!   syscall: 'open',
npm ERR!   path: 'npm-debug.log.7da2e9feb6af80ea1c45d89105a542fd' }
npm ERR!
npm ERR! Please try running this command again as root/Administrator.

npm ERR! Please include the following file with any support request:
npm ERR!     /usr/src/ghost/npm-debug.log
npm info it worked if it ends with ok
npm info using npm@2.14.12
npm info using node@v4.2.6
npm info prestart ghost@0.8.0
npm info start ghost@0.8.0

The missing section

paths: {
            contentPath: path.join(__dirname, '/content/')
        }

Example the larger config file context

    production: {
        url: 'http://www.codedad.net',
        mail: {},
        database: {
            client: 'sqlite3',
            connection: {
                filename: path.join(__dirname, '/content/data/ghost.db')
            },
            debug: false
        },

        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: {
            contentPath: path.join(__dirname, '/content/')
        }
    }
Dockerize your Blog with Ghost and NGINX
Share this