Sign inSign up

mittwald/kirby

By mittwald

•Updated about 10 hours ago

Kirby CMS on FrankenPHP, built daily for Kirby 4 and 5

Image
Content management system
0

5.5K

mittwald/kirby repository overview

⁠mittwald/kirby

Container images for Kirby CMS⁠, served by FrankenPHP⁠. One process, no PHP-FPM, no separate web server, and no configuration needed to get a running site.

The images are rebuilt every day, so a docker pull picks up Kirby patch releases and distribution security updates without anything in this repository changing.

Warning

This project is experimental; at the moment, we do not recommend production usage.

⁠Supported tags

TagKirbyPHPNotes
5, 5.x, 5.x.y, latestKirby 58.4Current release
4, 4.x, 4.x.yKirby 48.4Previous release

Every branch also publishes the exact patch version, so mittwald/kirby:5.5.3 pins one Kirby release while mittwald/kirby:5 follows the branch. Images are built for linux/amd64 and linux/arm64.

⁠Quick start

docker run --rm -p 8080:80 mittwald/kirby:5

That serves a minimal Kirby site on http://localhost:8080⁠. It is meant as a starting point, not as something to deploy — for a real site you either mount your own content or build an image on top.

With persistent data:

docker run -d --name kirby -p 8080:80 \
  -v kirby-content:/app/content \
  -v kirby-storage:/app/storage \
  -v kirby-media:/app/public/media \
  mittwald/kirby:5

⁠What is inside

/app
├── composer.json      from plainkit, with the CMS pinned at build time
├── kirby/             the CMS, installed by Composer
├── vendor/            Composer autoloader and plugin dependencies
├── content/           your pages                        (volume)
├── site/              templates, snippets, blueprints, plugins, config
├── storage/           accounts, cache, sessions, logs    (volume)
└── public/            the only directory the web server serves
    ├── index.php      front controller
    └── media/         generated thumbnails               (volume)

The site skeleton — templates, snippets, blueprints and the starting content — is Kirby's own plainkit⁠, installed with composer create-project during the build. Nothing about it is maintained in this repository, so it tracks whatever upstream ships. Exactly two files are this image's own: public/index.php, the front controller, and site/config/config.php, which bridges the KIRBY_* variables into Kirby options.

plainkit is not released in lockstep with the CMS — its 4.x line stopped at 4.8.0 while Kirby 4 kept going — so the kit is resolved by major version and the exact CMS release is pinned right afterwards. The build fails if the installed version does not match the pin.

The image uses Kirby's public/private folder setup⁠: only /app/public is reachable over HTTP. content, site, kirby, storage and composer.json are not below the document root and therefore cannot be served at all, which is a stronger guarantee than blocking their paths in the web server. plainkit ships the flat layout instead, so the build moves media under public/, drops plainkit's index.php and .htaccess, and collects the writable directories in storage/.

The container runs as the unprivileged user kirby (uid/gid 1000:1000). The Kirby CLI⁠ is installed as kirby.

⁠Persistent data

Three paths hold state and are declared as volumes:

PathContentsLosing it means
/app/contentAll pages, files and site contentLosing the site
/app/storagePanel accounts, sessions, cache, logsUsers logged out, accounts gone
/app/public/mediaThumbnails generated from contentNothing permanent; regenerated on demand, at a cost

site/plugins is deliberately not a volume. Plugins are code: they belong in your image, either committed under site/plugins or installed with composer require. A plugin directory that lives in a volume never gets updated when the image is rebuilt, which is the kind of drift that only shows up during an incident.

Because the paths are declared with VOLUME, a plain docker run creates anonymous volumes for them. Name them, as in the quick start above, or docker run --rm and let them be discarded.

⁠Relocating the roots

Every root can be moved, which is what you want when a platform gives you exactly one mount. Point content and media inside the storage volume:

docker run -d -p 8080:80 \
  -v kirby-data:/app/storage \
  -e KIRBY_ROOT_CONTENT=/app/storage/content \
  -e KIRBY_ROOT_MEDIA=/app/storage/media \
  mittwald/kirby:5

Two things to know before you do this. The relocated content root starts out empty — the seeded content stays behind at /app/content and is no longer read, so the site is blank until you add pages. And the mount point has to be writable by uid 1000: /app/storage already is, which is why the example nests below it. Mounting a volume somewhere that does not exist in the image, /data for instance, creates it owned by root and the container will refuse to start with an explicit error. Either nest below /app, or start the container as root once and let the entrypoint fix the ownership.

