Running Ghost in Kubernetes
Reference Table
Reference Table
ACME: Automated Certificate Management Environment, a way to get a TLS certificate commonly used by LetsEncrypt.
CRDs: CustomResourceDefinition in K8s. A way to define a custom data type that will be used K8s entities.
Horizontal scaling: Scaling by adding more nodes that allow for work to be done.
Gateway: The K8s API that replaces Ingress
Ingress: An K8s API that defines a load balancer now replaced with its successor.
K8s : Kubernetes
K3s: A lightweight Kubernetes Distribution
Vertical scaling: upgrading a single or set of cluster to high end compute nodes able to handle the new load.
My home lab is mostly running off a K3s install these days, so anything I run tends to be in kubernetes. Not everything needs to be in k8s and ghost is definitely not optimized for horizontal scaling. Still, it is nice to create a full install using yaml files.
The main limitation with ghost is that you can't really scale the replica count. You can only reliably run a single instance.
Limitations:
- Scheduler conflicts: Ghost's built-in job scheduler (for email newsletters, post scheduling) runs in every instance. Without coordination, multiple pods will fire the same jobs simultaneously, causing duplicate emails sent to subscribers, duplicate post publishes, and race conditions on time-sensitive tasks. This could be solved with redis or by using postgres that has a pub/sub pattern but with the way things are this would be a blocker.
- File-based storage: The default local file storage for images/media lives on disk. In a multi-pod setup, each pod has its own filesystem — uploads to Pod A won't be visible to Pod B. You must replace local storage with an object store (S3, GCS, Azure Blob) via a Ghost Storage Adapter. Or you would need to install a plugin for ghost to allow that. Both of the ones I'm aware of have not had a lot of love.
- GSS support https://github.com/danmasta/ghost-gcs-adapter version 2.0.0
- S3 support -- https://github.com/colinmeinke/ghost-storage-adapter-s3 v2.8.0
- I used to maintain a version of the ghost image patched up with both plugins: https://github.com/OSAlt/gb-docker-ghost but It's been a while since I looked at that closely. I'm fairly certain the GCS works though could likely benefit from an update.
- In-memory caching: Ghost caches settings, routes, and some content in process memory. Pods don't share this cache, so cache invalidation across instances is not automatic.
So basically, keep your replica count at 1 to avoid upsetting your subscribers or creating a website with multiple 404s that are non deterministic.
For

