Ghost CMS on Hetzner Cloud
Tags: webdev, gatsby, ghost, cms
Published: 2020-06-22 10:16:56

---
title: Ghost CMS on Hetzner Cloud
published: true
date: 2020-04-07 07:00:00 UTC
tags: webdev, gatsbyjs, ghost, cms
canonical_url: https://www.jamify.org/2020/04/07/ghost-cms-on-hetzner-cloud/
---

Learn howto install and run a headless Ghost content management system in the cloud. Deploy Ghost in a Docker container and make it accessible to your teams. This guide focuses on the specifics of a headless configuration and fully prepares you for the awesome Jamstack.

 ![Ghost CMS on Hetzner Cloud](https://images.unsplash.com/photo-1471877325906-aee7c2240b5f?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ)

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](https://www.jamify.org) sources in all content from a [Ghost CMS](https://ghost.org/), this guide focuses on setting it up correctly for the [Jamstack](https://jamstack.org/) 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](https://www.digitalocean.com), [Scaleway](https://www.scaleway.com) or [Vultr](https://www.mamboserver.com/go/vultr), just to name three alternatives.

For this guide, I choose [Hetzner Cloud](https://hetzner.cloud/?ref=S3XW3ucjywSi), 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. Just [_Sign up_](https://hetzner.cloud/?ref=S3XW3ucjywSi) 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:

 ![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-ssh.png)

 ![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-tokens.png)

> 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](https://github.com/hetznercloud/cli) on Github. After installation, check the version:

```bash
[local]$ hcloud version
hcloud v1.16.2
```

You can now create a context in `hcloud` that lets you access your _ghost project._

```bash
[local]$ hcloud context create ghost
Token:
```

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!

```bash
[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:

```bash
[local]$ hcloud server list
ID NAME STATUS IPV4 IPV6 DATACENTER
5838079 ghost running 49.12.109.72 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:

![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-zonefile.png)

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 _blazing-blogs.com._

> 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.

### Firewall

Log into your server with `ssh root@$(hcloud server ip ghost)` and install the _uncomplicated firewall_ `ufw`:

```bash
[remote]$ dnf -y install ufw
```

Create a file containing the firewall commands:

```bash
[remote]$ (cat << EOF
#!/bin/sh
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
EOF
) > ./firewall.sh
```

Execute and enable it with:

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

You can check the firewall rules at any time with:

```bash
[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:

```bash
[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](https://fedoraproject.org/wiki/Common_F31_bugs#Other_software_issues).

```bash
[remote]$ grubby --update-kernel=ALL \
    --args="systemd.unified_cgroup_hierarchy=0"
```

### Reboot

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

```bash
[remote]$ reboot
```

### Logon again

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

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

```

and check that the firewall is up and running

```bash
 [remote]$ ufw status
```

and the kernel updates have been applied:

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

## Install Docker

Use Fedora's package manager to install Docker:

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

Fedora now uses the [Moby Project](https://mobyproject.org/) to assemble the Docker components. Check that docker is running with an up-to-date version:

```bash
[remote]$ docker version
Client:
 Version: 19.03.8
Server:
 Engine:
  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.

```bash
[remote]$ dnf -y install certbot 
```

Substitute _blazing-blogs.com_ with **your domain,** _your@mail.com_ with **your email** and put it in a variable:

```bash
[remote]$ DOMAIN=blazing-blogs.com
[remote]$ EMAIL=your@mail.com

```

Use `certbot` to get a certificate:

```bash
[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](https://www.nginx.com/) 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:

```bash
[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.

```bash
[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;
    proxy_pass http://127.0.0.1:2368;
  }
    
  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;
  }

}
EOF
) > /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:

```bash
[remote]$ systemctl enable --now nginx
```

and check it is `running` correctly:

```bash
[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:

```bash
[remote]$ EMAIL_FROM=noreply@your-blog.com
[remote]$ SMTP_HOST=mail.server.com
[remote]$ SMTP_PORT=587
[remote]$ SMTP_USER=user@server.com
[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:

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

services:
  ghost:
    image: ghost:alpine
    restart: always
    ports:
      - 2368:2368
    volumes:
      - ./content:/var/lib/ghost/content
    environment:
      # see https://docs.ghost.org/docs/config#section-running-ghost-with-config-env-variables
      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}
EOF
) > ./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.

```bash
[remote]$ docker-compose up
```

Open your CMS in a web browser, which is `https://cms.blazing-blogs.com` in this example. You should see the Ghost sign up page. Complete the registration process until you see the Ghost Admin panel.

 ![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-ghost-setup-1.png)

 ![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-ghost-create-account-1.png)

 ![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-ghost-invite-users-1.png)

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

```bash
[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:

![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/hetzner-cloud-ghost-headless-mode.png)

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.

![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/jamify-cms-members-1.png)

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_:

![Ghost CMS on Hetzner Cloud](https://cms.jamify.org/content/images/2020/05/jamify-cms-email-test-1.png)

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:

```bash
// /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.

## 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](https://github.com/styxlab/ghost-on-hetzner-cloud) that I published on Github:

```bash
$ git clone https://github.com/styxlab/ghost-on-hetzner-cloud.git
$ 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:

```bash
# .env file

# Server
CLOUD_SERVER_NAME=ghost
CLOUD_SERVER_IMAGE=fedora-32
CLOUD_SERVER_TYPE=cx11
CLOUD_SERVER_LOCATION=fsn1
CLOUD_SSH_KEY=you@home

# LetsEncrypt + Nginx
DOMAIN=your-blog.com
EMAIL=you@your-blog.com

# Ghost email settings
EMAIL_FROM=noreply@your-blog.com
SMTP_HOST=mail.server.com
SMTP_PORT=587
SMTP_USER=user@server.com
SMTP_PASS=SMTP_password
```

> 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:

```bash
[ghost-on-hetzner-cloud]$ sh create.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._

## Snapshots

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

```bash
hcloud server create-image --type snapshot ghost
```

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

```bash
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.

```bash
[local]$ hcloud server delete ghost
Server 5851202 deleted
```

## Operation

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:

```bash
$ mkdir -p /root/backup/weekly
```

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

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

[Service]
Type=oneshot
ExecStart=/usr/bin/sh -c 'rsync -avr --delete-after /root/backup /backup/weekly'
EOF
) > /usr/lib/systemd/system/backup-weekly.service

[remote]$ (cat << EOF
[Unit]
Description=Run backup-weekly.service

[Timer]
OnCalendar=Mon *-*-* 02:02:02

[Install]
WantedBy=multi-user.target
EOF
) > /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:

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

You can view all active timers with:

```bash
[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 `create.sh` script, everything should already been set up for you. This is the manual install instruction:

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

Always check that the scheduler is correctly installed:

```bash
[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](https://hub.docker.com/_/ghost?tab=description).

```bash
version: '3.3'

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

```

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

```bash
[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:

```bash
[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:

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

[Service]
Type=oneshot
ExecStart=/usr/bin/sh -c 'dnf -y update'
EOF
) > /usr/lib/systemd/system/system-update.service

[remote]$ (cat << EOF
[Unit]
Description=Run daily system update

[Timer]
OnCalendar=*-*-* 03:03:03

[Install]
WantedBy=multi-user.target
EOF
) > /usr/lib/systemd/system/system-update.timer
```

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

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

[Service]
Type=oneshot
ExecStart=/usr/bin/sh -c 'reboot'
EOF
) > /usr/lib/systemd/system/system-reboot.service

[remote]$ (cat << EOF
[Unit]
Description=Weekly system reboot

[Timer]
OnCalendar=Tue *-*-* 04:04:04

[Install]
WantedBy=multi-user.target
EOF
) > /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`.

## Summary

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!](https://www.blogody.com/landing)

***

This post was originally published at [jamify.org](https://www.jamify.org/2020/04/07/ghost-cms-on-hetzner-cloud/) on April 7, 2020.