VariableDefault
KIRBY_ROOT_BASE/app
KIRBY_ROOT_CONTENT/app/content
KIRBY_ROOT_SITE/app/site
KIRBY_ROOT_MEDIA/app/public/media
KIRBY_ROOT_STORAGE/app/storage
KIRBY_ROOT_ACCOUNTS<storage>/accounts
KIRBY_ROOT_CACHE<storage>/cache
KIRBY_ROOT_SESSIONS<storage>/sessions
KIRBY_ROOT_LOGS<storage>/logs

⁠Configuration

Everything below has a working default. Setting nothing at all gives you a production-shaped configuration: OPcache on with timestamp validation off, errors logged but not displayed, and no automatic HTTPS.

⁠Server
VariableDefaultDescription
SERVER_NAME:80Caddy site address. Set it to a hostname (example.com) and Caddy obtains a Let's Encrypt certificate automatically. Use :8080 for an unprivileged port.
SERVER_ROOT/app/publicDocument root.
TRUSTED_PROXIESprivate_rangesWhich proxies may set X-Forwarded-*. Required for correct client IPs and HTTPS detection behind an ingress.
HEALTH_PORT8090Port of the plain-HTTP health listener.
HEALTH_PATH/healthzPath of the health endpoint.
CADDY_ADMINoffCaddy admin API address.
CADDY_GLOBAL_OPTIONS—Extra directives in Caddy's global block, e.g. auto_https off.
CADDY_SERVER_EXTRA_DIRECTIVES—Extra directives inside the site block.
CADDY_EXTRA_CONFIG—Extra top-level Caddyfile content.
FRANKENPHP_CONFIG—Extra directives in the frankenphp block.
⁠PHP
VariableDefault
PHP_MEMORY_LIMIT256M
PHP_UPLOAD_MAX_FILESIZE128M
PHP_POST_MAX_SIZE128M
PHP_MAX_EXECUTION_TIME60
PHP_MAX_INPUT_VARS3000
PHP_MAX_FILE_UPLOADS50
PHP_TIMEZONEUTC
PHP_DISPLAY_ERRORSOff
PHP_ERROR_REPORTINGE_ALL & ~E_DEPRECATED
PHP_OPCACHE_MEMORY_CONSUMPTION192
PHP_OPCACHE_VALIDATE_TIMESTAMPS0

Set PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 when you bind-mount source code during development, otherwise your edits are invisible until the container restarts.

Installed extensions beyond the PHP defaults: gd, intl, exif, zip, apcu, opcache. That covers everything Kirby requires and recommends.

⁠Kirby options

These map onto Kirby config options⁠. Only the variables you actually set are applied; the rest keep Kirby's own defaults.

VariableOption
KIRBY_URLurl
KIRBY_DEBUGdebug
KIRBY_PANELpanel — set to false to disable the panel entirely
KIRBY_PANEL_INSTALLpanel.install
KIRBY_PANEL_SLUGpanel.slug
KIRBY_LANGUAGESlanguages
KIRBY_SMARTYPANTSsmartypants
KIRBY_DATE_HANDLERdate.handler
KIRBY_CONTENT_LOCKINGcontent.locking
KIRBY_CACHE_PAGEScache.pages.active
KIRBY_CACHE_PAGES_TYPEcache.pages.type
KIRBY_THUMBS_DRIVERthumbs.driver
KIRBY_THUMBS_QUALITYthumbs.quality
KIRBY_API_BASIC_AUTHapi.basicAuth
KIRBY_API_ALLOW_INSECUREapi.allowInsecure
KIRBY_AUTH_METHODSauth.methods (comma separated)
KIRBY_AUTH_TRIALSauth.trials
KIRBY_AUTH_EMAIL_FROMauth.challenge.email.from — sender of login and password reset codes
KIRBY_AUTH_EMAIL_FROM_NAMEauth.challenge.email.fromName
KIRBY_EMAIL_TRANSPORTemail.transport.type — smtp to send through a mail server
KIRBY_EMAIL_HOSTemail.transport.host
KIRBY_EMAIL_PORTemail.transport.port
KIRBY_EMAIL_USERemail.transport.username
KIRBY_EMAIL_PASSWORD, KIRBY_EMAIL_PASSWORD_FILEemail.transport.password
KIRBY_EMAIL_SECURITYemail.transport.security — tls, ssl, true (derived from the port, 587 or 465 only) or false
KIRBY_OPTIONS_JSONAny option, as a JSON object. Merged last, so it wins.

