Deploy a containerized Ghost blog π»
Table of Contents
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
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!
# https://github.com/docker/compose
curl -LO https://github.com/docker/compose/releases/download/v2.19.0/docker-compose-linux-x86_64
# 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.
email youremail@domain.com
# Staging LetsEncrypt server to use while testing.
# Uncomment this before going to production!
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
# Basic virtual host definition to feed traffic into the
# Ghost container when it arrives.
example.com {
reverse_proxy ghost:2368
}
# OPTIONAL: Redirect traffic to 'www' to the bare domain.
www.example.com {
redir https://example.com{uri}
}
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 example.com, use something like test.example.com or new.example.com 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'
services:
# OPTIONAL
# Watchtower monitors all running containers and updates
# them when the upstream container repo is updated.
watchtower:
image: docker.io/containrrr/watchtower:latest
container_name: watchtower
restart: unless-stopped
hostname: coreos-ghost-deployment
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=3600
command:
- --cleanup
volumes:
- /var/run/docker.sock:/var/run/docker.sock
privileged: true
# Caddy acts as our external-facing webserver and handles
# getting TLS certs from LetsEncrypt.
caddy:
image: caddy:latest
container_name: caddy
depends_on:
- ghost
ports:
- 80:80
- 443:443
restart: unless-stopped
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:Z
- ghost:/var/www/html
- caddy_data:/data
- caddy_config:/config
# The Ghost blog software itself
ghost:
image: docker.io/library/ghost:5
container_name: ghost
restart: always
depends_on:
- ghostdb
environment:
url: https://example.com
database__client: mysql
database__connection__host: ghostdb
database__connection__user: ghost
database__connection__password: GHOST_PASSWORD_FOR_MARIADB
database__connection__database: ghostdb
volumes:
- ghost:/var/lib/ghost/content
# Our MariaDB database
ghostdb:
image: docker.io/library/mariadb:11
container_name: ghostdb
restart: always
environment:
MYSQL_ROOT_PASSWORD: A_SECURE_ROOT_PASSWORD
MYSQL_USER: ghost
MYSQL_PASSWORD: GHOST_PASSWORD_FOR_MARIADB
MYSQL_DATABASE: ghostdb
volumes:
- ghostdb:/var/lib/mysql
volumes:
caddy_config:
caddy_data:
ghost:
ghostdb:
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
andA_SECURE_ROOT_PASSWORD
above with better passwords. π - Also, set the
url
parameter for theghost
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.io/library/ghost:5 "docker-entrypoint.sβ¦" ghost
ghostdb docker.io/library/mariadb:11 "docker-entrypoint.sβ¦" ghostdb
watchtower docker.io/containrrr/watchtower:latest "/watchtower --cleanβ¦" watchtower
Awesome! π
Ghost initial setup #
With all of your containers running, browse to https://example.com/ghost/
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.
email youremail@domain.com
# Staging LetsEncrypt server to use while testing.
# Uncomment this before going to production!
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
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 theurl
key in theghost
container to the production domain - Apply the configuration with
sudo docker-compose up -d
Enjoy your new automatically-updating Ghost blog deployment! π»