Requirements:
There are two high level areas of components. One category is general infrastructure, and the other category is the application and supporting elements.
Infrastructure:
- DHCP server equivalent IPPool ( in K8s)
- Load Balancer of some kind (Gateway Envoy in my case)
- Valid TLS
Application:
- Database (MariaDB/MySQL)
- Ghost
Infrastructure
A lot of this is not really inherent to running ghost but it should be touched on. I'm going to do a pretty high level introduction but I'll reference all the components that are needed.
MetalLB
metallb is the easiest way I've found to get an IPPool. Essentially no matter if you run Ingress or Gateway you will need something that manages IP allocation. If you use a cloud provider that's handled by your cloud provider. If you run things locally, metallb does that for you.
You'll need to install metallb first. You can follow the docs but helm chart is the pattern I used:
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb
helm install metallb metallb/metallb -f values.yaml
I didn't really add anything to the values.yaml but you can add your customizations.
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: external-pool
namespace: metallb-system
spec:
addresses:
- 1.2.3.4/32
- fd37:beef:cafe::1/64
autoAssign: true
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: external
namespace: metallb-system
spec:
ipAddressPools:
- external-poolEnvoy Gateway
If you are using ingress skip over this and to your own thing.
You need to install the gateway API CRDs.
helm template eg oci://docker.io/envoyproxy/gateway-crds-helm \
--version v1.8.0 \
--set crds.gatewayAPI.enabled=true \
--set crds.gatewayAPI.channel=standard \
--set crds.envoyGateway.enabled=true \
| kubectl apply --server-side -f -Then install the actual helm chart that installs the controller:
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.8.0 \
-n envoy-gateway-system \
--create-namespace \
--skip-crds
This sets up envoy but once again you have no config, this just installs the watcher.
You may need to add the gateway class as well:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
Cert-Manager
Cert-Manager is how you do ACME/Lets Encrypt in a K8s cluster. Its the equivalent of installing certbot. It won't do anything without you asking it to do so, but it installs the tool. Same pattern as before.
helm install \
cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.20.2 \
--create-namespace \
-f values.ymlMy values.yml file is below, the main thing that's import since I'm using gateway is to ensure the gateway API is enabled:
crds:
enabled: true
keep: true
config:
enableGatewayAPI: true
namespace: cert-manager
Here, we'll create two issuers that you can use:
"Production", gets a real cert:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: somev@alidemail.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: http-gateway
namespace: default
kind: Gateway
And one called "Staging" for testing:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: somev@alidemail.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: http-gateway
namespace: default
kind: Gateway
Envoy Continued:
Now, we're ready to configure the Gateway and Certs
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gateway-tls-cert
namespace: default
spec:
secretName: gateway-tls-cert
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- auth.domain.com ## will be used in part 2.
- blog.domain.com ## your blog site
I'm using the default NS for the cert and gateway but feel free to put in anywhere you like but the gateway will need to be able to access the cert by either being in the same namespace or adding a ReferenceGrant that will grant it access.
Standard gateway for HTTP/HTTPS:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: http-gateway
namespace: default
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod ##Point this to staging for test
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
allowedRoutes:
namespaces:
from: All
tls:
mode: Terminate
certificateRefs:
- name: gateway-tls-cert
kind: Secret
That's it for Infrastructure. Any other app that you add will usually just get a new DNS name added and we'll attach a route to the gateway.
Backups
I'll add this as a footnote. I use velero which backs up all my volumes to S3/GCP. So my database, volume, DB are all available for restore. A DB backup is recommended but this gives me a baseline. I'll let you explore velero or longhorn on your own.
Application
Okay, so now we get to the app itself. We need a database to run with this. You COULD just run a mariadb but since I have a cluster might as well do it right.
MariaDB
You need to install the operator and the CRDs:
helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator
helm repo update mariadb-operator
## CRDs
helm install mariadb-operator-crds mariadb-operator/mariadb-operator-crds
## Operator
helm install mariadb-operator mariadb-operator/mariadb-operator
Setting up the database
Feel free to tweak these settings but this does expect a secret to exist named ghost-db with the credentials. I use External Secret Operator and left my pattern there in the manifest as an example:
## Define your secrets
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ghost-db
namespace: mariadb
spec:
refreshInterval: 1h
secretStoreRef:
name: onepassword-cluster-store
kind: ClusterSecretStore
target:
name: ghost-db
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: seafile-db
property: ghost_user_password
---
## Define Database
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
name: ghost
namespace: mariadb
spec:
mariaDbRef:
name: mariadb
characterSet: utf8mb4
collate: utf8mb4_general_ci
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s
---
## Create the DB user
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: ghost
namespace: mariadb
spec:
mariaDbRef:
name: mariadb
passwordSecretKeyRef:
name: ghost-db
key: password
host: "%"
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s
---
## Define your Grant giving the user access to the DB
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: ghost
namespace: mariadb
spec:
mariaDbRef:
name: mariadb
privileges:
- "ALL PRIVILEGES"
database: "ghost"
table: "*"
username: ghost
host: "%"
cleanupPolicy: Delete
requeueInterval: 10h
retryInterval: 30s
That's it. Now I should be able to connect to my database via: mariadb.mariadb.svc.cluster.local in my case the mariadb operator and app are in the mariadb ns with a service named mariadb as well.
Now, this is still technically a single pod, no replica. If you really wanted to you could scale mariadb but since we're only running one ghost instance it seems silly.
For science though setting up a HA cluster docs can be found here.
Ghost
Now that we have our database up and running we really just need to bring the pods up and add a route.
First PVC, where the data is stored
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ghost-content
namespace: ghost
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi ## make this bigger
I'm VERY conservative with my allocation, I'm running far too many thing on a tiny VPS, but adjust it to your resources.
Deployment, this assumes we have a ghost-db secret created. If you mostly followed what I did all of this should be copy/paste with the exception of the domain name.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghost
namespace: ghost
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app: ghost
template:
metadata:
labels:
app: ghost
spec:
containers:
- name: ghost
image: ghost:6.39.0
ports:
- containerPort: 2368
env:
- name: NODE_ENV
value: production
- name: url
value: https://blog.domain.com
- name: database__client
value: mysql
- name: database__connection__host
value: mariadb.mariadb.svc.cluster.local
- name: database__connection__port
value: "3306"
- name: database__connection__database
value: ghost
- name: database__connection__user
value: ghost
- name: database__connection__password
valueFrom:
secretKeyRef:
name: ghost-db
key: password
- name: mail__transport
value: SMTP
- name: mail__options__host
value: smtp.mailgun.org
- name: mail__options__port
value: "587"
- name: mail__options__secure
value: "false"
- name: mail__from
value: donotreply@mailings.somedomain.com
- name: mail__options__auth__user
valueFrom:
secretKeyRef:
name: ghost-db
key: smtp-username
- name: mail__options__auth__pass
valueFrom:
secretKeyRef:
name: ghost-db
key: smtp-password
volumeMounts:
- name: content
mountPath: /var/lib/ghost/content
volumes:
- name: content
persistentVolumeClaim:
claimName: ghost-content
Service:
apiVersion: v1
kind: Service
metadata:
name: ghost
namespace: ghost
spec:
selector:
app: ghost
ports:
- port: 80
targetPort: 2368Route, how we connect to our app:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: ghost-https
namespace: ghost
spec:
parentRefs:
- name: http-gateway
namespace: default
sectionName: https
hostnames:
- blog.domain.com
rules:
- backendRefs:
- name: ghost
port: 80
OR if you'd rather serve traffic off a URI path:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: ghost-https
namespace: ghost
spec:
parentRefs:
- name: http-gateway
namespace: default
sectionName: https
hostnames:
- domain.com
rules:
- matches:
- path:
type: PathPrefix
value: /blog
backendRefs:
- name: ghost
port: 80And update your deployment accordingly:
env:
- name: url
value: https://blog.domain.com/blogThat's it. log in to your blog: https://blog.domain.com/ghost/ or https://blog.domain.com/blog/ghost/.
Finally, if you want people who for some reason are not using https by default to find you, may want to add a redirect. Adjust this accordingly based on the setup you have.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: ghost-http-redirect
namespace: ghost
spec:
parentRefs:
- name: http-gateway
namespace: default
sectionName: http
hostnames:
- blog.domain.com
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301References:
- K8s:
- Ghost:
- Ghost CMS: https://ghost.org/
- Ghost GSS support https://github.com/danmasta/ghost-gcs-adapter
- GS3 support – https://github.com/colinmeinke/ghost-storage-adapter-s3
- Backup Solutions: