12 min read

Standing up this website on DigitalOcean with Pulumi and Ghost

A screenshot of the TypeScript code used to deploy this website with Pulumi
Actual code, written by a human

Friends, here's a truth I'm not thrilled to admit: It's now been almost three years since I began work on The Pulumi Book.

Even by non-fiction book standards, that's quite a little while. I could come up with all sorts of reasons why it's taken so long – the shock and disruption of COVID-19, the awkward decision to part ways with Manning and reboot the book on my own terms on Leanpub, the challenges of remote work and the pace of startup life, the daily grind as a homeschooling parent – all legit, justifiable reasons, as far as I'm concerned. But I'd be lying if I said I wasn't a bit disappointed that it still isn't done. (And I'm sure at least some of my readers are, too.) Writing a book is a lot more work than I ever expected, and the trade-offs, particularly where they require giving up precious time with family, are real. I love it, and there's ultimately nothing else I'd rather be doing. But it hasn't been easy.

Nevertheless, I remain committed, so for the last month or so, I've been working hard to find and keep a regular time slot in the schedule for writing, and as of this week, I'm happy to report that the book and all of its examples have been updated to support the latest versions of Pulumi and @pulumi/aws, and that I'm well into the second part of the book now as well. Now, when someone asks, "Hey, Chris – how's the writing's going?" I can finally reply that it's going well, which makes me happy. And in the interest of working more in the open (and of keeping myself accountable), I've set up a public GitHub project to track the progress of the book from one chapter to the next. So if you're interested, I'd encourage you to keep an eye on that space – and to consider starring the examples repository to keep up with new programs as they land from week to week. (One of which will come out of this very post!)

Beyond the book proper, though, I've also been wishing I had some sort of a blog or companion site to go along with it. Up to now, of course, a companion website hasn't been a priority; the book itself has made for more work than I could handle, and the last thing I needed was another distraction to pull me away from the writing of it. But here's the thing – well, two things, actually. First, not everyone has the time (or the patience) to read through a technical book these days; most of us are just trying to learn what we need in order to get our work done for the day and get on with our lives. Blog posts and short, focused videos are, for better or worse, still a great fit for this. And second, considering so much of what I learn every day (and would love to be able to share with my readers) about Pulumi and what can be done with it is neither written in TypeScript nor deployed on AWS, so chances are, it wouldn't ever make it into my book, anyway. In short, what I needed was a low-friction way to share all of the useful stuff I pick up as an engineer working with Pulumi every day and share it with people like you without having to figure out how to work it into the book (and then ask you to pay for it).

So I set out this week to make that happen, and this site is the result. And I must say, I'm pretty happy with it so far – it's delightfully simple, does all I need (and more), and I was able to do it all in less than an hour with DigitalOcean, Ghost, Mailgun, Cloudflare, Pulumi (naturally) and a hundred or so lines of TypeScript.

So with the remainder of this post – since you're here, and since I happen to have a few minutes left of my vacation –  I'll show you how.

It all starts with a template

I started out with the standard Pulumi DigitalOcean template:

pulumi new digitalocean-typescript

Why DigitalOcean and not AWS, you ask? Well, here's what I knew going in:

  • I knew I wanted to at least try to use Ghost CMS (self-hosted of course), as I've always been impressed with it, and I knew it had a lot of the functionality I was looking for.
  • I liked the idea of using AWS, and I knew Ghost had an official container image that I could use for the Ghost service itself; I figured Fargate might be an okay fit, maybe behind an ALB, and with Aurora MySQL for the database. But a quick run of the numbers made it clear this approach wouldn't come cheap, and having to work out where to store all of the uploaded imagery and other stateful bits (configuration, themes, customizations) and then wire up all of the right services, permissions, and so on made me feel ... well, nauseous probably isn't quite the right word for it – but still, it was clear things were beginning to venture into more-trouble-than-it's-worth territory for sure.
  • I'm a huge fan of DigitalOcean – always been happy with it, constantly singing its praises – but to implement a similar container-based architecture on App Platform, with the managed MySQL cluster behind it, would end up costing about as much as it would on AWS (albeit with a lot less infrastructural hassle). So containers, unfortunately, were probably out.
  • I knew there was an official Ghost droplet image, however – and that it's actually quite nice; indeed it's the same setup my kids' BSA troop uses for its own website. (I know because I'm the former troop webmaster!) The managed image not only ships with the ghost CLI pre-installed, making configuring and upgrading the app incredibly simple, but also installs and configures a MySQL instance for the Ghost app for you. Once the droplet's up and running, you're good to go – the DB's just there.  
  • As much as I do not dig the idea of mutable infrastructure, sometimes a single VM really is all you need. After all, it's just a blog – it's not a business-critical app that my team is shipping to paying customers fifty times a week. The important thing isn't to have the finest, most exemplary cloud architecture powering my blog – it's to have a practical setup that lets me write quickly and easily and publish my writing with as little fuss as possible. (And before you mention it, yes, I realize static websites are always an option – but to be honest, given how much time I already spend toiling in Markdown and the mechanics of static-website delivery, the thought of creating yet another one definitely did make me feel nauseous – right word this time.)

