How to run Ghost blog inside Kubernetes

For the very first post of this blog, we will see together how I installed Ghost.

Source files are available here.

Architecture

Ghost is a decoupled content management system allowing great flexibility.

Because its core is composed of a REST and a GraphQL API, we can plug in any technology to retrieve the publications and display them. By default, a turnkey interface is offered but nothing prevents you from using an Angular, React or Blazor frontend.

We're going to deploy it in Azure using Ghost's vanilla flavor. Follow me!

Deployment

I'm going to disregard the creation of the environment to not expand too much, but you must have a Kubernetes server in hands.

1 - Creating the namespace

This will allow you to isolate resources to facilitate management and cleaning.

kubectl create namespace blog

2 - Creating a volume for permanent storage

As Docker containers are volatile, the information contained in them will not be kept following a restart unless you associate a volume with them. We will let Kubernetes auto create an Azure File Share for our blog with a PersistentVolumeClaim.

#File : blog-persistent-volume-claim.yml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: blog-pvc
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: azurefile
  resources:
    requests:
      storage: 50Gi
kubectl apply -f .\blog-persistent-volume-claim.yml --namespace=blog

3 - Creating a deployment

The deployment defines the specifications to be achieved for our Pods. Pods allow multiple containers to share the same context. Once deployed, a controller will monitor the current state of the resources to reach the desired specification. For example, if two Pods are requested and one is deleted, then a new Pod will be automatically be created.

#File : blog-deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: blog
  namespace: blog
  labels:
    app: blog
    release: 3.38.2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: blog
      release: 3.38.2
  template:
    metadata:
      labels:
        app: blog
        release: 3.38.2
    spec:
      volumes:
      - name: blog-content
        persistentVolumeClaim:
          claimName: blog-pvc
      containers:
      - name: blog
        image: ghost:3.38.2-alpine
        env:
        - name: url
          value: <domain-name>
        volumeMounts:
        - name: blog-content
          mountPath: /var/lib/ghost/content
        resources:
          limits:
            cpu: "1"
            memory: 256Mi
          requests:
            cpu: 100m
            memory: 64Mi
        ports:
        - name: http
          containerPort: 2368
          protocol: TCP
      restartPolicy: Always
kubectl apply -f .\blog-deployment.yml --namespace=blog

4 - Validation of deployment

Following a deployment, it is good practice to always check whether the operation went well.

kubectl get events --namespace=blog
kubectl get pods --namespace=blog

You should have a container ready.

NAME                    READY   STATUS    RESTARTS   AGE
blog-679b759f94-kl87h   1/1     Pending   0          22s

5 - Creation of a service

We will now expose the Pods to an IP address internal to the cluster.

#File : blog-service.yml
apiVersion: v1
kind: Service
metadata:
  name: blog
  namespace: blog
spec:
  type: ClusterIP
  selector:
    app: blog
  ports:
  - protocol: TCP
    port: 80
    targetPort: 2368
kubectl apply -f .\blog-service.yml --namespace=blog

6 - Creation of the certificate

We will be using Let's Encrypt, which allows you to generate trusted certificates for free.

You must first have created a certificate issuer called "letsencrypt-production". The easiest way to do this is to use cert-manager. Here is the procedure : https://cert-manager.io/docs/installation/kubernetes/

Create your certificate :

#File : blog-tls.yml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: blog-tls
spec:
  secretName: blog-tls
  dnsNames:
  - <domain-name>
  acme:
    config:
    - http01:
        ingressClass: nginx
      domains:
      - <domain-name>
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
kubectl apply -f .\blog-tls.yml --validate=false --namespace=blog

7 - Creating an IngressController

The gateway to your services in Kubernetes will be an Nginx controller.
The good thing about it is that all traffic coming from outside is encrypted thanks to the certificate automatically published by Let's encrypt. Then, inside the cluster, we reduce the complexity of the services by only using the HTTP protocol.

Tanks to Ahmet Alp Balkan for the diagram

#File : blog-ingress.yml
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: blog
  namespace: blog
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-production
    nginx.ingress.kubernetes.io/rewrite-target: /$1
    nginx.ingress.kubernetes.io/use-regex: "true"
spec:
  tls:
  - hosts:
    - <domain-name>
    secretName: blog-tls
  rules:
  - host: <domain-name>
    http:
      paths:
      - path: /(.*)
        backend:
          serviceName: blog
          servicePort: 80
kubectl apply -f .\blog-ingress.yml --namespace=blog

8- Domain name routing

All you have to do now is associate your domain name with the ClusterIp.
To do this, you will need to create an @ record and a * record with your registrar.

To get the public IP of the cluster:

kubectl get ingress --namespace=blog

Kubernetes NodePort vs LoadBalancer vs Ingress? When should I use what?

Next

I plan to show how to backup and restore the Azure File Share. Tell me what you would like to see next! ;)