Ghost on Kubernetes with Kapitan
Ghost 6.75.0, the publishing platform for blogs and newsletters, with its content directory on a claim, designed in kadet-hub on Ghost’s Docker image and rendered by the kadet-hub kubernetes generator 1.14.0. The database is the included recipe mysql.
- Latest version:
1.0.0, published - Source: container image
docker.io/library/ghost:6.75.0-alpine@sha256:bea7100f19d83b23c234fd8ad94a90505552f7b0f5f307b079907c1fd03dbdf7, recipe designed in kadet-hub - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade F 111 findings, one per advisory and package, in the 1 image of version 1.0.0: CRITICAL 1, HIGH 50, MEDIUM 54, LOW 5, UNKNOWN 1. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/library/ghost.
What it deploys
recipes.ghost(init.yml)recipes.ghost.ghost(ghost.yml):ghost
Required parameters
applications.ghost.url is the URL readers and staff reach Ghost at, with
its scheme, such as https://blog.example.com. Ghost builds every link
from it. With an https URL Ghost redirects requests that a proxy does not
mark as HTTPS with X-Forwarded-Proto.
The included recipe mysql requires applications.mysql.password, the
password with which Ghost connects as the user app, and
applications.mysql.root_password. Set both as refs with the backend of
your choice. Without one of the three parameters the compile stops with
Interpolation key and its name.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.ghost
parameters:
namespace: ghost
kapitan:
vars:
target: ghost
applications:
ghost:
url: https://blog.example.com
mysql:
password: <ref>
root_password: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.0:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/ghost:1.0.0@sha256:2f098a63b47ba371e5d752c922183cde41dfced20a9a4f825a62f725d2ae1bef
output_path: inventory/classes/recipes/ghost
Version 1.0.0 includes mysql 1.0.1. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:
tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/mysql@sha256:194fd92aaab3e93f69032402c709cd1b526f6721121f6d20895ef4e0951f49cc
mkdir -p inventory/classes/recipes/mysql
tar -xf "$tmp/mysql.tar" -C inventory/classes/recipes/mysql --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/ghost, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/ghost@sha256:2f098a63b47ba371e5d752c922183cde41dfced20a9a4f825a62f725d2ae1bef \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/mysql@sha256:194fd92aaab3e93f69032402c709cd1b526f6721121f6d20895ef4e0951f49cc \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
| Setting | Inventory |
|---|---|
| Ghost version | components.ghost.image, tag and digest |
| content volume size, 8Gi | components.ghost.volume_claims.content.spec.resources.requests.storage |
| resources | components.ghost.resources |
further Ghost settings, such as admin__url or mail |
components.ghost.env.<setting>, with __ between the levels of Ghost’s configuration |
| exposure | a route or Ingress of the target to the Service ghost on port 2368 |
| database, its user and both passwords | the settings of mysql |
The Service ghost serves plain HTTP on port 2368. Expose it through a
route or Ingress that terminates TLS at the address of url and sets
X-Forwarded-Proto.
The claim content holds what Ghost keeps outside MySQL: uploaded images
and files, themes, settings files and Ghost’s log files. At each start the
image’s entrypoint copies its default themes and directories onto the
claim where they are missing. Posts, members, staff users and sessions
live in MySQL. Ghost connects to it without TLS.
The pod runs as the image’s user node, UID 1000, and passes Pod Security
restricted. The init container wait-for-database waits until the
Service mysql accepts connections, because Ghost exits at its start
while the database does not answer.
The readiness probe gets / every 10 seconds with
X-Forwarded-Proto: https. Ghost answers 503 while it boots and runs
database migrations, and 500 while MySQL is down, so the pod is not ready
then. There is no liveness probe, which would restart Ghost in the middle
of a migration.
MySQL is the included recipe mysql, MySQL 9.7 LTS. Ghost’s own compose
file runs MySQL 8.0, and Ghost’s CI tests against MySQL 8.0. MySQL 8.0
reached its end of life on 2026-04-30 (DEC-211 in the spec).
Proof
check ghost --level deploys with tests/target.yml on kind for each
Kubernetes minor CI tests. The image has no Kubernetes objects to compare
with, so the recipe is proven by deploying it (RCP-44). tests/target.yml
sets the URL http://ghost:2368, which the test pod reaches, and
low-entropy test passwords.
tests/smoke checks that the pods of Ghost and MySQL pass Pod Security
restricted and that Ghost did not restart. From a pod of the Ghost image,
through the Service ghost on port 2368, it
- reads that Ghost is not set up yet and that the front page names the image’s Ghost version
- creates the owner through the Admin API’s setup and gets 403 for a second setup
- gets no session for a wrong password and logs in
- publishes a post through the Admin API and reads it on its URL without the session
- uploads a PNG and fetches it byte for byte
- deletes the pod
ghost-0and, once the new pod is ready, reads the post and the PNG again and reaches/ghost/api/admin/users/me/with the same session.
Versions
1.0.0, :sha256:2f098a63b47ba371e5d752c922183cde41dfced20a9a4f825a62f725d2ae1bef
The recipe's files are in kadet-hub/recipes. All recipes