Setting KIRBY_EMAIL_USER or a password also sets email.transport.auth, without which Kirby would not log in to the SMTP server. If KIRBY_EMAIL_SECURITY is left unset, Kirby uses ssl, which is wrong for a server on port 587: set it to tls there.

Login and password reset codes are sent from noreply@ followed by the host of KIRBY_URL, under the site title. Most SMTP servers reject a sender their account does not own, so when the panel sends codes by email, set KIRBY_AUTH_EMAIL_FROM to an address the mail account is allowed to use. Kirby has no global default sender: mail sent from your own templates and plugins still names its sender itself, or takes it from an email preset.

KIRBY_OPTIONS_JSON is the escape hatch for anything without its own variable:

-e KIRBY_OPTIONS_JSON='{"thumbs.presets.default":{"width":1200},"routes":[]}'

It is merged into the options built from the variables above: an object such as {"email":{"presets":{...}}} adds to the email settings instead of replacing them, while a list such as routes or auth.methods replaces the existing one as a whole.

⁠License

Paste the contents of the .license file Kirby sent you:

-e KIRBY_LICENSE='{"license":"K5-...","order":"...","email":"...","date":"...","domain":"...","signature":"..."}'

Or mount it as a secret and point at the file, which keeps it out of docker inspect:

-v /run/secrets/kirby-license:/run/secrets/kirby-license:ro \
-e KIRBY_LICENSE_FILE=/run/secrets/kirby-license

The entrypoint writes it to site/config/.license with mode 600 on every start, so the license does not have to be persisted in a volume.

⁠First panel account

Kirby offers its panel installer only on localhost. To get an admin account on a deployment that is only reachable under its public hostname, set:

VariableDescription
KIRBY_ADMIN_EMAILEmail address of the account
KIRBY_ADMIN_PASSWORD, KIRBY_ADMIN_PASSWORD_FILEIts password, at least 8 characters. From a file, a trailing line break is dropped.

The entrypoint creates the account with the admin role only while no account exists at all, before the server starts. After that the variables do nothing: changing KIRBY_ADMIN_PASSWORD does not change the password, and an account renamed or deleted in the panel does not come back. The log says which of the two happened on every start.

The password stays in the container's environment for as long as the variable is set, so prefer KIRBY_ADMIN_PASSWORD_FILE with a mounted secret, or remove the variables once the account exists and change the password in the panel.

If the account cannot be created — a missing password, one Kirby rejects, a plugin that fails to load — the container exits with an error instead of starting without an admin. Start a single replica for the first run: two containers starting at the same moment on an empty accounts volume could both try to create the account.

⁠Kirby CLI

The Kirby CLI⁠ is preinstalled as kirby:

docker exec my-kirby kirby clear:cache
docker exec my-kirby kirby uuid:populate
docker run --rm mittwald/kirby:5 kirby version

It finds the site through ./public/index.php relative to its working directory, so run it from /app, which is the image's working directory and therefore the default for docker exec and docker run. From anywhere else it prints The Kirby installation could not be found. Because it boots the same front controller as the web server, KIRBY_ROOT_* and the KIRBY_* options apply to it too.

The CLI lives in /opt/kirby-cli, apart from the site's own composer.json, so its dependencies do not affect the plugins you install. It is updated to the latest release whenever the image is rebuilt.

Commands that change the installation itself — upgrade, install, plugin:install and the migrate:* commands — do not belong in a running container: the Kirby version is pinned by the tag, and anything written outside the volumes is lost when the container is replaced. Make those changes in your own image instead, as below. The same goes for make:*, which writes into site/.

⁠Building your own site on top

This is the intended way to use the image. Your content and code live in your repository and become an immutable image; only runtime state lives in volumes.

FROM mittwald/kirby:5

# Plugins are code, so they belong in the image.
RUN composer require --no-interaction --no-progress \
      getkirby/staticache

COPY --chown=kirby:kirby site/ /app/site/
COPY --chown=kirby:kirby content/ /app/content/
COPY --chown=kirby:kirby assets/ /app/public/assets/

Replacing site/config/config.php is fine — keep the require so the KIRBY_* variables above keep working:

<?php

return array_replace_recursive(
    require '/usr/local/share/kirby/env-options.php',
    [
        'smartypants' => true,
        'thumbs' => ['quality' => 85],
    ]
);

