Ghost started as a blogging platform but now officially announces itself a powerful content management system (CMS) with support for headless mode. Headless means that you can run it as a pure content source, replacing it's build-in front-end with a Jamstack variant.

As Jamify sources in all content from a Ghost CMS, this guide focuses on setting it up correctly for the Jamstack and particularly for use with the Jamify tools. Jamify currently requires you to run your CMS on a public endpoint, because inline images are served directly from the CMS. Hosting your CMS on a public cloud also enables you to invite team members, so it's generally good for collaboration.

Choosing a cloud host

Public accessibility means that you need to pick a hosting service. The installations steps that follow can be easily adapted to other cloud providers, such as Digital Ocean, Scaleway or Vultr, just to name three alternatives.

For this guide, I choose Hetzner Cloud, because it as an extremely reliable and cost-efficient service. Although not that widely known, it's a very professional company with a fast network, featuring DDOS protection and GDPR compliancy.

For under $3/month you can host your Ghost CMS with them which is extremely competitive for a hight quality service. However, you do have to install, maintain and secure your server yourself.

Hetzner Cloud

If you want to closely follow the installation steps shown in this guide, start by registering a new account with Hetzner.

Use this Link to get a 20 USD starter credit on your Hetzner account!

Just Sign up and you are good to go. Unfortunately, you cannot register with your existing Github account, they are still a bit old school in that respect.

Set up a new project

After setting up an account with Hetzner Cloud, go to the Cloud Console, add a new project and give it the name ghost. To simplify access to the cloud, make sure to configure your ssh key and generate an API token:

Make sure to copy the API token in the clipboard, so you can paste it later. For security reasons, you cannot see it a second time.

Install and connect hcloud

Initially you may find it easier to configure your cloud servers on their web interface, but you will soon discover the benefits of the CLI tool hcloud. It will make automating processes a breeze, so installing it will save you a lot of time later.  Just follow their install instructions on Github. After installation, check the version:

[local]$ hcloud version
hcloud v1.16.2

You can now create a context in hcloud that lets you access your ghost project.

[local]$ hcloud context create ghost

Paste you previously generated API token at the token prompt, and you'll see the confirmation message Context ghost created and activated. hcloud is now connected to your ghost project.

Create a cloud server

With the command hcloud server-type list you get a list of currently supported servers. The smallest option is totally sufficient for a Ghost CMS, so you can create a new server with the following command:

Don't forget to replace the ssh-key name with your own!
[local]$ hcloud server create --image fedora-32 --location fsn1 --type cx11 --name ghost --ssh-key your@key

When you follow the commands in this guide you should also use the Fedora OS, because installation commands can vary slightly between different Linux distributions. Check the status of your server:

[local]$ hcloud server list
ID        NAME    STATUS    IPV4           IPV6                     DATACENTER
5838079   ghost   running   2a01:4f8:c17:a6dc::/64   fsn1-dc14

Connect a domain name

Now that you have a cloud server with a dedicated IP, you can connect your domain name with it. You can purchase a new one with Hetzner or you can use an existing one that you registered with another provider. In any case, you need to configure your DNS zone file:

Configure at least three A records for @, www and cms to point at your server IP. As you can see from the figure above, I am using the domain

It can take up to 24 hours until these settings have propagated to all DNS servers.

Protect your server

As a very first step, you must further harden the server. As the only authentication method to your server is a key based SSH method, it should not be possible for others to log into your server. Still, you should close down all ports that you don't need, rate-limit your SSH port and update all software packages to the latest version.


Log into your server with ssh root@$(hcloud server ip ghost) and install the uncomplicated firewall ufw:

[remote]$ dnf -y install ufw

Create a file containing the firewall commands:

[remote]$ (cat << EOF
ufw default deny incoming
ufw limit in 22/tcp comment "rate-limit SSH"
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
) > ./

Execute and enable it with:

[remote]$ sh ./
[remote]$ systemctl enable --now ufw.service

You can check the firewall rules at any time with:

[remote]$ ufw status
Important: To verify that you have not locked out yourself, open a new terminal window and check that you can login in with ssh root@$(hcloud server ip ghost).

Update all packages

Second most important step is to bring the kernel and other software packages up-to-date:

[remote]$ dnf -y update

These updates are only fully applied after a reboot.

Kernel tweak for docker

In perfect foresight, we are going to make a kernel tweak here, that is needed later for running docker. You need to enable the backward compatibility for cgroups.

[remote]$ grubby --update-kernel=ALL \


