How to deploy Ghost using Ansible, Terraform, and Ghost CLI
Install Ghost blog instance using Terraform, Ansible, and Ghost CLI.
I maintain a repo for deployment files of this blog. It already uses Ansible, and Terraform to deploy this instance of Ghost. The only issue is that it uses the unofficial Docker image of Ghost to deploy the instance. I wanted to updated my deployment to use the Ghost recommended infrastructure. This meant, for one, an Ubuntu server with NodeJS 18, and using the Ghost CLI to deploy the actual Ghost instance. I wanted to go one step further and deploy a separate VM for the MySQL database so I can maintain the Ghost instance independently of the database.
Terraform spec updates
The update to the spec file are pretty predictable. I simply added two different servers with lower CPU and RAM configurations. One gigabyte of RAM and one CPU should be enough for most instance of Ghost. Especially with caching implemented.
resource "digitalocean_droplet" "ghost_server_4" {
image = "ubuntu-24-04-x64"
name = "ghost-server-4"
region = "tor1"
size = "s-1vcpu-1gb"
monitoring = true
ssh_keys = [var.ssh_key_id]
tags = ["blog", "ghost"]
droplet_agent = true
graceful_shutdown = true
backups = false
}
resource "digitalocean_droplet" "ghost_db_1" {
image = "ubuntu-24-04-x64"
name = "ghost-db-1"
region = "tor1"
size = "s-1vcpu-1gb"
monitoring = true
ssh_keys = [var.ssh_key_id]
tags = ["blog", "ghost", "db"]
droplet_agent = true
graceful_shutdown = true
backups = false
}
resource "digitalocean_monitor_alert" "cpu_alert" {
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/cpu"
compare = "GreaterThan"
value = 70
enabled = true
entities = [
digitalocean_droplet.ghost_server_4.id,
digitalocean_droplet.ghost_db_1.id
]
description = "Alert about CPU usage"
}infra.tf
Referral link

Playbook update
The Ansible playbook contains the bulk of the changes for this update. The first significant change was to remove the Docker dependency and all tasks that used Docker to deploy Ghost. While this method of deployment was working fine, it did take more resources than necessary and is not officially supported by Ghost. I then added the MySQL and NodeJS Ansible roles from Geerlingguy. The MySQL role exposes the variables needed to setup the database for Ghost, and NodeJS role is used to install the Ghost supported version of NodeJS.
---
- name: "DB play"
hosts: dbservers
vars:
mysql_root_password: "{{ lookup('env', 'GHOST_DB_PASSWORD') }}"
mysql_databases:
- name: ghost
mysql_users:
- name: "{{ lookup('env', 'GHOST_DB_USER') }}"
host: "%"
password: "{{ lookup('env', 'GHOST_DB_PASSWORD') }}"
priv: "ghost.*:ALL"
roles:
- role: geerlingguy.mysql
become: true
- name: "Ghost play"
hosts: webservers
vars:
nodejs_version: "18.x"
nodejs_npm_global_packages:
- name: ghost-cli
roles:
- role: geerlingguy.nginx
- role: geerlingguy.nodejs
- role: ghost
playbook.yaml
The main updates were made in my custom Ghost role tasks. Instead of simply copying the compose file to the server and running the docker-compose command, there is some setup required to run the Ghost CLI. After creating the custom ghost user, I had to create a custom sudoers file to allow the custom ghost user sudo privileges without a password. This, passwordless sudo, functionality is required to complete the ghost installation without human intervention.
---
- name: Add the user 'ghostuser' with a specific uid and a primary group of 'sudo'
ansible.builtin.user:
name: ghostuser
group: sudo
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: 100-ghost-user
dest: /etc/sudoers.d/100-ghost-user
mode: '440'
- name: Create a directory if it does not exist
ansible.builtin.file:
path: /var/www/hackandslash
owner: ghostuser
group: root
state: directory
mode: '755'
- name: Install Ghost
become: true
become_user: ghostuser
ansible.builtin.shell: |
/usr/local/lib/npm/bin/ghost \
install 5.87.1 \
--no-prompt \
--dir /var/www/hackandslash \
--url https://hackandslash.blog \
--port 2368 \
--sslemail "{{ lookup('env', 'ALERT_EMAIL') }}" \
--db mysql \
--dbhost 10.137.0.3 \
--dbuser "{{ lookup('env', 'GHOST_DB_USER') }}" \
--dbpass "{{ lookup('env', 'GHOST_DB_PASSWORD') }}" \
--dbname ghost \
--mail SMTP \
--mailservice Mailgun \
--mailuser "{{ lookup('env', 'GHOST_MAIL_USER') }}" \
--mailpass "{{ lookup('env', 'GHOST_MAIL_PASSWORD') }}" \
--mailhost smtp.mailgun.org \
--mailport 465 \
--process systemd
args:
executable: /bin/bashtasks/main.yaml
# User rules for ghostuser
ghostuser ALL=(ALL) NOPASSWD:ALLtemplates/100-ghost-user
After running the Terraform plan, and the playbook, everything should install without human interaction. You can find the full source code in the repo below.
Future changes
I will be updating the repo with a couple of new features soon:
- Ability to update installed instance of Ghost
- Firewall rules to block public access to the DB server