So I figured, what the heck – let's keep it simple and go with a DigitalOcean droplet and managed Ghost image. Sure, mutable-bad; sure, always-on – but also easy to stand up and use, feature-rich, inexpensive, scalable, back-uppable, and fortunately, totally manageable with Pulumi. Always with the tradeoffs, of course, but in this case – at least for now – they felt like the right ones to me.

Fleshing out the Pulumi program

Below is the complete Pulumi program contained in a single file, index.ts file. In it, you'll see that it:

  • pulls in the DigitalOcean, Cloudflare (I happen to use to manage pulumibook.info), Mailgun, and Random providers, along with the Pulumi SDK and the built-in Node.js fs module,
  • defines a DigitalOcean domain and DNS record for the droplet,
  • provisions a droplet using an SSH public key that you specify,
  • defines a Mailgun domain (Ghost uses Mailgun to send transactional and bulk email for user accounts, newsletters, and more), and
  • creates a handful of Cloudflare DNS records to route requests to DigitalOcean and verify domain ownership (with TXT and MX records) for Mailgun.

That's pretty much it. There's a little more baked into the details – optional support for apex domains, a randomly generated password to give Mailgun for the SMTP service (tracked as a Pulumi secret) – but the gist is that when the program completes, you're left with a running DigitalOcean droplet ready to be configured along with the DNS and mail services you'll need to start cranking out blog posts and newsletters with Ghost.

import * as pulumi from "@pulumi/pulumi";
import * as digitalocean from "@pulumi/digitalocean";
import * as cloudflare from "@pulumi/cloudflare";
import * as mailgun from "@pulumi/mailgun";
import * as random from "@pulumi/random";
import * as fs from "fs";

// Import the configuration settings.
const config = new pulumi.Config();
const hostname = config.require("hostname");
const cloudflareDomain = config.require("cloudflareDomain");
const dropletImage = config.require("dropletImage");
const dropletSize = config.require("dropletSize");
const sshPublicKeyPath = config.require("sshPublicKeyPath");

// Set a few local variables.
const digitalOceanDomainName = hostname ? `${hostname}.${cloudflareDomain}` : cloudflareDomain;
const digitalOceanDNSRecordName = hostname ? pulumi.interpolate`${hostname}.${cloudflareDomain}` : "@";
const cloudflareDNSRecordNameForDroplet = hostname || "@";

// Upload the SSH public key to DigitalOcean.
const sshKey = new digitalocean.SshKey("ssh-key", {
    publicKey: fs.readFileSync(sshPublicKeyPath).toString("utf-8"),
});

// Provision a droplet using the specified SSH key.
const droplet = new digitalocean.Droplet("digitalocean-droplet", {
    image: dropletImage,
    size: dropletSize,
    backups: true,
    sshKeys: [
        sshKey.fingerprint,
    ],
});

// Provision a DigitalOcean domain.
const domain = new digitalocean.Domain("digitalocean-domain", {
    name: digitalOceanDomainName,
});

// Provision a DigitalOcean DNS record for the droplet.
const record = new digitalocean.DnsRecord("digitalocean-dns-record", {
    domain: domain.name,
    name: digitalOceanDNSRecordName,
    value: droplet.ipv4Address,
    type: "A",
});

// Generate a random password to be used for the SMTP server.
const smtpPassword = new random.RandomPassword("smtp-password", {
    length: 16,
    special: true,
}).result;

// Provision a Mailgun domain.
const mailgunDomain = new mailgun.Domain("mailgun-domain", {
    name: `mail-${pulumi.getStack()}.${cloudflareDomain}`,
    region: "us",
    dkimKeySize: 1024,
    smtpPassword: smtpPassword,
    spamAction: "disabled",
});

// Look up the target Cloudflare domain.
const dnsZone = cloudflare.getZoneOutput({
    name: cloudflareDomain,
});

