Kirby CMS on FrankenPHP, built daily for Kirby 4 and 5
10K+
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.
The Panel at http://localhost:8080/panel needs a first account. Create it with -e KIRBY_ADMIN_EMAIL=... -e KIRBY_ADMIN_PASSWORD=..., or add -e KIRBY_PANEL_INSTALL=true to a trial run to use Kirby's installer; see First panel account.
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 — opens the Panel installer to every visitor until the first account exists, see First panel account |
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.
PHP sees the server's environment in $_SERVER and $_ENV, and with KIRBY_DEBUG=true Kirby's error page lists both to whoever triggers an error. The front controller removes the variables above that carry secrets (KIRBY_ADMIN_PASSWORD, KIRBY_EMAIL_PASSWORD, KIRBY_CONTENT_SALT, KIRBY_COOKIE_KEY, KIRBY_LICENSE, KIRBY_OPTIONS_JSON) from $_SERVER and $_ENV before Kirby starts, whatever your config sets. They are also on the page's whoops.blocklist, and the entrypoint drops KIRBY_ADMIN_PASSWORD and KIRBY_LICENSE from the server's environment once it has used them.
Anything else you pass in the environment, a plugin's API key for instance, is listed unmasked. Add it to whoops.blocklist keyed by its name, so that it is merged with the image's entries instead of replacing them:
<?php
return array_replace_recursive(
require '/usr/local/share/kirby/env-options.php',
[
'whoops' => ['blocklist' => [
'_SERVER' => ['MY_API_KEY' => 'MY_API_KEY'],
'_ENV' => ['MY_API_KEY' => 'MY_API_KEY'],
]],
]
);
Through KIRBY_OPTIONS_JSON the same works as {"whoops":{"blocklist":{"_SERVER":{"MY_API_KEY":"MY_API_KEY"},"_ENV":{"MY_API_KEY":"MY_API_KEY"}}}}; a plain list there is accepted too. Setting whoops to true replaces the blocklist, so your own entries are lost again; false switches the page off. Wherever a variable has a _FILE variant, a mounted secret keeps the value out of the environment and out of docker inspect altogether. Never switch on KIRBY_DEBUG on a site the public can reach.
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's Panel installer creates an admin account for whoever asks while none exists, if Kirby considers the installation local. It decides that by the host a request names — localhost, or a name ending in .local, .test or .ddev.site — before it looks at any address, and any client can name such a host. The image therefore only lets the installer answer requests from the container's own loopback interface, unless KIRBY_PANEL_INSTALL=true opens it to everybody until the first account exists. Through a published port, even from the same machine, a request does not come from loopback.
To get an admin account, 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 entrypoint drops the password from the web server's environment once it has used it, but it stays visible to docker inspect and docker exec for as long as the variable is set. 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 assets/ /app/public/assets/
The document root belongs to root, apart from the media root below it, so that the web server process cannot put a script there; keep what you add to /app/public owned by root too. The media root is writable, but nothing in it is ever executed.
Kirby pins some of its dependencies, such as symfony/yaml, to one exact release. When a security fix for one of them is out before Kirby pins it, the image ships the fixed patch release under an inline alias, and the require section of /app/composer.json then contains an entry such as "symfony/yaml": "7.4.23 as 7.4.21". Images built while Kirby already pins the newest patch release have no such entry. The alias stays in the file your image builds on:
Adding plugins with composer require, as above, works as usual.
To move getkirby/cms to another release yourself, drop the alias first and update Kirby together with its dependencies. Otherwise Composer keeps the exact release the alias requires and refuses a Kirby release that pins a newer one:
RUN composer remove --no-update symfony/yaml \
&& composer require --no-interaction --with-all-dependencies getkirby/cms:5.6.4
If an advisory is later published for the aliased release itself, Composer refuses to resolve it, and composer require in your image fails. Build on a freshly pulled base image (docker build --pull): the images are rebuilt daily and move to the newest patch release on their own. Until one is out, require the fixed release under the same alias yourself, for example composer require --no-update "symfony/yaml:7.4.24 as 7.4.21" followed by composer update symfony/yaml.
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. The Panel installer only answers requests from inside the container. 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.
Setting KIRBY_URL. Set it to the public URL on every deployment. Without it, Kirby takes the host from the request, which any client controls, and uses it for every absolute URL it generates, for the sender of login codes, for the namespace of the page cache and to pick a site/config/config.<host>.php. With KIRBY_URL set, Kirby ignores the Host header.
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
Content type
Image
Digest
sha256:b5557f288…
Size
232.2 MB
Last updated
about 7 hours ago
docker pull mittwald/kirby