Deploying a Self-Hosted Ghost Blog on Kubernetes

Deploying a Self-Hosted Ghost Blog on Kubernetes
Photo by Lan Gao / Unsplash

In this post, I'll walk you through the process of deploying a self-hosted Ghost blog on a Kubernetes cluster.

There are a couple of key reasons why I chose to do this. First and foremost, I wanted to save money by not paying $11 per month for the Ghost Pro subscription, especially since I wasn't utilizing all the premium features. Additionally, I recently set up a home server, and I was keen to maximize its resources by running various services.

In this tutorial I will use the following docker image to botstrap the blog.

https://github.com/docker-library/ghost?tab=readme-ov-file

Storage Setup


The first thing that comes to my mind on this journey is that I want my blog content data to be stored outside the container. This way, data is not linked to container lifecycle.

For that, I created a StorageClass, PersistentVolume, PersistentVolumeClaim

  • StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
  namespace: internet-apps
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
  • PersistentVolume
apiVersion: v1
kind: PersistentVolume
metadata:
  name: ghost-pv
  namespace: internet-apps
  labels:
    type: local
spec:
  storageClassName: local-storage
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/data/ghost"

The hostPath volumes need to be accessible on all nodes in your cluster. because it ties to the node a pod is on, so the path needs to be there and reachable on any node where the pod might run.

  • PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ghost-pvc
  namespace: internet-apps
spec:
  storageClassName: local-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  volumeName: ghost-pv

Deployment

The Ghost Docker image allows managing configuration outside of the container in a MySQL database. For this, I created a config map to manage MySQL connection values:

  • ConfigMap mysql-config
apiVersion: v1
data:
  MYSQL_HOST: mysql-service.internet-apps.svc.cluster.local
  MYSQL_USER: xxxxx
kind: ConfigMap
metadata:
  name: mysql-config
  namespace: internet-apps
  • ConfigMap wp-db-secrets
apiVersion: v1
data:
  MYSQL_ROOT_PASSWORD: xxxxx
kind: Secret
metadata:
  name: wp-db-secrets
  namespace: internet-apps

We also need to specify the URL, which is the public URL of your Ghost blog.

  • Url

In my case : https://blog.v2.abakri.xyz

  • Deployment Config file
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ghost
  namespace: internet-apps
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ghost
  template:
    metadata:
      labels:
        app: ghost
    spec:
      containers:
      - name: ghost
        image: ghost:5-alpine
        env:
          - name: database__client
            value: "mysql"
          - name: database__connection__host
            valueFrom:
              configMapKeyRef:
                name: mysql-config
                key: MYSQL_HOST
          - name: database__connection__user
            valueFrom:
              configMapKeyRef:
                name: mysql-config
                key: MYSQL_USER
          - name: database__connection__password
            valueFrom:
              secretKeyRef:
                name: wp-db-secrets
                key: MYSQL_ROOT_PASSWORD
          - name: database__connection__database
            value: "xxx"
          - name: url
            value: "https://blog.v2.abakri.xyz"
        ports:
        - containerPort: 2368
        volumeMounts:
        - name: ghost-storage
          mountPath: "/var/lib/ghost/content"
      volumes:
      - name: ghost-storage
        persistentVolumeClaim:
          claimName: ghost-pvc

Service

  • Cluster IP Service
    targetPort need to be 2368 and not 80 ;)
apiVersion: v1
kind: Service
metadata:
  name: ghost-service
  namespace: internet-apps
spec:
  selector:
    app: ghost
  ports:
    - protocol: TCP
      port: 80
      targetPort: 2368
  • Ingress

You must have an ingress controller (Nginx in my case), and cert-manager needs to be deployed with letsencrypt-prod configured.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ghost-ingress
  namespace: internet-apps
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  rules:
    - host: blog.abakri.xyz
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ghost-service
                port:
                  number: 80
  tls:
    - hosts:
        - blog.abakri.xyz
      secretName: ghost-service-tls
  • Domain Configuration(Google Domains)

Bingo !