For all these changes to take affect, a reboot is required:

[remote]$ reboot

Logon again

You have to wait a minute until the OS has booted up again. Then login as usual:

[local]$ ssh root@$(hcloud server ip ghost)

and check that the firewall is up and running

 [remote]$ ufw status

and the kernel updates have been applied:

 [remote]$ uname -a
 Linux ghost 5.6.11-300.fc32.x86_64 #1 SMP... 

Install Docker

Use Fedora's package manager to install Docker:

[remote]$ dnf -y install docker docker-compose
[remote]$ systemctl enable --now docker

Fedora now uses the Moby Project to assemble the Docker components. Check that docker is running with an up-to-date version:

[remote]$ docker version
 Version:           19.03.8
  Version:          19.03.8

Obtain certificates from LetsEncrypt

The connection to your CMS should be SSL encrypted, so network traffic is secured. This is an important step as you do not want to send your passwords in plain-text over the internet.

[remote]$ dnf -y install certbot 

Substitute with your domain, with your email and put it in a variable:


Use certbot to get a certificate:

[remote]$ certbot certonly --standalone --no-eff-email \
--agree-tos --rsa-key-size 4096 --email ${EMAIL} \
--domains ${DOMAIN},www.${DOMAIN},cms.${DOMAIN}
If this command exits on error, check that DNS servers have already picked up the changed IP. You should see your cloud server IP with [local]$ ping <your-domain.tld> , otherwise you have to wait up to 24 hours until you can complete this step.

Install Nginx

Nginx is a reverse-proxy and load-balancer that routes incoming traffic to your internal endpoints. It needs to be installed and configured with the previously obtained certificates:

[remote]$ dnf -y install nginx

The nginx configuration specifies how nginx relays traffic from your public endpoint to your internal Ghost installation. It also handles SSL encryption for you.

Statically serving images

With a default install of Ghost, Nginx will proxy all requests to Node.js. That means Nginx is proxying a request that it could handle itself much faster. You may wonder why you should be concerned about proxy speeds on your CMS. There are two valid reasons why you want to improve image load performance:

  • Reduce initial build times: Although all resources are cached on Jamify, initial build times can be reduced when images are served faster.
  • Accelerate live sites: Feature images are added to the static assets in Jamify, but inline images are currently still served from your Ghost CMS.
