A flowchart. Caddy 2 web server and ghost blog engine. A right directed arrow toward a grey box entitled google cloud platform. Inside the grey box, compute engine. Right directed arrow. Static IP address. Right directed arrow. Cloud flare content delivery network. From above the grey box, name cheap, domain registrar. Below directed arrow. Static IP address. From above cloud flare, mail provider mail gun. Below directed arrow. Cloud flare, content delivery network. Right directed arrow. Blog and newsletter.

I’m sold on this stack, jump to the tutorial!

Why this stack?

I recently helped a friend migrate his movie review blog off Wix’s free tier and onto a Ghost blog self-hosted on Google Cloud Platform (GCP). He was looking for

  1. the ability to use a custom domain without an additional fee;
  2. a visual, rich-text content editor;
  3. a user-friendly website control panel accessible from mobile; and
  4. a plug-and-play email newsletter feature

for free, or as close to free as possible.

Requirement (1) ruled out other “as-a-service” website builders, while (2), (3), and (4) together ruled out my go-to Jekyll + GitHub Pages combo. In the end, I settled on the stack shown in the diagram above. A brief overview of why I chose each component:

  • Blog engine. I chose Ghost as it has a beautiful content editor and a mobile-friendly control panel. While Ghost (the company) offers managed hosting plans for a fee, Ghost (the blog engine) is open-source and free to self-host.
  • Web server. I chose Caddy as the web server instead of the default Nginx because Caddy enforces HTTPS right out of the box. While it’s certainly possible to enforce HTTPS on Nginx using certbot and cron jobs, Caddy abstracts away literally everything to do with HTTPS, and I find it’s just that much easier to use.
  • Hosting server. I chose to use a Compute Engine virtual machine to host the website, because GCP has a unique Always Free Tier (distinct from their free trial period) with resources enough to permanently host a small- to medium-sized blog (1GB RAM, 1GB monthly traffic to all regions except China and Australia). GCP usually charges for static external IP addresses, but these are also free when attached to Free Tier virtual machines.
  • Domain registrar. I like to buy my domain names through Namecheap because they’re transparent with their prices and don’t fill your cart with upsells.
  • Content delivery network (CDN). I use Cloudflare’s free CDN service to shorten website loading times and protect against DDOS attacks. I also prefer managing DNS records through Cloudflare (rather than at the domain registrar level); not only is domain resolution faster, the user interface is also sleeker.
  • Mail provider. Ghost has a built-in newsletter feature that integrates most easily with Mailgun. I’m not too familiar with mail providers, but Mailgun has been great so far, consistently sending emails to inbox, not spam. The first 1,250 emails every month are free, and 0.80 USD / thousand emails thereafter.

I tried to come up with a tech stack that was as low-cost as a self-hosted website could possibly be. In the diagram above, the only cost for certain is the annual domain name rental fee; everything else is either totally free (Caddy, Ghost) or free with usage limits (GCP, Cloudflare, Mailgun).


In this walkthrough, I outline the whole process of setting up each and every part of this exact stack, in five stages:

  1. Set up GCP
  2. Configure the domain
  3. Deploy Ghost and Caddy
  4. Configure Ghost
  5. Finish Cloudflare configuration

The whole thing takes 1-2 hours depending on your comfort level with the various technologies.

Note: To follow along with the configuration instructions below, edit the highlighted values and leave all other fields at default values.

1. Set up GCP

Initialize GCP

  1. Log in to the Google account you want to use with GCP. Make a GCP account here.
  2. Select Create Project:
    • Project name: ghost-blog
    • Location: No organization
  3. In the floating topbar, Activate Free Trial. You’ll be asked to set up a billing account and put your card details on file.
  4. (Optional) In the floating topbar, Activate a paid account. I prefer to do this immediately for two reasons. One, everything you’ve set up today will be deleted unless you remember to upgrade before the free trial ends; and two, the resources used (at least in this tutorial) should stay within the bounds of the Always Free tier anyway.
  5. Go to Billing > Budgets & alerts > Create Budget:
    • Name: ghost-blog-budget
    • Time range: Monthly
    • Budget type: Specified amount
    • Target amount: 2.00 (as cost should be zero, anyway)
    • Manage notifications: Email alerts to billing admins and users