A compose file for local development, with sources bind-mounted and OPcache revalidating:

services:
  kirby:
    image: mittwald/kirby:5
    ports:
      - "8080:80"
    environment:
      KIRBY_DEBUG: "true"
      KIRBY_PANEL_INSTALL: "true"
      PHP_OPCACHE_VALIDATE_TIMESTAMPS: "1"
    volumes:
      - ./site:/app/site
      - ./content:/app/content
      - kirby-storage:/app/storage
      - kirby-media:/app/public/media

volumes:
  kirby-storage:
  kirby-media:

⁠Notes for production

Creating the first panel user. Kirby refuses to run its installer on a non-local host unless you allow it. Set KIRBY_ADMIN_EMAIL and KIRBY_ADMIN_PASSWORD_FILE instead, as described under First panel account⁠. KIRBY_PANEL_INSTALL=true also works, but until the account exists, anyone reaching /panel can create an admin user.

Behind a TLS-terminating proxy. Keep SERVER_NAME on a bare port, set TRUSTED_PROXIES if your proxy is outside the private ranges, and set KIRBY_URL to the public URL so Kirby generates correct links.

Terminating TLS in the container. Set SERVER_NAME to your hostname and publish ports 80 and 443. Caddy handles certificates on its own, but needs /data to be a volume — otherwise it re-requests a certificate on every restart and will hit Let's Encrypt rate limits.

Health checks. Port 8090 serves /healthz over plain HTTP, independent of virtual hosts, TLS and PHP. Use it for liveness and readiness probes.

Running as root. Not the default, and not necessary. If you do start the container as root — typically to fix ownership of a host bind mount — the entrypoint takes ownership of the writable roots and drops back to kirby before starting the server. KIRBY_RUN_AS_ROOT=true skips that, which you should not need.

Worker mode. FrankenPHP can keep the application in memory between requests. Kirby is not written for that and will leak state across requests, so it is off. If you want to experiment: FRANKENPHP_CONFIG="worker /app/public/index.php".

⁠Repository layout

versions.json           every image that gets built, as data
image/                  build context (Dockerfile, Caddyfile, php.ini, entrypoint)
  app/                  the only two application files this repo owns
opencode.json           model config for the agent tasks
scripts/
  resolve-versions.py   versions.json + Packagist -> build matrix
  check-updates.py      finds new Kirby majors and PHP bumps
  smoke-test.sh         runs tests/smoke against a built image
  local-build.sh        builds all branches locally, host architecture only
  lint.sh               every static check, pinned; CI runs exactly this
tests/smoke/            PHPUnit suite that starts a built image and asserts
                        it serves Kirby, one class per scenario
.github/workflows/      ci, publish, lint, update-versions, docs-audit,
                        release-health; build and agent-task (both reusable)
.agents/skills/         maintenance tasks that need judgement, run by agents

⁠How this repository maintains itself

The design goal was that routine upkeep needs no commits.

  • Kirby patch and minor releases are resolved from Packagist on every build. The daily publish picks up a new 5.5.4 the day it appears, with no change here.
  • The site skeleton is installed from Kirby's plainkit during the build, so templates, blueprints and starting content are never something this repository has to keep in step with upstream.
  • Security updates in PHP, FrankenPHP and Debian arrive through the same daily rebuild, which runs with the layer cache disabled so updated packages are actually installed.
  • PHP bumps are detected weekly by scripts/check-updates.py and applied as a pull request. CI builds and smoke tests it, so a green run means the new PHP version actually serves Kirby and the PR can be merged as it stands.
  • New Kirby majors are detected by the same run, but not applied by it. Adding a major needs a plainkit release that may not exist yet, a decision about the latest tag, a decision about the branch it replaces, and a README table that nothing generates. So the run hands off to the add-kirby-branch skill under opencode⁠, which does the work, builds it, runs the smoke test and opens the pull request itself — with a briefing carrying the facts the script already checked.
  • Documentation drift — an extension moving from recommended to required, a renamed Kirby root, a plainkit release that adds a directory the build has to place — is audited quarterly by the kirby-docs-audit skill, run the same way.
  • What actually got published is checked weekly by the release-health-check skill: every promised tag exists, is recent, is multi-arch, and still runs. It is looking for the quiet failure — a publish that breaks for one branch while the others stay green, so the images look maintained while one of them has stopped receiving security updates.
  • GitHub Actions versions are updated by Dependabot.