// Provision a CloudFlare DNS record for the droplet.
const dnsRecord = new cloudflare.Record("dns-record", {
    zoneId: dnsZone.zoneId,
    type: "A",
    name: cloudflareDNSRecordNameForDroplet,
    value: droplet.ipv4Address,
    ttl: 3600,
    proxied: false,
});

// Provision a TXT verification record for each "sending" recordset.
mailgunDomain.sendingRecordsSets.apply(records => {
    records.forEach((record, i) => {
        if (record.recordType === "TXT") {
            new cloudflare.Record(`txt-record-${i}`, {
                name: record.name,
                zoneId: dnsZone.zoneId,
                type: record.recordType,
                value: record.value,
                ttl: 3600,
            });
        }
    });
});

// Provision a CloudFlare MX record for each "receiving" recordset.
mailgunDomain.receivingRecordsSets.apply(records => {
    records.forEach((record, i) => {
        if (record.recordType === "MX") {
            new cloudflare.Record(`mx-record-${i}`, {
                name: `mailgun.${cloudflareDomain}`,
                zoneId: dnsZone.zoneId,
                type: record.recordType,
                value: record.value,
                priority: parseInt(record.priority),
            });
        }
    });
});

// Export all of the generated goodies.
export const ipv4Address = droplet.ipv4Address;
export const url = pulumi.interpolate`https://${dnsRecord.hostname}/ghost`;
export const mailConfig = pulumi.jsonStringify({
    mail: {
        transport: "SMTP",
        options: {
            service: "Mailgun",
            auth: {
                user: mailgunDomain.smtpLogin,
                pass: mailgunDomain.smtpPassword,
            },
        },
    },
});
index.ts

Deploying it all

To stand it all up for yourself, you'll need to make sure you've got a few fundamentals in place, including:

Once you've got all three providers configured, you can start adding the requisite providers by installing their Node.js packages – for example:

yarn add @pulumi/cloudflare
yarn add @pulumi/mailgun
yarn add @pulumi/random

After you've overwritten the code in  index.ts with the snippet above, you can add a few configuration settings to the dev stack (which should be the active one). Be sure to make the appropriate adjustments to the values for your domain and SSH public key path:

pulumi config set cloudflareDomain pulumibook.info
pulumi config set dropletImage ghost-20-04
pulumi config set dropletSize s-2vcpu-2gb
pulumi config set hostname dev
pulumi config set sshPublicKeyPath /Users/christian/.ssh/id_rsa.pub

