Skip to main content
  1. Posts/

Deploy a containerized Ghost blog πŸ‘»

·1130 words·6 mins·

There’s no shortage of options for starting a self-hosted blog. Wordpress might be chosen most often, but I stumbled upon Ghost recently and their performance numbers really got my attention.

I prefer deploying most things in containers these days with Fedora CoreOS. Luckily, the Ghost stack doesn’t demand a lot of infrastructure:

  • Ghost itself
  • MySQL 8+ (I went with MariaDB 11.x)
  • A web server out front
  • TLS certificate
Although I chose MariaDB for the database here, Ghost recommends MySQL and will throw a warning in the admin panel if you’re using something else. I haven’t had any issues so far, but you’ve been warned. πŸ’£

I picked Caddy for the webserver since it’s so small and the configuration is tremendously simple.

Launch CoreOS #

Fedora CoreOS offers lots of cloud options for launching it immediately. Many public clouds already have CoreOS images available, but I love Hetzner’s US locations and I already had a CoreOS image loaded up in my account.

πŸ‡©πŸ‡ͺ Want CoreOS at Hetzner? There’s a blog post for that!

Once your CoreOS instance is running, connect to the instance over ssh and ensure the docker.service starts on each boot:

sudo systemctl enable --now docker.service

This ensures that containers come up on each reboot. CoreOS has a podman socket that listens for docker-compatible connections, but that doesn’t help with reboots.

Perhaps I’m old fashioned, but I still enjoy using docker-compose for container management. I like how I can declare what I want and let docker-compose sort out the rest.

Let’s install docker-compose on the CoreOS instance now:

# Check the latest version in the GitHub repo before starting!
curl -LO

# Install docker-compose and make it executable.
sudo mv docker-compose-linux-x86_64 /usr/local/bin/docker-compose
sudo chown +x /usr/local/bin/docker-compose

Verify that docker-compose is ready to go:

$ docker-compose --version
Docker Compose version v2.19.0

Preparing Caddy #

Caddy uses a configuration file called a Caddyfile and we need that in place before we deploy the other containers. Within my home directory, I created a directory called caddy:

mkdir caddy

Then I added the Caddyfile inside the directory:

    # Your email for LetsEncrypt warnings/notices.

    # Staging LetsEncrypt server to use while testing.
    # Uncomment this before going to production!

# Basic virtual host definition to feed traffic into the
# Ghost container when it arrives. {
    reverse_proxy ghost:2368

# OPTIONAL: Redirect traffic to 'www' to the bare domain. {

This configuration sets up LetsEncrypt certificates automatically from the staging server for now. Once we know our configuration is working well, we can comment out the acme_ca line above and get production TLS certificates.

At this point, you need a DNS record pointed to your server so you can get a certificate. You have some options:

  • If the site is entirely new, just point the root domain name to your CoreOS instance. Use that domain in the configuration above and later in the deployment.

  • If you’re migrating from an existing site, choose a subdomain off your main domain to use. If your website is, use something like or to get Ghost up and running. It’s really easy to change this later.

Now we’re ready for the rest of the deployment.

Deploying containers #

Here’s the docker-compose.yml file I’m using:

version: '3.8'

  # Watchtower monitors all running containers and updates
  # them when the upstream container repo is updated.
    container_name: watchtower
    restart: unless-stopped
    hostname: coreos-ghost-deployment
      - --cleanup
      - /var/run/docker.sock:/var/run/docker.sock
    privileged: true

  # Caddy acts as our external-facing webserver and handles
  # getting TLS certs from LetsEncrypt.
    image: caddy:latest
    container_name: caddy
      - ghost
      - 80:80
      - 443:443
    restart: unless-stopped
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:Z
      - ghost:/var/www/html
      - caddy_data:/data
      - caddy_config:/config

  # The Ghost blog software itself
    container_name: ghost
    restart: always
      - ghostdb
      database__client: mysql
      database__connection__host: ghostdb
      database__connection__user: ghost
      database__connection__password: GHOST_PASSWORD_FOR_MARIADB
      database__connection__database: ghostdb
      - ghost:/var/lib/ghost/content

  # Our MariaDB database
    container_name: ghostdb
    restart: always
      MYSQL_USER: ghost
      MYSQL_DATABASE: ghostdb
      - ghostdb:/var/lib/mysql


I love watchtower but that step is completely optional. It does require some elevated privileges to talk to the podman socket, so keep that in mind if you choose to use it.

Our ghostdb container starts first, followed by ghost, and then caddy. That follows the depends_on configuration keys shown above.

There are two steps to take now:

  • Replace GHOST_PASSWORD_FOR_MARIADB and A_SECURE_ROOT_PASSWORD above with better passwords. πŸ˜‰
  • Also, set the url parameter for the ghost container to your blog’s domain name.

Once all of that is done, let’s let docker-compose do the heavy lifting:

sudo docker-compose up -d

Let’s verify that our containers are running:

$ sudo docker-compose ps
NAME                IMAGE                                    COMMAND                  SERVICE         
caddy               caddy:latest                             "caddy run --config …"   caddy           
ghost                     "docker-entrypoint.s…"   ghost           
ghostdb                "docker-entrypoint.s…"   ghostdb         
watchtower   "/watchtower --clean…"   watchtower      

Awesome! πŸ‘

Ghost initial setup #

With all of your containers running, browse to Just add /ghost/ to the end of your domain name to reach the admin panel. Create your admin account there with a good password.

If everything looks good, run back to your Caddyfile and comment out the acme_ca line:

    # Your email for LetsEncrypt warnings/notices.

    # Staging LetsEncrypt server to use while testing.
    # Uncomment this before going to production!
    # acme_ca

Restart the caddy container to get a production LetsEncrypt certificate on the site:

sudo docker-compose restart caddy

Customizing Ghost #

Ghost looks for lots of environment variables to determine its configuration and you can set these in your docker-compose.yml file. Although some configuration items are easy, like url, some are nested and get more complicated. For these, you can use double underscores __ to handle the nesting.

As an example, we already used database__connection__host in the docker-compose.yaml, and that’s the equivalent to this nested configuration:

"database": {
    "connection": {
        "host": "..."

If you’re deploying in containers, it’s a good idea to configure Ghost via environment variables. This ensures that your docker-compose.yml is authoritative for the Ghost deployment. You can exec into the container, adjust the config file on disk, and restart Ghost, but then you have to remember where you configured each item. πŸ₯΅

Switching to production domain #

If you used a temporary domain to get everything configured and you’re ready to use your production domain, follow these steps:

  • Open your Caddyfile and replace all instances of the testing domain with the production domain
  • Restart caddy: sudo docker-compose restart caddy
  • Edit the docker-compose.yml and change the url key in the ghost container to the production domain
  • Apply the configuration with sudo docker-compose up -d

Enjoy your new automatically-updating Ghost blog deployment! πŸ‘»