Guides

Setting up Ghost CMS with Docker, Digital Ocean, and Cloudflare

A full guide on infophreak's Ghost CMS infrastructure. - Blog by SH3LL

SH3LL
· 14 min read
Send by email

Setting up Ghost CMS with Docker, Digital Ocean, and Cloudflare

A full guide on infophreak's Ghost CMS infrastructure. - Blog by SH3LL

Connect with me!

INTRODUCTION

Thanks for tuning in to my blog! We are going to be setting up a self-hosted Ghost CMS platform. This guide will walk you through from start to finish, including mailgun configuration and Ghost theme link troubleshooting.

We are going to use the following:

  • A Digital Ocean Ubuntu Droplet
  • Cloudflare Reverse Proxy/Origin Certificates
  • Cloudflare Advanced Certificates
  • Mailgun API
  • NGINX Docker
  • Community Maintained Ghost CMS Docker
  • MariaDB Docker

This guide assumes you have a decent understanding of Linux, a good amount of patience, and some basic troubleshooting skills. I will try to lay everything out for you the best I can.

Additionally, I will provide some basic steps to harden your droplet and docker setup. This is by no means the most secure build, but a decent baseline based on what I've learned. I highly recommend you do additional research to further secure your setup as needed such as SSH MFA, IPS/IDS, etc..


DEPLOY DROPLET

  • If you haven't already, create a Digital Ocean account. Once logged in, on your dashboard find the "Droplets" tab, under the "Manage" category, on the lefthand navigation plane.

Screenshot 2024-08-11 211214.png

Creation

  • Next, lets click "Create Droplet."
  • Choose the closest region to you, or the region where a majority of your users are located.
  • Under "Choose an image" we are going to use Ubuntu 24.04 (LTS) x64 for this guide.
  • Under "Droplet Type" we used "Basic" (optional).
  • Under "CPU options" we chose "Premium AMD" (optional) and 2GBmem/1cpu (minimum).
  • Under "Choose Authentication Method" select "SSH Key."

Let's create a key

On the device you are going to use to access your droplet, use this guide to create a key pair. This works on both Linux CLI (Command-line interface) and Windows Powershell and is a modern, secure key that works on most systems.

Upload the public key

Now that the keys have been generated and the private key has been added to the authentication agent, we can copy the contents of our public key and add it to the Digital Ocean droplet setup.

  • Click on "New SSH Key" in the droplet setup menu.
  • Paste the content of your full public key, making sure there is no whitespace or additional formatting.
  • Add a friendly name.
  • Click "Add SSH Key."

Finalize

Now lets finish up our setup with the last few options.

  • Under "We recommend these options" I recommend selecting "Add improved metrics monitoring and alerting (free)."
  • Under "Hostname" choose your desired droplet name.
  • Click "Create Droplet" at the bottom right.

Access

Now, from your local device, open up a powershell or terminal window as admin and connect to your droplet. In your Digital Ocean panel, you will see an IP for your Droplet. Use the IP in the following command:

ssh root@IP

if you run into issues, try to manually provide your private key (not the one that ends in .pub) with the following command:

Linux

ssh -i "/path/to/private/key" root@IP

Windows

ssh -i "\path\to\private\key" root@IP

Now, if you password-protected your private key, supply the password and you should now have a root shell on your droplet.


HARDENING DROPLET

My basic hardening guide can be referenced here.

Follow everything but ignore the "UFW setup" portion of that hardening guide and, instead, use this one:

UFW setup

We are going to be using UFW for this guide, it is easy to use and setup and does its job well. However, since most of our traffic is going to be going in and out of docker, setting up the firewall rules is a little tricky unless you want to do them by hand. For nginx, we will have to install it on the host in order to add the firewall rules. Once we've added the rules, we can remove nginx.

  1. Install ufw if not already installed.
sudo apt install ufw
  1. Install nginx.
sudo apt install nginx
  1. Install openssl.
sudo apt install openssl
  1. Allow ssh.
sudo ufw allow ssh
  1. Allow nginx.
sudo ufw allow 'NGINX Full'
  1. Enable ufw.
sudo ufw enable
  1. Uninstall nginx.
sudo apt purge nginx nginx-common -y
  1. Do not disconnect from your SSH session in case something went wrong. This is a good time to open a new terminal and verify you can still connect via SSH.