(By the way, the SSH key can be any public key not already in use on DigitalOcean; you'll use its private counterpart later when you log into the provisioned VM.)

That's it! You can now deploy with a single pulumi up:

pulumi up
...

Updating (pulumibook/dev)

View Live: https://app.pulumi.com/pulumibook/website/dev/updates/1

     Type                             Name                     Status             
 +   pulumi:pulumi:Stack              website-dev              created (68s)      
 +   ├─ digitalocean:index:SshKey     ssh-key                  created (1s)        
 +   ├─ digitalocean:index:Domain     digitalocean-domain      created (2s)        
 +   ├─ random:index:RandomPassword   admin-password           created (0.34s)     
 +   ├─ random:index:RandomPassword   smtp-password            created (0.20s)     
 +   ├─ digitalocean:index:Droplet    digitalocean-droplet     created (63s)       
 +   ├─ mailgun:index:Domain          mailgun-domain           created (1s)        
 +   ├─ cloudflare:index:Record       txt-record-1             created (1s)        
 +   ├─ cloudflare:index:Record       mx-record-0              created (1s)        
 +   ├─ cloudflare:index:Record       mx-record-1              created (1s)        
 +   ├─ cloudflare:index:Record       txt-record-2             created (2s)        
 +   ├─ cloudflare:index:Record       dns-record               created (0.71s)     
 +   └─ digitalocean:index:DnsRecord  digitalocean-dns-record  created (0.81s)     

Outputs:
    ipv4Address: "167.172.32.67"
    mailConfig : [secret]
    url        : "https://dev.pulumibook.info/ghost"

Resources:
    + 13 created

Duration: 1m10s

And in a minute or so, your new blog will be up and running at the exported stack output URL.

Well, almost up and running.  There's still one thing left to do.

Configuring the Ghost app

There might be a way to do this programmatically, but if there is, I couldn't find it – and I suppose that'd make sense, considering the process is interactive. (A script runs on login that prompts you for values like you domain name and e-mail address in order to finish the setup proceess.) To get that going, ssh into the droplet using the private counterpart of the public key you used for the droplet:

ssh -i /Users/christian/.ssh/id_rsa root@167.172.32.67

When you do that, you'll see that the configuration process starts automatically:

-------------------------------------------------------------------------------
Configuring DigitalOcean 1-Click Ghost installation.

Please wait a minute while your 1-Click is configured. 

Once complete, you are encouraged to run mysql_secure_installation to ready
your server for production. The root MySQL password has been saved to:

    root/.digitalocean_password
-------------------------------------------------------------------------------

mysqld is alive
Ensuring Ghost-CLI is up-to-date...
...

Ghost will prompt you for two details:

1. Your domain
 - Add an A Record -> 167.172.32.67 & ensure the DNS has fully propagated
 - Or alternatively enter http://167.172.32.67
2. Your email address (only used for SSL)

Press enter when you're ready to get started!

When you're prompted, enter the fully-qualified URL for the droplet (for me, that was https://dev.pulumibook.info) and your e-mail address, then wait for the process to finish (which usually takes a few minutes). When it's done, you should see that:

Ghost was installed successfully! To complete setup of your publication, visit: 
    https://dev.pulumibook.info/ghost/

For any further commands, please switch to the ghost-mgr user to manage Ghost.
    sudo -i -u ghost-mgr

Huzzah! You're almost there.

Now, follow those instructions: switch to the ghost_mgr user and change to the directory that serves as the home of your newly minted Ghost app:

sudo -i -u ghost-mgr
cd /var/www/ghost

The last thing to do – well, second-to-last – is to configure Ghost to use Mailgun for email. The settings you need were exported as a stack output – but because the SMTP password is tracked as an encrypted Pulumi secret, the whole batch was masked with [secret] by the Pulumi CLI. To obtain them, you'll just need to tell Pulumi to show them:

pulumi stack output mailConfig --show-secrets

{
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "Mailgun",
      "auth": {
        "user": "postmaster@mail-dev.pulumibook.info",
        "pass": "<some-password>"
      }
    }
  }
}

With those settings, you can overwrite the mail configuration block in config.production.json. I like to use nano for this sort of thing, so for example, from within /var/www/ghost:

nano config.production.json

Here's what the mailblock should look like when you're done:

{
  "url": "https://dev.pulumibook.info",
  ...
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "Mailgun",
      "auth": {
        "user": "postmaster@mail-dev.pulumibook.info",
        "pass": "<some-password>"
      }
    }
  },
  ...
}

Save the file, then restart the app:

ghost restart

And in a moment, you should be able to browse to your server (at /ghost) and set up your administrator account. Almost there!

Verifying your mail settings

A moment ago I said there was one last thing to do, and this time I mean it is it. Navigate to the Mailgun console, find the domain you just set up (it should be named something like mail-dev.pulumibook.info), and click Verify DNS settings  to have Mailgun query Cloudflare for your new TXT and MX records. (If you don't complete this step, you won't be able to use Ghost's built-in mail functionality, and your users won't be able to sign up for your amazing newsletters-to-be.)

A screenshot of the Mailgun console showing the Verify DNS settings button

It usually takes a few tries to get all of the records verified (DNS takes a bit to propagate), but once it does, you should be able to browse to your website's home page, click the Subscribe button, register for your own newsletter, confirm your email address, and use the forgot-password functionality.

A screenshot of the deployed website, showing the signup dialog

Wrapping up

And with that, you're done – time to start writing. You can stick with a single dev stack if you like, or instead (as I did), keep the dev stack for experimentation and create a second production stack using the dev stack config as a foundation:

pulumi stack init --copy-config-from dev

To use an apex domain (e.g., https://pulumibook.info – sans the dev), set the hostname to an empty string to tell both DigitalOcean and Cloudflare to use @ for their DNS name records:

pulumi config set hostname ""

Deploy with pulumi up as you did before, and voilà – you're live.

I hope this post helps you as much as has me. If it does – or even if it doesn't! – do let me know how it goes in the comments.

Thanks for reading, and thanks for hanging with me. Happy Ghosting! 👻


Get the code!

You'll find the full source for this example on GitHub:

examples/website/ghost-on-digitalocean at main · pulumibook/examples
Contribute to pulumibook/examples development by creating an account on GitHub.

Create a new project and deploy it right now

To bootstrap a new Pulumi project with the code from this example above, just click the magic button below and follow the prompts: