DigitalOcean Ghost blog with Terraform
This is a very basic setup for a Ghost blog routed globally through Cloudflare's network that you can run with minimal cost. It will run on the lowest tier droplet that DigitalOcean offers at $6 a month. The Cloudflare cost is roughly the cost of the domain ~$12 / year. Add automated backups to your droplet and your cost is just over $8 a month for the entire setup. For this setup we will use the Ghost Marketplace image on DigitalOcean which helps you quickly launch a single server Ghost blog site.
Pre-requisites
- Cloudflare account and API Key
- Digitalocean account and API token
- SSH Key configured in Digitalocean account so you can access the droplet.
Design
When I first created this design I borrowed elements from other blogs and built this out manually through the Cloud Web UIs. But for this blog we just love IAC so today we do this with Terraform. The less clicks, the better!
Our Terraform setup is just as simple as the design layout with three files:
- config.tf
- cloudflare.tf
- digitalocean.tf
You probably could put them all in one main.tf but I like to break things out to keep the configs cleaner. You can find all of these example files on my Github: https://github.com/randclem/ghost-blog-files
Let's take a look at the config.tf file.
Config.tf
Here we place all the provider configs and terraform requirements. I like to remind myself in the provider statements what environment variables I'll need to set before running any operations. Of course, you could use variables there also and pass the tokens in at run time if you wanted.
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "2.39.2"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.38.0"
}
}
}
provider "cloudflare" {
#Env var: export CLOUDFLARE_API_KEY
#Env var: export CLOUDFLARE_EMAIL
}
provider "digitalocean" {
# Env var: export DIGITALOCEAN_TOKEN
}
Digitalocean.tf
In our Digitalocean.tf file we setup the droplet resource using the Ghost Blog Marketplace image: "ghost-20-04"
. We retrieve the data resource for the public SSH key we have in the DigitalOcean account and attach that to the droplet so we can login use SSH key authentication. I highly recommend use SSH Key authentication on any and all cloud hosted VPS. It much more secure than using a root account with a password which can be possibly brute forced.
We also output the public IP address that gets assigned to the droplet. We will use this later to login to the droplet to finalize our post setup.
# SSH pub key data resource
data "digitalocean_ssh_key" "my_ssh_key" {
name = "My SSH Key"
}
# droplet resource
resource "digitalocean_droplet" "ghost_blog" {
image = "ghost-20-04"
name = "ghost-blog-2"
region = "nyc3"
size = "s-1vcpu-1gb"
backups = true
ssh_keys = [data.digitalocean_ssh_key.my_ssh_key.id]
}
# output the droplet public IP address
output "droplet_public_ip" {
value = digitalocean_droplet.ghost_blog.ipv4_address
}
Cloudflare.tf
In our cloudflare.tf file we place the Cloudflare configurations for the DNS zone records for our blog. In this example we use just one A record to create blog.example.com. We also need to grab the id of the zone so we add a data block in there to get the details for our Cloudflare zone. Also, the A record requires a Public IP address for the value which we get from our DigitalOcean configuration: digitalocean_droplet.ghost_blog.ipv4_address
.
Because the public IP address of the droplet won't be known until after its stood up, I added a depends_on
clause into the record to make sure this setup comes after the droplet is created. Terraform is usually pretty good about figuring out dependencies, but by using "depends_on" I can make sure it will do the steps in the correct order.
# zone data resource for example.com
data "cloudflare_zone" "example" {
name = "example.com"
}
# Zone A record resource
resource "cloudflare_record" "blog" {
zone_id = data.cloudflare_zone.example.id
name = "blog"
value = digitalocean_droplet.ghost_blog.ipv4_address
type = "A"
proxied = true
depends_on = [digitalocean_droplet.ghost_blog]
}
Terraform apply
Before we start using Terraform, we need to set our environment variables for API access to Cloudflare and DigitalOcean:
export CLOUDFLARE_API_KEY='abcdefghi123456'
export CLOUDFLARE_EMAIL='[email protected]'
export DIGITALOCEAN_TOKEN='09876poiulkjn'
With the environment variables set, running terraform apply
shows us the two new resources we will create, a cloudflare_record and a digitalocean_droplet.
<$> terraform apply
data.digitalocean_ssh_key.my_ssh_key: Reading...
data.cloudflare_zone.example: Reading...
data.digitalocean_ssh_key.my_ssh_key: Read complete after 0s [name=My SSH Key]
data.cloudflare_zone.example: Read complete after 0s
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# cloudflare_record.blog will be created
+ resource "cloudflare_record" "blog" {
+ allow_overwrite = false
+ created_on = (known after apply)
+ hostname = (known after apply)
+ id = (known after apply)
+ metadata = (known after apply)
+ modified_on = (known after apply)
+ name = "blog"
+ proxiable = (known after apply)
+ proxied = true
+ ttl = (known after apply)
+ type = "A"
+ value = (known after apply)
}
# digitalocean_droplet.ghost_blog will be created
+ resource "digitalocean_droplet" "ghost_blog" {
+ backups = true
+ created_at = (known after apply)
+ disk = (known after apply)
+ graceful_shutdown = false
+ id = (known after apply)
+ image = "ghost-20-04"
+ ipv4_address = (known after apply)
+ ipv4_address_private = (known after apply)
+ ipv6 = false
+ ipv6_address = (known after apply)
+ locked = (known after apply)
+ memory = (known after apply)
+ monitoring = false
+ name = "ghost-blog-2"
+ price_hourly = (known after apply)
+ price_monthly = (known after apply)
+ private_networking = (known after apply)
+ region = "nyc3"
+ resize_disk = true
+ size = "s-1vcpu-1gb"
+ ssh_keys = [
+ "11111111",
]
+ status = (known after apply)
+ urn = (known after apply)
+ vcpus = (known after apply)
+ volume_ids = (known after apply)
+ vpc_uuid = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ droplet_public_ip = (known after apply)
Post setup
After completing terraform apply
we will receive an output of the public IP address assigned to our DigitalOcean droplet.
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
droplet_public_ip = "157.245.212.250"
With that IP address we can login to our droplet using our SSH key to complete the Ghost application setup on the droplet. The droplet has a built-in startup script that makes application setup simple. All you need is the URL of your blog and an email for the Let's Encryption TLS certificate.
$ ssh -i /path/to/.ssh/my_id [email protected]
...
Configuring DigitalOcean 1-Click Ghost installation.
Please wait a minute while your 1-Click is configured.
...
Ghost will prompt you for two details:
1. Your domain
- Add an A Record -> 157.245.212.250 & ensure the DNS has fully propagated
- Or alternatively enter http://157.245.212.250
2. Your email address (only used for SSL)
Press enter when you're ready to get started!
After completing the steps to setup and install Ghost, you will have a fully ready Ghost installation that is being routed through Cloudflare. Complete the application setup at your Ghost Admin panel at https://<youserver url>/ghost
. And that is it.
Wrap up
In this blog post we stood up a Ghost Blog in DigitalOcean and started its content distribution through Cloudflare. And we did this all with Terraform. Now, you may notice some drawbacks with this solution. The first is that a post setup is still required to complete the "DigitalOcean 1-Click Ghost installation". Because this script is an interactive shell it requires some particular type of scripting to automate. The Linux "expect" package could be used here to automate this process, but at the end of the day, it was only two form fields to fill out and you still have to setup the Ghost blog in the Web GUI after that.
The second drawback with this solution is it is not high availability. Now some tweaks could be made with the Terraform script to create a duplicate droplet, and a Mysql server could be added. However, because the droplet image is designed to be a self contained easy to setup Ghost installation, it does require some additional configuration after the fact to setup a cluster with an external Mysql server. Not difficult to do, but would require additional configuration which could be done with other tooling such as Packer or Ansible. If you want high availability, you might even think about creating your own Packer image and using that instead of the DigitalOcean image.
There are additional setup requirements I would add to this for additional security for your Ghost installation, backup processes, as well as the management of the CMS itself. However, all that takes us outside of the Terraform territory and into using a configuration management tool like Ansible or Puppet. And finally, although this is a very inexpensive solution, the bottom tier droplet size is not very performant. If you find you are having issues with the setup or maintenance of the server due to performance issues, you could drop a few extra dollars a month for a 1 vCPU and 2GB RAM instance which would run you $12 / month. In order to do that, change the size in the DigitalOcean Terraform config to size = "s-1vcpu-2gb"
.
And that's it. I hope this was helpful. Enjoy!