CLOUDFLARE & MAILGUN

If you haven't already created a cloudflare account, you can create one here.

Once logged in, you will need to add your domain to cloudflare. You can reference their documentation.

Once your site is added, navigate to your website in the cloudflare dashboard.

  1. Under the DNS tab, create an "A" record pointing to the IP address of your droplet. Under name put your root domain name (ex: "@" or "domain.com") or subdomain (ex: for "sub.domain.com" just put "sub"). Make sure that the proxy is toggled on.
  2. Under the SSL/TLS main tab, make sure current encryption mode is set to "Full (strict)". Then navigate to the "Edge Certificates" child tab and enable "Always use HTTPS."
  3. Still under the SSL/TLS section, find the "Origin Server" tab. Create a new origin certificate. You can use a wildcard *.domain.com to cover all your subdomains. During setup, make sure you download or copy the contents of the certificate and the key and name them accordingly because we will need to be able to distinguish between the key and the origin cert later.
  4. In order to use mailgun and cloudflare origin certificates, you have to sign up for Cloudflare Advanced Certificates.
    • First, lets get mailgun setup. Go ahead and create an account if you haven't already.
    • Make sure you verify your email or you will not be able to use the API.
    • Under Send -> Sending -> Domains click on "Add new domain."
    • Configure a subdomain to use for the Mailgun API. For this guide, we chose to go with "mailing.domain.com." Mailgun will provide the necessary DNS records to setup. Make sure to turn proxy off for these DNS records. Once DNS records are added, make sure, in Send -> Sending -> Domains -> Your Domain -> DNS Records, to use the verify button to confirm everything is in order.
    • Next, in Mailgun, go to Send -> Sending -> Domains -> Your Domain -> SMTP Credentials. If [email protected] exists, reset the password and take note of it. Otherwise, create a new SMTP user [email protected] and take note of the password.
    • Next, in Mailgun, click on your profile dropdown -> API Security and add a new API Key. Give it a relatable name and take note of the key.
    • Next, in Cloudflare -> SSL/TLS -> Edge Certificates -> Order Advanced Certificates create a new advanced certificate with the following domains: domain.com, *.domain.com, *.mailing.domain.com.

SETTING UP DOCKER APPLICATIONS

Since docker changes quite frequently, I'm going to refer you to the official documentation for installation. You will need to install docker engine and docker compose. Since docker does change, and there are multiple ways to install it, you may have to transpose some of these commands to the correct/modern syntax depending on when you are reading this guide.

Initial Environment

Now that docker engine and docker compose are installed, we can work on getting the docker environment setup.

Create a docker user

Lets first create a docker user so the containers arent running under a privileged user.

  1. Create new dockeruser and add to docker group.
sudo adduser dockeruser
sudo usermod -a -G docker dockeruser
  1. Create a Ghost directory and add correct permissions/ownership for our dockeruser.
sudo mkdir /etc/docker
sudo mkdir /etc/docker/ghost
sudo chown dockeruser:dockeruser /etc/docker
sudo chown dockeruser:dockeruser /etc/docker/ghost
sudo chmod 770 /etc/docker
sudo chmod 770 /etc/docker/ghost
  1. Switch to the docker user.
sudo su dockeruser
  1. Find the PUID of the dockeruser and take note of it. We will need it for the docker-compose.yml.
id -u dockeruser
  1. Find the PGID of the dockeruser and take note of it. We will need it for the docker-compose.yml.
id -g dockeruser

Directory & File Setup

  1. Navigate to the root ghost directory.
cd /etc/docker/ghost
  1. Create a docker-compose.yml file.
touch docker-compose.yml
  1. Create a database directory.
mkdir db
  1. Create an NGINX directory.
mkdir nginx
  1. Create a ghost directory.
mkdir ghost
  1. Navigate to ghost directory and create a config.production.json.
cd /etc/docker/ghost/ghost
touch config.production.json
  1. Navigate to nginx directory and create conf and key files.
cd /etc/docker/ghost/nginx
touch http.conf
touch origin.pem
touch key.pem

Your directory and files should look like this:

/ghost/
├── /db/
├── /nginx/
│   ├── http.conf
│   ├── key.pem
│   └── origin.pem
└── /ghost/
    └── config.production.json

docker-compose.yml Setup

  1. Navigate to the root Ghost directory.
cd /etc/docker/ghost
  1. Edit the docker-compose.yml file.
nano docker-compose.yml

Here is an example of how we have our docker compose yaml configured:

version: "3.3"
services:
  ghost:
    image: ghost:latest
    restart: always
    ports:
      - "127.0.0.1:2368:2368"
    depends_on:
      - db
    environment:
      - PUID=1000     # User ID (replace with dockeruser PUID)
      - PGID=1000     # Group ID (replace with dockeruser PGID)
      - url: https://domain.com     # replace
      - database__client: mysql
      - database__connection__host: db
      - database__connection__user: ghost
      - database__connection__password: securepassword2    # replace
      - database__connection__database: ghostdb
    volumes:
      - ./ghost/content:/var/lib/ghost/content
      - ./ghost/config.production.json:/var/lib/ghost/config.production.json
    networks:
      - default
      - internal

  nginx:
    image: nginx:stable
    restart: always
    environment:
      - PUID=1000     # User ID (replace with dockeruser PUID)
      - PGID=1000     # Group ID (replace with dockeruser PGID)
    volumes:
      - ./nginx/http.conf:/etc/nginx/nginx.conf
      - ./nginx/origin.pem:/certificates/origin.pem
      - ./nginx/key.pem:/certificates/key.pem
    ports:
      - 80:80
      - 443:443
    depends_on:
      - ghost
    networks:
      - default

  db:
    image: mariadb:latest
    ports:
      - 127.0.0.1:3306:3306
    restart: always
    environment:
      - PUID=1000     # User ID (replace with dockeruser PUID)
      - PGID=1000     # Group ID (replace with dockeruser PGID)
      - MYSQL_ROOT_PASSWORD: securepassword1    # replace
      - MYSQL_USER: ghost
      - MYSQL_PASSWORD: securepassword2    # replace
      - MYSQL_DATABASE: ghostdb
    volumes:
      - ./db/ghost/mysql:/var/lib/mysql
    networks:
      - internal

networks:
    default:
    internal:
       internal: true

The pros to this configuration are that the ghost and database containers are on an internal docker network and mapped only to a port accessible on localhost and is not exposed to the outside world. I designed the config this way so that no one can bypass nginx. They are forced to go through nginx in order to route to the localhost.

Ghost recommends using MySQL8.0. However, in this guide we are using MariaDB. It works for us, but we aren't storing anything special in the database such as emojis. If you plan to use emojis, you will need to use the Ghost docker and configuration documentation to use MySQL8.0 instead of MariaDB.

Make sure to add your own secret keys for the database and make sure to note securepassword1 and securepassword2 and keep them consistent because they are reused throughout the config. Also, make sure that you update the url to your actual domain. Additionally, confirm that all your paths are mapped correctly per your configuration. If you followed my schema, then this should already be good.

config.production.json Setup

Navigate to the ghost directory and edit the config.production.json file.

cd /etc/docker/ghost/ghost
nano config.production.json

Here is how we configured our config.production.json:

{
  "url": "https://domain.com",
  "server": {
      "port": 2368,
      "host": "0.0.0.0"
    },
    "database": {
      "client": "mysql",
      "connection": {
          "host": "db",
          "user": "root",
          "password": "securepassword1",
          "database": "ghostdb",
          "charset": "utf8"
      }
    },
    "mail": {
      "from": "'My Domain Name' <[email protected]>",
      "transport": "SMTP",
      "options": {
        "service": "Mailgun",
        "host": "smtp.mailgun.org",
        "port": 465,
        "secureConnection": true,
        "auth": {
          "user": "[email protected]",
          "pass": "SMTP-USER-PASSWORD"
        }
      }
    },
  "logging": {
      "transports": [
            "file",
            "stdout"
          ]
    },
  "process": "systemd",
  "paths": {
      "contentPath": "/var/lib/ghost/content"
    }
}

Make sure to update your url, from address, smtp user, smtp password, and database securepassword1.

Configuring nginx

  1. Navigate to the nginx directory.
cd /etc/docker/ghost/nginx
  1. Edit the http.conf file.
nano http.conf

Here is what our nginx config file looks like:

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    upstream ghost {
        server ghost:2368;
    }

    server {
        listen 80 default_server;
        listen [::]:80 default_server;
        server_name _;
        return 444; # Connection closed without response
    }

    server {
        listen 443 default_server ssl;
        listen [::]:443 default_server ssl;
        server_name _;
        ssl_reject_handshake on; # Reject SSL connection
    }

    server {
        listen 80;
        listen [::]:80;
        server_name domain.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name domain.com;

        ssl_certificate /certificates/origin.pem;
        ssl_certificate_key /certificates/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        client_max_body_size 5G;
        gzip on;
        gzip_comp_level 5;
        gzip_min_length 256;
        gzip_proxied any;
        gzip_vary on;
        gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

        location / {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $server_name;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header Cookie $http_cookie;
            proxy_buffering off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }

        location /events {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $scheme;
            proxy_set_header Cookie $http_cookie;
            proxy_http_version 1.1;
            chunked_transfer_encoding off;
            proxy_buffering off;
            proxy_cache off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }
    }

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name mailing.domain.com;

        ssl_certificate /certificates/origin.pem;
        ssl_certificate_key /certificates/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        client_max_body_size 5G;
        gzip on;
        gzip_comp_level 5;
        gzip_min_length 256;
        gzip_proxied any;
        gzip_vary on;
        gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

        location / {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $server_name;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header Cookie $http_cookie;
            proxy_buffering off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }

        location /events {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $scheme;
            proxy_set_header Cookie $http_cookie;
            proxy_http_version 1.1;
            chunked_transfer_encoding off;
            proxy_buffering off;
            proxy_cache off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }
    }

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name email.mailing.domain.com;

        ssl_certificate /certificates/origin.pem;
        ssl_certificate_key /certificates/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        client_max_body_size 5G;
        gzip on;
        gzip_comp_level 5;
        gzip_min_length 256;
        gzip_proxied any;
        gzip_vary on;
        gzip_types 
        application/atom+xml
        application/javascript
        application/json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

        location / {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $server_name;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header Cookie $http_cookie;
            proxy_buffering off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }

        location /events {
            proxy_pass http://ghost;
            proxy_redirect off;
            proxy_set_header Host $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-Host $scheme;
            proxy_set_header Cookie $http_cookie;
            proxy_http_version 1.1;
            chunked_transfer_encoding off;
            proxy_buffering off;
            proxy_cache off;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Upgrade $http_upgrade;
        }

    }
}


This config is set up to deny any incoming requests that are not going to domain.com, mailing.domain.com, and email.mailing.domain.com and forwards all http traffic to https and then to the ghost docker application on localhost. You will need to replace every occurrence of domain.com with your actual domain. This can be easily done by copying the config into a source-code editor like Visual Studio Code and using the find and replace feature.


In the same directory, we are going to insert our cloudflare origin and key files.

  1. Make sure you are in the correct directory.
cd /etc/docker/ghost/nginx
  1. Copy the contents of your origin cert to the origin.pem.
echo "content of origin cert goes here in quotes" > origin.pem
  1. Copy the contents of your key to the key.pem file.
echo "content of key goes here in quotes" > key.pem

FINISHING UP

Now that everything is configured, we can compose the docker application.

  1. Navigate to the root Ghost directory.
cd /etc/docker/ghost
  1. Compose the docker application.
docker compose up -d

Once the application is up, you should now be able to access your Ghost platform. Please note that DNS records take some time to propagate. If you are unable to access your sites please wait a few hours and try again. If you have any issues, please join our discord, we are happy to assist. If you found any issues with this guide, please let me know in the comments below and I will get it corrected.

If you need to troubleshoot, you can check the docker compose logs or use SSH tunneling to the localhost port for the ghost app. Then add a custom hosts file (linux | windows) entry to map the FQDN in order to view the app on your system's browser.

Mailgun Setting

Once you have done your initial setup, go into the admin settings -> Email newsletter -> Mailgun settings and put mailing.domain.com for the domain name and your API key for the key.

Troubleshooting Themes

We have noticed that some themes have incorrect links to the ghost api functions such as login, signup, account, etc. You may have to edit your theme source code to update these links. Always be sure to check if your theme has documentation as it may provide additional information. Here are all the correct links that you will need to make sure are being used accurately:

Depending on your version of ghost, you may have to confirm that this is still accurate. I thought it might be helpful to provide these as a quick reference because if you have a busted theme, its a tad painful to figure out what the correct links should be.

Thanks for reading!

- SH3LL

CONNECT