Ghost blog on Kubernetes with GitOps
This repository contains a Ghost (the open source publishing platform) deployment using modern GitOps practices. I found this approach interesting and use it myself for the TTC blog.
This repo is an opinionated deployment using:
- Kustomize (base + overlays for dev/prod)
- ArgoCD (GitOps-first, no manual kubectl applys)
- MySQL (in-cluster by default, with hooks for external DB if you want later)
- cert-manager + nginx-ingress (TLS with Let's Encrypt baked in)
- Backup jobs (PVC snapshots + MySQL dumps, with restore jobs included)
The goal is simple: π Spin up Ghost in a cluster β clean, reproducible, Git-driven. π A solid base, which can be extended and tweaked as required.
apps/ghost/
base/ # shared manifests (Ghost, MySQL, PVCs, backups, monitoring, PDBs)
overlays/
dev/ # dev overlay (local hostnames, no TLS hardening)
staging/ # staging overlay (TLS, real domain, SMTP)
prod/ # prod overlay (TLS, real domain, SMTP, hardened ingress)
e2e/ # e2e testing overlay
argo/ # ArgoCD Application definitions
docs/ # setup notes, SMTP, runbooks
cert-manager/ # TLS certificate management
scripts/ # automation scripts
.github/workflows # CI validation
- Kubernetes cluster (tested with k3s + k8s 1.30+)
- Ingress controller (nginx-ingress recommended, Traefik works with tweaks)
- cert-manager installed + DNS access (any DNS provider with API support)
- ArgoCD running (this repo expects GitOps, not manual applies)
- SMTP account (any provider: Gmail, Mailgun, SES, Postmark, SendGrid, etc.)
Clone + apply the Argo app of choice:
Dev (local hosts file, no TLS):
git clone https://github.com/yourusername/ghost-k8s-production.git
cd ghost-k8s-production
kubectl apply -f argo/ghost-dev-app.yamlProd (with TLS + real domain):
# Update your domain in apps/ghost/overlays/prod/ingress.yaml first
kubectl apply -f argo/ghost-prod-app.yamlDev (no TLS):
kustomize build apps/ghost/overlays/dev | kubectl apply -f -
kubectl rollout status -n default statefulset/ghost-mysql --timeout=5m
kubectl rollout status -n default deploy/ghost --timeout=5mStaging/Prod (TLS via DNS-01):
kustomize build apps/ghost/overlays/staging | kubectl apply -f -
# or
kustomize build apps/ghost/overlays/prod | kubectl apply -f -Secrets are managed with Kubernetes Secret objects. You'll need:
- MySQL:
ghost-mysql-secretwithmysql-root-password+mysql-user-password - SMTP:
ghost-smtp-secretwithSMTP_USER+SMTP_PASS
MySQL Secret:
kubectl create secret generic ghost-mysql-secret \
--from-literal=mysql-root-password="your-root-password" \
--from-literal=mysql-user-password="your-user-password"SMTP Secret:
kubectl create secret generic ghost-smtp-secret \
--from-literal=SMTP_USER="no-reply@yourdomain.com" \
--from-literal=SMTP_PASS="your-smtp-password"Use the provided script for development:
./scripts/generate-secrets.shFor production, see Secrets Setup Guide.
Dev:
kubectl -n default port-forward svc/ghost 8080:2368
# browser β http://localhost:8080Staging/Prod: Browse to your domain (e.g., https://staging.example.com/blog)
Install cert-manager (CRDs + controller).
Create DNS token secret:
kubectl -n cert-manager create secret generic dns-api-token-secret \
--from-literal=api-token='YOUR_DNS_TOKEN_VALUE'Apply issuer:
kubectl apply -f cert-manager/clusterissuer-dns-01.yamlIngresses reference:
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-dns01- DB backup CronJob:
ghost-db-backup(02:00 daily) - Content backup CronJob:
ghost-content-backup(02:30 daily) - Verify CronJob:
ghost-backup-verify(03:25 daily) - Prune CronJob:
ghost-backup-prune(03:55 daily; 14-day retention)
Backups saved to ghost-backups-pvc (flat files). Each backup does a quick integrity test; nightly verify does deeper checks.
# namespace may be default or ghost-staging depending on env
NS=default
kubectl -n $NS create job --from=cronjob/ghost-db-backup ghost-db-backup-now-$(date +%s)
kubectl -n $NS create job --from=cronjob/ghost-content-backup ghost-content-backup-now-$(date +%s)
kubectl -n $NS create job --from=cronjob/ghost-backup-verify ghost-backup-verify-now-$(date +%s)
kubectl -n $NS logs -l job-name=ghost-backup-verify-now- --tail=200 --prefixRestore DB from latest dump:
NS=default
DB_SECRET=$(kubectl -n $NS get secret ghost-mysql-secret -o jsonpath='{.data.mysql-root-password}' | base64 -d)
LATEST=$(kubectl -n $NS exec deploy/ghost -- sh -lc 'ls -1t /backups/db-*.sql.gz 2>/dev/null | head -n1')
kubectl -n $NS exec -it sts/ghost-mysql -- sh -lc "
set -e
echo Restoring: $LATEST
gzip -cd $LATEST | mysql -uroot -p$DB_SECRET ghostdb
"Restore content from latest archive:
NS=default
LATEST_CONTENT=$(kubectl -n $NS exec deploy/ghost -- sh -lc 'ls -1t /backups/content-*.t*z 2>/dev/null | head -n1')
# Stop Ghost briefly to avoid concurrent writes
kubectl -n $NS scale deploy/ghost --replicas=0
kubectl -n $NS exec deploy/ghost -- sh -lc "
set -e
cd /var/lib/ghost
tar -xzf $LATEST_CONTENT
"
kubectl -n $NS scale deploy/ghost --replicas=1
kubectl -n $NS rollout status deploy/ghost --timeout=5m- Ghost pinned to 6.0.5 for stability
- MySQL pinned to 8.0.37 (runs as StatefulSet with PVC)
- Config generated from env vars via initContainer (no manual config.production.json)
- cert-manager DNS-01 with Let's Encrypt (Cloudflare example provided, works with any DNS provider)
- Automated PVC + MySQL backups, restore jobs included
- PodDisruptionBudgets for Ghost + MySQL
- Health/readiness probes configured
- Network policies for security
- Optional monitoring with Prometheus/Grafana (see Monitoring Setup)
- CI workflow validates YAML + kustomize builds
- default-deny-all (Ingress/Egress)
- Allow Ghost β MySQL (3306) and backup jobs to MySQL
- Allow DNS egress (TCP/UDP 53)
- Allow SMTP egress (587)
- Optional: allow Prometheus β Ghost (if monitoring enabled)
App-of-apps under argo/apps/:
- ghost-dev (auto)
- ghost-staging (auto)
- ghost-prod (manual at first)
Sync waves: MySQL -1 β Ghost 0 β Ingress 1 ignoreDifferences covers HPA/status/clusterIP/LB.
Ghost loops or 502? Check probes (env-specific paths / vs /blog/) and Host headers in probes for staging/prod.
TLS won't issue? Verify DNS token scope; set DNS to grey cloud; check cert-manager logs.
Backups empty? Check PVCs mounted correctly; verify CronJob logs and verify job output.
Argo fights replicas? HPA enabled? Ensure ignoreDifferences includes /spec/replicas.
- validate.yaml β builds all overlays, schema-validates, guards against :latest in prod
- smoke-kind.yaml β boots Kind, applies dev overlay with dummy secrets, waits for rollout, curls endpoint
- CI smoke test (deploy to kind + curl check)
- Upgrade PR bot (auto bumps Ghost/MySQL images with review gate)
- External DB overlay (Amazon RDS/Aurora, Cloud SQL, PlanetScale)
- Object storage overlay for Ghost content (S3, GCS, MinIO)
- Optional hardening: Cloudflare Access for /ghost, OAuth2-proxy, etc.
- Monitoring dashboards (Grafana JSON included)
Feel free to fork, extend, or contribute back. Open a PR or file an issue if you have suggestions.
See CONTRIBUTING.md for detailed guidelines.
Please report security vulnerabilities responsibly. See SECURITY.md for details.
MIT. See LICENSE for details.
This is part of The Tech Capsule β pre-built, opinionated deployments for indie hackers, devs, and teams that want to self-host apps without reinventing the wheel.
Ready to deploy? Get started now or visit the website!