Initialize Compute Engine

  1. Go to Compute Engine > Enable API.
  2. (Optional) If asked to Create Credentials, do so:
    • Data accessing: Application Data
    • Yes, I'm using Compute Engine
  3. Go to Instance Templates > Create Instance Template:
    • Name: free-web-server
    • Machine family: General-purpose
    • Series: E2
    • Machine type: e2-micro (2vCPU, 1GB memory)
    • Boot disk:
      • Operating system: Ubuntu
      • Version: Ubuntu 20.04 LTS
      • Boot disk type: Standard persistent disk
    • Firewall:
      • Allow HTTP traffic
      • Allow HTTPS traffic
  4. Go to VM instances > Create an instance:
    • New VM Instance from template: free-web-server
    • Name: ghost-blog
    • Region: us-west1 (or any region on the Always Free Tier)
    • Security:
      • Turn on Secure Boot
      • Turn on vTPM
      • Turn on Integrity Monitoring
  5. Go to Snapshots > Create Snapshot Schedule:
    • Name: weekly-backup-schedule
    • Schedule location: us-west1 (the same region as your instance)
    • Snapshot storage location: Regional
      • Location: us-west1
    • Schedule frequency: Weekly
  6. Go to Disks > ghost-blog > Edit:
    • Snapshot schedule: weekly-backup-schedule

Initialize VPC Network

  1. Go to VPC network > Firewall > Create Firewall Rule:
    • Name: allow-outgoing-2525
    • Logs: Off
    • Direction: Egress
    • Action on match: Allow
    • Targets: All instances in the network
    • Destination filter:
      • IPv4 ranges
      • Destination ranges:
    • Protocols and ports:
      • Specified protocols and ports
      • tcp: 2525
  2. Go to External IP addresses > Reserve Static Address:
    • Name: ghost-blog-ip
    • Network service tier: Standard
    • Region: us-west1 (the same region as your instance)
    • Attached to: ghost-blog
  3. Take note of the External IP address.

2. Configure the domain

Buy a domain and set up Cloudflare

  1. Make a Namecheap account and buy your domain. I’ll use ghostblog.com as an example. Going forward, everywhere you see ghostblog.com, replace it with your own domain name.
  2. Make a Cloudflare account and add your domain under the Free subscription plan.
  3. Under “Review your DNS records”, first delete all the records.
  4. Add record:
    • Type: A
    • Name: ghostblog.com
    • IPv4 address: the static IP address you just reserved
    • Proxy status: DNS only
  5. Save > Continue > Confirm.
  6. Go back to Namecheap > ghostblog.com > Manage > Nameservers > Custom DNS and input the specified Cloudflare nameservers.
  7. Go back to Cloudflare > Done, check nameservers.
  8. Go to Cloudflare > ghostblog.com > Overview, then in the right sidebar, Advanced Actions > Pause Cloudflare on site while finishing setup.

Set up Mailgun

  1. Make a Mailgun account under the Foundation Trial plan. You’ll be asked to confirm a phone number and put your card details on file.
  2. Select Add a custom domain:
    • Domain name: ghostblog.com
    • Domain region: US (unless your website requires within-EU data processing)
  3. Go back to Cloudflare > ghostblog.com > DNS and add the five DNS records Mailgun requires. Turn Proxy Status off (i.e., set to DNS only).
  4. Go back to Mailgun > Verify DNS settings.
  5. Once the custom domain has been added to Mailgun, go to Sending > Domain Settings > SMTP Credentials. Take note of the “login” (usually [email protected]).
  6. Click Reset password > Reset password > Copy. Paste this password somewhere safe—it will only be generated this once!
  7. Go to Settings > API Keys, show the Private API Key and copy it somewhere safe.

3. Deploy Ghost and Caddy

Set up VM

  1. Go back to GCP > Compute Engine > VM Instances > ghost-blog > SSH. A virtual terminal (“cloud shell”) will appear in a pop-up window.
  2. First, set a password for the root user:

     sudo passwd
  3. Switch to root user:
  4. Update Linux:
     apt update
     apt upgrade

