Kirby CMS on FrankenPHP, built daily for Kirby 4 and 5
8.8K
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, the generated secrets | Users logged out, accounts gone, media URLs changed |
/app/public/media | Thumbnails generated from content | Nothing permanent; regenerated on demand, at a cost |
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.
Templates, snippets, blueprints, plugins and the config live in /app/site. There are two ways to change them:
/app/site and edit the site in place, without building anything. When the container starts with an empty site volume, the entrypoint copies the site the image ships into it, so it works the same on platforms that hand the container an empty volume and with an empty bind mount. Once the volume holds anything, it is left alone.FROM mittwald/kirby:5 that copies your site into /app/site, so that site and image are versioned together.A persisted site is yours from its first start: image updates bring new Kirby releases and the KIRBY_* handling in /usr/local/share/kirby/env-options.php, but never touch the templates or plugins in the volume. Keep the require of env-options.php in site/config/config.php, or the KIRBY_* variables stop working. The empty volume has to be writable by uid 1000, or the container has to start as root once so the entrypoint can take ownership.
OPcache does not check files for changes by default, so a template edited in the volume only shows after a restart. Set PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 if you edit the site while it runs.
/app/site is not declared with VOLUME. If it were, docker compose would carry an anonymous site volume over every time a container is recreated, and an image built with its own site would keep serving the site from its first deployment.
Content and storage can be moved. Usually there is no reason to, but on a platform that attaches only one volume per container, you can point content inside the storage volume:
docker run -d -p 8080:80 \
-v kirby-data:/app/storage \
-e KIRBY_ROOT_CONTENT=/app/storage/content \
mittwald/kirby:5
Media cannot be moved, because the web server only serves thumbnails and Panel assets from /app/public/media. Without a volume of its own, it is regenerated after the container is replaced. The container refuses to start when KIRBY_ROOT_MEDIA is set, which earlier versions of this README suggested; remove it, and delete the media folder it pointed to.
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_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 | Addresses allowed to name the client in X-Forwarded-For, as a space-separated list of CIDR ranges. PHP and Kirby receive the resulting client address as REMOTE_ADDR. See Behind a TLS-terminating proxy. |
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_CONTENT_SALT, KIRBY_CONTENT_SALT_FILE | content.salt — generated if unset, see Secrets |
KIRBY_COOKIE_KEY, KIRBY_COOKIE_KEY_FILE | cookie.key — generated if unset, see Secrets |
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.
Kirby signs the preview links of drafts and unsaved changes with content.salt, which also goes into every media URL, and signs cookies with cookie.key. Left unset, the salt defaults to the path of the content folder. That path is /app/content in every container of this image, so anyone could compute preview links to your drafts.
On its first start, the container therefore generates both into the storage root, as content.salt and cookie.key, and keeps using them from then on. Persist /app/storage, or they are generated anew with every container: media URLs change and everybody is logged out.
To manage them yourself, set KIRBY_CONTENT_SALT and KIRBY_COOKIE_KEY, or point KIRBY_CONTENT_SALT_FILE and KIRBY_COOKIE_KEY_FILE at mounted secrets. Nothing is generated for a secret that is set this way. Containers that serve the same site need the same salt, either from a shared storage volume or from the environment.
A deployment that started on an image without this generates its secrets on the next start. Its media URLs change once and its users have to log in again; Kirby regenerates the media files on demand.
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/, unless site/ is a volume.
When your site is in a repository, this turns it into an immutable image, and only runtime state lives in volumes. Editing the site on a running deployment instead is covered under Persisting the site.
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, and set KIRBY_URL to the public URL so Kirby generates correct links; Kirby does not read X-Forwarded-Proto. Set TRUSTED_PROXIES to the address range your proxy connects from, and nothing wider. Kirby blocks logins per client address after auth.trials failures. If the proxy is not trusted, every visitor shares its address, and one client can lock everyone out of the panel. A range that is too wide lets anything connecting from inside it choose its own address, and so get around that limit. The default, private_ranges, trusts every private network, which is convenient but rarely as narrow as it could be.
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
Content type
Image
Digest
sha256:d96b25bda…
Size
220.8 MB
Last updated
about 10 hours ago
docker pull mittwald/kirby