[remote]$ (cat << EOF
server {
  listen 80;
  listen [::]:80;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;

  server_name cms.${DOMAIN};

  ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

  location / {    
    proxy_set_header Host \$http_host;
    proxy_set_header X-Real-IP \$remote_addr;
    proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto \$scheme;
  location ^~ /content/images/(!size) {
    root /root;
  location ^~ /.well-known/acme-challenge/ {
    default_type "text/plain";
    root /var/www/letsencrypt;

  location = /.well-known/acme-challenge/ {
    return 404;

) > /etc/nginx/conf.d/cms-ghost.conf

The first location block above describes the proxy to Ghost and is followed by the discussed bypass to serve images statically.

The last two location blocks are for LetsEncrypt, so certbot can renew your certificates while your web server is up and running. Finally, start nginx with:

[remote]$ systemctl enable --now nginx

and check it is running correctly:

[remote]$ systemctl status nginx
● nginx.service - The nginx HTTP and reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; vendor preset: disabled)
     Active: active (running) since Fri 2020-05-15 16:47:30 CEST; 6s ago

Ghost CMS in a Container

You are now fully prepared to install headless Ghost CMS. With all the prerequisites out of the way this is the easy part. A couple of environment variables must be defined that allow Ghost to send emails such as user invites or lost password requests:

[remote]$ SMTP_PORT=587
[remote]$ SMTP_PASS=strong password

Replace the values with your own mail provider and use the following command to make a docker-compose.yml definition file:

[remote]$ (cat << EOF
version: '3.3'

    image: ghost:alpine
    restart: always
      - 2368:2368
      - ./content:/var/lib/ghost/content
      # see
      url: https://cms.${DOMAIN}
      mail__transport: SMTP
      mail__from: ${EMAIL_FROM}
      mail__options__host: ${SMTP_HOST}
      mail__options__port: ${SMTP_PORT}
      mail__options__auth__user: ${SMTP_USER}
      mail__options__auth__pass: ${SMTP_PASS}
) > ./docker-compose.yml

This file configures the url for ghost and exposes the service on the standard port 2368. The service is configured to be persistent, so all configuration and database files are saved in your local directory under contents. This directory is created on-the-fly when you start the container.

Register your admin account

Fire up Ghost with docker. In this foreground mode, you see a lot of info messages and also warnings and errors, if they occur.

[remote]$ docker-compose up

Open your CMS in a web browser, which is in this example. You should see the Ghost sign up page. Complete the registration process until you see the Ghost Admin panel.

If everything goes fine, stop your running container with Ctrl+C and re-start it in detached mode, so it runs in the background.

[remote]$ docker-compose up -d

Now, your docker container will be running also after a reboot.

Configure Headless Mode

For headless mode, it's crucial to switch on the private flag. It makes sure that the Ghost CMS does not interfere with your Jamify front-end. I did not manage to change this setting on the command line, but it's not a big deal to do it in the admin panel:

This enables password protection in front of the Ghost install and sets <meta name="robots" content="noindex" /> so your Jamify front-end becomes the authoritative source for search engines.

Enable Members

If you plan to integrate a newsletter subscription form into your site or would like to use Ghost Members features in the future, it's now a good time to enable that feature in Ghost Admin.

Just activate the Enable members switch and leave all other settings with their defaults. You can come back to those later.

When your users subscribe to your site, Ghost CMS will send a magic subscription link to the provided email address. For that to work, email must be correctly configured. If you correctly set up your EMAIL and SMTP variables as discussed above, this should be already working. Test it by pressing the Send button under Labs -> Test email configuration:

In order for you users to be able to see sign up messages on the Jamify site, make the following tweak in your nginx configuration:

// /etc/nginx/conf.d/cms-ghost.conf

server {


  server_name cms.${DOMAIN};

  ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

  if ($args ~* "^action=subscribe&success=") {
    return 301 $scheme://www.${DOMAIN}$request_uri;

Jut put the shown if statement below the certificate block. This statement instructs nginx to redirect subscription messages to your Jamify front-end. I made the assumption, that your Jamify site runs under www.${DOMAIN}. Please change it to the public endpoint of your production site.


The above redirect for the action argument should be augmented by one that redirects other common routes to your live site. This can be done in your nginx configuration, but in order to show you an alternative method that works even if you do not have control over nginx, you can create a redirects.json file:

    "from": "^\/(?!content\/images\/|members\/|unsubscribe\/|p\/)(.*)",
    "to": "https://www.${DOMAIN}/$1",
    "permanent": true

that you upload with Ghost admin:

Although the above regex matches the endpoint /ghost, requests to you admin interface are not redirected. If you put a similar redirect in your nginx configuration, you must exclude that endpoint too.

Install Automation

In order to understand the installation steps, a manual install is preferable. Ultimately, you want to fully automate the process. I have great news for you: you can use a fully working automation script that I published on Github:

$ git clone
$ cd ghost-on-hetzner-cloud

The scripts also includes additional features, some of which are discussed further below.

  • Floating IPs
  • SSH on non-standard port
  • Scheduled backup
  • Certificate renewal
  • System updates

Hetzner Cloud doesn't let you attach environment variable in their Cloud Console, therefore you have to provide them trough your own scripts. Create a .env file that contains the following environment variables:

# .env file

# Server

# LetsEncrypt + Nginx

# Ghost email settings
Please take some time to review this file, because your install script will fail with wrong variables. You have to substitute at least all values starting from CLOUD_SSH_KEY until SMTP_PASS with your own ones.

Start the automated install with:

[ghost-on-hetzner-cloud]$ sh

The script introduces a break when it asks you to update/verify your DNS zone file. You can comment out these lines, once you know that DNS is working.

The full install takes approximately 10 minutes to complete. After that you have a fresh Ghost installation and you can continue with the above steps for registering your admin account and configuring headless mode.


If you want to set up more installations or just want to make a backup of a fresh install, you can make a snapshot:

hcloud server create-image --type snapshot ghost

You can always create a new server from your snapshot image:

hcloud server create --image 16614774 --location fsn1 --type cx11 --name ghost --ssh-key your@key

Note that snapshots occupy cloud space and therefore incur costs.

Danger zone - tear down

You can easily remove your server again with hcloud. This is especially great during the testing phase, because a removed server doesn't incur any costs.

Caution! Make sure you have backed up all your data before doing this. You cannot recover this step - everything on your server will be lost.
[local]$ hcloud server delete ghost
Server 5851202 deleted


If you are running your own cloud server, you need to be comfortable with maintaining it. You can automate a lot, but checking it from time to time is necessary.

Scheduled Backups

Taking snapshots as shown before, is a quick way to make backups. In the long run, you want a backup strategy and implement scheduled backups.

All you need to backup from Ghost is the content directory that we persisted earlier during the docker install. This directory contains the database file and the image assets. You can use a systemd timer to set up the schedule, compress the directory and copy it over to another location.

If you are using the automated install, this is already set up for you. Otherwise follow the manual steps below.

Start by creating a new directory for your backup storage:

$ mkdir -p /root/backup/weekly

and subsequently create the following systemd unit files backup-weekly.service and backup-weekly.timer:

[remote]$ (cat << EOF
Description=Backup Ghost

ExecStart=/usr/bin/sh -c 'rsync -avr --delete-after /root/backup /backup/weekly'
) > /usr/lib/systemd/system/backup-weekly.service
[remote]$ (cat << EOF
Description=Run backup-weekly.service

OnCalendar=Mon *-*-* 02:02:02

) > /usr/lib/systemd/system/backup-weekly.timer

In this example your backup is scheduled to run every Monday at 2 in the morning. Enable your timer with:

[remote]$ systemctl enable --now backup-weekly.timer

You can view all active timers with:

[remote]$ systemctl list-timers

In this example, the data is stored on the same disk. You can customize the systemd service and use rsync to copy all files in a remote location, for example in a private AWS S3 bucket. Another option is to use Hetzner's backup service that you can enable in the Cloud Console.

Certificate Renewal

The previously installed certbot already ships with a systemd service and timer similar to the ones that we previously discussed for backups. If you are using the automated install with the script, everything should already been set up for you. This is the manual install instruction:

[remote]$ systemctl enable --now certbot-renew.timer

Always check that the scheduler is correctly installed:

[remote]$ systemctl list-timers

Ghost updates

If you use the standard Ghost install based on the ghost-cli, updating to a new version is always risky: strangely enough the official update process does not provide a method to revert to a previous version.

Luckily, we are not affected by this limitation, as we are using the Docker approach. With Docker, updating Ghost is just a matter of downloading a new image and you can always revert back to the old image, if the update fails.

It is still important to make a backup of the content directory before updating, because the new version might make irreversible changes to your database.

In order to update, just update the docker-compose.yml file with the new image version. Here, I added 3.16.0-  to the image signature. You can always check the latest version on the Docker hub.

version: '3.3'

    image: ghost:3.16.0-alpine
    restart: always
      - 2368:2368

Once you have updated docker-compose.yml you need to restart with:

[remote]$ docker-compose up -d

My previous image was based on version 3.15.3, so I will see the new image pulled and the container restarted shortly after. You should then see a new entry in your image list:

[remote]$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
ghost               3.16.0-alpine       2e6185cc8040        16 hours ago        351MB
ghost               alpine              fcc77b9d5f98        7 days ago          336MB
This process could be automated and accompanied with a revert script. If you like to do that and contribute to this project, just go ahead an let us know.

System Updates

You should make every effort to keep your system up-to-date. With new security vulnerabilities discovered every day, frequent updates are a necessity. The following systemd units download updates every day:

[remote]$ (cat << EOF
Description=System update

ExecStart=/usr/bin/sh -c 'dnf -y update'
) > /usr/lib/systemd/system/system-update.service
[remote]$ (cat << EOF
Description=Run daily system update

OnCalendar=*-*-* 03:03:03

) > /usr/lib/systemd/system/system-update.timer

and performs a reboot to apply the kernel updates every week:

[remote]$ (cat << EOF
Description=System Reboot

ExecStart=/usr/bin/sh -c 'reboot'
) > /usr/lib/systemd/system/system-reboot.service
[remote]$ (cat << EOF
Description=Weekly system reboot

OnCalendar=Tue *-*-* 04:04:04

) > /usr/lib/systemd/system/system-reboot.timer

You can change these scripts to your own needs. They are already included and enabled if you use the automation scripts provided on ghost-on-hetzner-cloud.


This tutorial contains a lot of information. Congrats for following it through! Running your own Ghost instance is no rocket science: you can easily do it yourself. With this guide you get the knowledge and the tools to operate Ghost it in a predominantly unsupervised way.

While you learned a lot about the operational aspects, we also focused on a headless Ghost configuration: by setting the private flag correctly, by serving assets statically and by tweaking the members functionality.

You are now fully prepared for the next step: Sourcing your content from a headless Ghost into your Jamify front-end. Start publishing flaring fast websites today!

Do you want early access to Blogody, the brand new blogging platform that I am creating? Just sign-up on the new Blogody landing page and be among the first to get notified!