< Tom Marx

How to create a minimalist blog with Ghost

Published on Oct 13, 2021.

Welcome to my new blog. It's way cleaner and minimalist than the previous one.

My older website was 100% custom, with a PHP engine. It was not very optimal to post content, as I needed to deploy files every time I wanted to write something. So I started looking for a blogging engine. Ghost fitted my requirements as it's 100% customizable, self-hostable, and way simpler than Wordpress.

I navigated through all available themes, but even those marketed as "minimalist" were overloaded with features as tags, post filtered by user, subscriptions, image gallery... I really wanted a theme with only two features:

Basically, I wanted a website only focused on text and content. As no template available online fitted these requirements, I decided to create my own template.

I like to build stuff on my own, so I also decided to create the infrastructure to run the blog on a VPS. So if you want to create a Ghost blog similar to this one and host it by yourself, follow this guide!

Setting up Ghost

The first thing we want to set up is a docker container with the Ghost app. Fortunately, Ghost developers have released an official image. We will also be using docker-compose, as it makes interactions between multiple containers easy.

If you're not familiar with docker-compose, it's a tool that allows you to easily run multiple Docker containers. It will also create a virtual network in which all those containers can interact with each other. It's a perfect tool to manage a small infrastructure.

So, everything starts with this docker-compose.yaml:

version: "3.8"

services:
  ghost:
    image: ghost:4-alpine
    restart: always
    env_file: .env
    ports:
      - "2368:2368"
    volumes:
      - ghost:/var/lib/ghost/content
      - ./theme:/var/lib/ghost/content/themes/theme

It's pretty straightforward, we create a "ghost" service, based on the official image. We expose port 2368 (default port). We have two volumes, one that will store all the content of the blog (backing up /var/lib/ghost/content), and the other one will inject our custom theme directly into the container, in the right place to be found by Ghost.

We can run this infra with:

docker-compose up -d

If everything is good, you should be able to connect to your freshly created Ghost blog by accessing localhost:2368.

Creating the Ghost theme

One thing I love with Ghost themes is that you can start with a real small template. The template should be written with handlebars, a templating language on top of HTML.

The only required files to start with are:

The default.hbs file is not required in the documentation, but I strongly recommend you to create one, as it's the base skeleton of your website. It will contain the base HTML structure.

To test your theme, select the theme in Ghost settings, develop directly in the theme folder, and refresh your webpage.

The minimal package.json file I got:

{
  "name": "my-theme",
  "version": "1.0.0",
  "engines": {
    "ghost": ">=4.0.0",
    "ghost-api": "v4"
  },
  "author": {
    "email": "tom@tommarx.fr",
    "name": "Tom Marx"
  },
  "config": {
    "posts_per_page": 20
  },
  "keywords": ["ghost-theme"]
}

The minimal default.hbs:

<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<link rel="stylesheet" href="{{asset "css/style.css"}}">
	<link rel="stylesheet" href="{{asset "css/mobile.css"}}" media="screen and (max-width: 
	800px)">
	<title>{{meta_title}}</title>
	{{ghost_head}}
</head>
<body class="{{body_class}}">
	{{{body}}}
	{{ghost_foot}}
</body>
</html>

Then you can put everything you want in the index.hbs and the post.hbs. Here is how I list posts on the homepage:

{{#foreach posts}}
    <a href="{{url}}" class="no-underline">
        <article class="{{post_class}}">
            <div class="article-header">
                <h3>{{title}}</h3>
                <span>{{date published_at}}</span>
            </div>
            <p>{{excerpt characters="170"}}...</p>
        </article>
    </a>
{{/foreach}}

As you can see, the syntax is really simple, and you can find all the tags available in the Ghost documentation.

Here is my post.hbs template:

{{!< default}}
{{#post}}
<main>
	<div>
		<a href="/">< Tom Marx</a>
	</div>
	<h1>{{title}}</h1>
	<span class="date">Published on {{date}}.</span>
	{{content}}
</main>
{{/post}}

That's it! Don't forget to add "{{!< default}}" on top of your template files, it's the command that will tell Handlebars to wrap the content by the default.hbs file. I lost several hours not understanding why my home page wasn't wrapped by any HTML tags present in my default.hbs file.

My whole blog is only approximately 100 lines of CSS and 100 lines of HTML!

Providing a SSL connection using a reverse proxy

A reverse proxy is a server that will proxy a request to another server. It allows in our case to expose an HTTP connection as an HTTPS one.

We will use nginx for that. To run the nginx with the Ghost container:

version: "3.8"

services:
  ghost:
    image: ghost:4-alpine
    restart: always
    env_file: .env
    ports:
      - "2368:2368"
    volumes:
      - ghost:/var/lib/ghost/content
      - ./theme:/var/lib/ghost/content/themes/theme

  https_proxy:
    image: nginx:1.21-alpine
    restart: always
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./server.cert:/root/server.cert
      - ./server.key:/root/server.key

volumes:
  ghost:

There is more configuration here:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./selfsigned.key -out selfsigned.crt
events {}

http {
	server { # redirects http requests to https
		listen 80 default_server;
		listen [::]:80 default_server;
		server_name _;
		return 301 https://$host$request_uri;
	}
	server {
		listen 443 ssl;
		listen [::]:443 ssl;

		ssl_certificate     /root/server.cert;
		ssl_certificate_key /root/server.key;

		access_log /var/log/nginx/reverse-access.log;
		error_log /var/log/nginx/reverse-error.log;

		location / {
			proxy_pass http://ghost:2368;
			proxy_set_header X-Forwarded-Proto $scheme;
		}
	}
}

The first server declaration will force users to connect to the HTTPS version of your blog. If we get requests on the port 80 (default for HTTP), we redirect them to the same URL but starting with "https://" instead of "http://"

The second server declaration will proxy the request to our Ghost blog. As this server is listening on port 443 and in SSL mode, we need to provide the certificate and the key that we previously created.

The "proxy_set_header X-Forwarded-Proto $scheme;" line will tell Ghost to redirect clients using HTTPS. I spent a few hours understanding this, without that I was redirected infinitely.

You can now restart your docker-compose, and access your blog on https://localhost!

Deploying your blog on the Internet

The last step is making your blog accessible on the Internet.

To achieve that, I bought a small VPS, and ran my docker-compose on it. It should be working, but you will need to connect to your blog using the VPS IP, and your browser would still show a warning for the SSL certificates.

The first issue can be resolved by redirecting a domain name to your VPS. On your favorite domain name provider dashboard, you can achieve that by adding a new A record to the DNS settings, with the IP of the VPS. The domain name should be now redirecting to your blog, but still with a warning for the certificates.

To get proper certificates, you can use Let's Encrypt service, certbot. It's a small tool that will generate a key and certificate for your domain, for free. Once you installed certbot, just run on your VPS:

certbot certonly --standalone -d yourdomain.com --staple-ocsp -m mail@yourdomain.com --agree-tos

You can then remove the self-signed certificate, and link the newly created certificates inside the docker nginx container.


And everything should be good! You now have a custom blog, working with Ghost, with your own template, accessible securely by everyone.

All the code of this blog is accessible here.