Kirby CMS on FrankenPHP, built daily for Kirby 4 and 5
5.5K
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.
| Tag | Kirby | PHP | Notes |
|---|---|---|---|
5, 5.x, 5.x.y, latest | Kirby 5 | 8.4 | Current release |
4, 4.x, 4.x.y | Kirby 4 | 8.4 | Previous 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.
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
/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.
Three paths hold state and are declared as volumes:
| Path | Contents | Losing it means |
|---|---|---|
/app/content | All pages, files and site content | Losing the site |
/app/storage | Panel accounts, sessions, cache, logs | Users logged out, accounts gone |
/app/public/media | Thumbnails generated from content | Nothing 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.
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.
| Variable | Default |
|---|---|
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 |
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.
| Variable | Default | Description |
|---|---|---|
SERVER_NAME | :80 | Caddy 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/public | Document root. |
TRUSTED_PROXIES | private_ranges | Which proxies may set X-Forwarded-*. Required for correct client IPs and HTTPS detection behind an ingress. |
HEALTH_PORT | 8090 | Port of the plain-HTTP health listener. |
HEALTH_PATH | /healthz | Path of the health endpoint. |
CADDY_ADMIN | off | Caddy 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. |
| Variable | Default |
|---|---|
PHP_MEMORY_LIMIT | 256M |
PHP_UPLOAD_MAX_FILESIZE | 128M |
PHP_POST_MAX_SIZE | 128M |
PHP_MAX_EXECUTION_TIME | 60 |
PHP_MAX_INPUT_VARS | 3000 |
PHP_MAX_FILE_UPLOADS | 50 |
PHP_TIMEZONE | UTC |
PHP_DISPLAY_ERRORS | Off |
PHP_ERROR_REPORTING | E_ALL & ~E_DEPRECATED |
PHP_OPCACHE_MEMORY_CONSUMPTION | 192 |
PHP_OPCACHE_VALIDATE_TIMESTAMPS | 0 |
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.
These map onto Kirby config options. Only the variables you actually set are applied; the rest keep Kirby's own defaults.
| Variable | Option |
|---|---|
KIRBY_URL | url |
KIRBY_DEBUG | debug |
KIRBY_PANEL | panel — set to false to disable the panel entirely |
KIRBY_PANEL_INSTALL | panel.install |
KIRBY_PANEL_SLUG | panel.slug |
KIRBY_LANGUAGES | languages |
KIRBY_SMARTYPANTS | smartypants |
KIRBY_DATE_HANDLER | date.handler |
KIRBY_CONTENT_LOCKING | content.locking |
KIRBY_CACHE_PAGES | cache.pages.active |
KIRBY_CACHE_PAGES_TYPE | cache.pages.type |
KIRBY_THUMBS_DRIVER | thumbs.driver |
KIRBY_THUMBS_QUALITY | thumbs.quality |
KIRBY_API_BASIC_AUTH | api.basicAuth |
KIRBY_API_ALLOW_INSECURE | api.allowInsecure |
KIRBY_AUTH_METHODS | auth.methods (comma separated) |
KIRBY_AUTH_TRIALS | auth.trials |
KIRBY_AUTH_EMAIL_FROM | auth.challenge.email.from — sender of login and password reset codes |
KIRBY_AUTH_EMAIL_FROM_NAME | auth.challenge.email.fromName |
KIRBY_EMAIL_TRANSPORT | email.transport.type — smtp to send through a mail server |
KIRBY_EMAIL_HOST | email.transport.host |
KIRBY_EMAIL_PORT | email.transport.port |
KIRBY_EMAIL_USER | email.transport.username |
KIRBY_EMAIL_PASSWORD, KIRBY_EMAIL_PASSWORD_FILE | email.transport.password |
KIRBY_EMAIL_SECURITY | email.transport.security — tls, ssl, true (derived from the port, 587 or 465 only) or false |
KIRBY_OPTIONS_JSON | Any 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.
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.
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:
| Variable | Description |
|---|---|
KIRBY_ADMIN_EMAIL | Email address of the account |
KIRBY_ADMIN_PASSWORD, KIRBY_ADMIN_PASSWORD_FILE | Its 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.
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/.
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:
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".
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
The design goal was that routine upkeep needs no commits.
5.5.4 the day it appears, with no change here.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.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.kirby-docs-audit skill, run the same way.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.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.
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.update-versions.yml can open its PR.release-health-check skill looks for exactly this.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.
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.
Content type
Image
Digest
sha256:3e15a0227…
Size
226.5 MB
Last updated
about 10 hours ago
docker pull mittwald/kirby