kadet-hub logokadet-hub

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.

What it deploys

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

Versions

The recipe's files are in kadet-hub/recipes. All recipes