The split is the point. A version string is applied by a script and verified by CI. Anything needing a reader is handed to a skill that has to build the image and pass the smoke test before it may open a pull request.

Each of those runs has three possible endings, and the distinction is what keeps the automation honest: it fixed something, so there is a reviewed pull request; it found something it should not decide alone — moving latest, retiring a branch, an expired credential, an upstream outage — so there is an issue saying what a person has to decide; or there was nothing to do, so there is nothing. What it may never do is guess its way past a judgement call, or leave one buried in the body of a pull request that is about to be merged and forgotten.

.github/workflows/agent-task.yml is the shared runner for the agent tasks. It owns the rules that are the same every time — never ask questions, verify with a real build, what to do when there is nothing to do — so adding another periodic skill is a caller of about fifteen lines.

The skills themselves know nothing about the workflows that call them. A skill describes its task and how to verify it, nothing more; run-specific context is the caller's business, and anything a run needs to know belongs in the runner's preamble or the caller's prompt. That is what keeps the same skill usable by hand — /add-kirby-branch in an interactive session, with no workflow anywhere in sight — and it is worth preserving when adding new ones.

Every build runs scripts/smoke-test.sh against the loaded image before anything is pushed. It starts real containers and checks that Kirby renders, that environment variables reach the CMS, that content, site and kirby are not web-reachable, that volumes survive a container replacement, and that the root-to-kirby privilege drop works.

⁠Repository setup

A few things have to be configured once, or the automation silently does nothing:

  • DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (an access token with write scope). Without them the publish workflow fails at the login step; CI builds are unaffected because they never push.
  • MITTWALD_AI_API_KEY, the key for the model opencode.json points at. Only the agent tasks need it.
  • RELEASE_USER_TOKEN, a PAT with repo scope used by the agent tasks to push and open pull requests. It has to be a PAT rather than the default GITHUB_TOKEN: pushes made with GITHUB_TOKEN do not trigger workflows, and an agent's pull request whose CI never runs is worse than none.
  • Settings → Actions → General → Allow GitHub Actions to create and approve pull requests, so update-versions.yml can open its PR.
  • Scheduled workflows are disabled automatically on repositories with no activity for 60 days. The daily rebuild is the thing that delivers security updates, so if the repository goes quiet, check that the schedule still fires — the release-health-check skill looks for exactly this.

⁠Adding a Kirby version

Add an entry to versions.json:

{
  "name": "6",
  "constraint": "^6.0",
  "php": "8.5"
}

Everything else follows: the build matrix, the tag ladder (6, 6.x, 6.x.y), the smoke test and the push. See .agents/skills/add-kirby-branch/SKILL.md for the decisions that are not automated — moving latest, and retiring an end-of-life branch.

Locally, scripts/local-build.sh builds every branch in versions.json for the host architecture, with exactly the build arguments the CI matrix would use, and smoke tests each image before it counts as built. Images are tagged with the same tag ladder as the published ones:

scripts/local-build.sh               # build and smoke test all branches
scripts/local-build.sh --skip-smoke  # just the images

For a single image pinned to one Kirby release:

python3 scripts/resolve-versions.py
docker buildx build --build-arg KIRBY_VERSION=5.5.3 --build-arg PLAINKIT_CONSTRAINT='^5.0' \
  -t kirby:dev --load ./image
scripts/smoke-test.sh kirby:dev --kirby-version 5.5.3 --php-version 8.4
scripts/smoke-test.sh kirby:dev --filter FirstAdmin   # one scenario

The smoke test is a PHPUnit suite in tests/smoke and needs php (8.4 or later) and composer on the host; the script installs the suite's dependencies itself.

scripts/lint.sh runs every static check — hadolint, shellcheck, actionlint, the Python tests, php -l, the smoke suite's composer.json, Caddyfile formatting and the versions.json checks. CI runs that same script with the same pinned, containerised tools, so a green run locally is a green run in CI. Pass a name to run one check: scripts/lint.sh hadolint.

⁠License

The contents of this repository are MIT licensed. Kirby itself is not free software: it is free to try, but a license⁠ is required to run it in public. The image ships Kirby under its own license terms.

The published images also contain plainkit⁠, which carries no license file of its own and whose README points at the same Kirby license agreement. Redistributing it in an image is covered by a separate agreement with Kirby rather than by anything in this repository.

Tag summary

Content type

Image

Digest

sha256:3e15a0227…

Size

226.5 MB

Last updated

about 10 hours ago

docker pull mittwald/kirby