Install Docker

  1. Install dependencies:
     apt install apt-transport-https ca-certificates curl software-properties-common
  2. Get Docker GPG key:
     curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
  3. Download Docker:
     sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
  4. Install Docker:
     sudo apt update
     sudo apt-cache policy docker-ce
     sudo apt install docker-ce
  5. Make a new sudo-eligible user called service_account:
     adduser service_account
  6. Set a password for service_account. Leave the “user information” for service_account at default values, and confirm with Y.

  7. Give service_account sudo privileges:
     usermod -aG sudo service_account
  8. Close this cloud shell window and open a new one.

  9. Switch to service_account:
     su - service_account
  10. Allow service_account to use Docker:
    sudo usermod -aG docker service_account
  11. Close this cloud shell window and open a new one.

  12. Verify that Docker works by running:
    docker run hello-world
  13. Set Mailgun SMTP credentials as environment variables. First open the bash profile in Vim:
    vi .profile
  14. Vim has two modes, “Command Mode” (file is read-only) and “Insert Mode” (file is editable). Press I to enter Insert Mode. Add these lines at the bottom of the file, replacing the fields with the Mailgun SMTP credentials you obtained:
    export mail__options__auth__user="[email protected]"
    export mail__options__auth__pass="theSMTPpasswordyoucopied"
  15. Press Esc to return to “Command Mode”. Type :wq to save and quit, then press Enter.

  16. Update the profile with:
    source .profile
  17. Close this cloud shell window and open a new one.

Install Ghost and Caddy

  1. Switch to service_account:
     su - service_account
  2. Make a new directory called ghost and navigate to it:
     mkdir /home/service_account/ghost
     cd ghost
  3. If you chose to run Mailgun from the EU, replace smtp.mailgun.org with smtp.eu.mailgun.org. Other than that, run the below command as is. This will download, configure, and run a Ghost image within Docker:
     docker run -d \
     --restart always \
     --name ghost-blog \
     -v /home/service_account/ghost/content:/var/lib/ghost/content \
     -p 2368:2368 \
     -e url=https://ghostblog.com \
     -e mail__transport="SMTP" \
     -e mail__options__host="smtp.mailgun.org" \
     -e mail__options__port=2525 \
     -e mail__options__auth__user \
     -e mail__options__auth__pass \
  4. Run the command below and note the “Container ID” associated with the “ghost” Image:
     docker ps
  5. Run the command below, replacing containerid with yours. Verify that the environment variables (the docker run fields tagged with -e) were saved.
     docker exec containerid printenv
  6. Create a Caddyfile to store the configuration for the Caddy web server:
     vi Caddyfile
  7. In the text below, replace [email protected] with your own email. (Caddy uses your email address to procure free SSL certificates from Let’s Encrypt.) Press I to enter Insert Mode. Type out the following text in Vim directly, using tabs to indent lines:
     https://ghostblog.com {
         proxy / ghost-blog:2368 {
         tls [email protected]
  8. Press Esc to return to “Command Mode”. Type :wq to save and quit, then press Enter.

  9. Install Caddy, keying in Y when prompted:
     docker run -it \
     --restart always \
     --link ghost-blog:ghost-blog \
     --name caddy \
     -p 80:80 \
     -p 443:443 \
     -v /home/service_account/ghost/Caddyfile:/etc/Caddyfile \
     -v /home/service_account/.caddy:/root/.caddy \
  10. Try to visit your domain https://ghostblog.com. Sometimes you might need to go back to Cloudflare and Pause Cloudflare on this site for a while. Once the webpage connects and displays the default Ghost template, close the cloud shell window. Installation is complete!

4. Configure Ghost

  1. Go to https://ghostblog.com/ghost. Set up your website basic details.
  2. Go to Settings (the “gear” icon) > Email newsletter > Email newsletter settings:
    • Mailgun region: whichever you selected previously
    • Mailgun domain: ghostblog.com
    • Mailgun Private API key: the Private API key you took note of
  3. Turn off “Enable newsletter open-rate analytics”.
  4. Save Settings.
  5. (Optional) To disable the hovering “Subscribe” button, go to Membership > Customize Portal and disable “Show Portal Button.”

5. Finish Cloudflare Configuration

  1. Go back to Cloudflare. If you had previously Paused Cloudflare, go back to Advanced Actions > Enable Cloudflare on site.
  2. Go to ghostblog.com > DNS > Add record:
    • Type: A
    • Name: www.ghostblog.com
    • IPv4 address: your GCP external IP address
    • Proxy status: Proxied
  3. Edit the other A record to change Proxy status to Proxied.
  4. Go to ghostblog.com > SSL/TLS > Overview and change SSL/TLS encryption mode to Full.


At this point you should have a working self-hosted Ghost blog. The technical setup is over—from now on, you should be working within the https://ghostblog.com/ghost control panel instead.


This walkthrough last worked for me in January 2022. If you spot errors, vulnerabilities, or potential improvements, please do open a pull request on this blog post!


This tutorial owes a debt of gratitude to The Applied Architect’s Ghost on GCP tutorial and Brian Burroughs’ Ghost + Caddy tutorial, which helped me piece together the deployment process in Step 3.