English · Русский — see CHANGELOG.md for release history.
This repo builds Docker images that extend
ghcr.io/6run0/freeunit-php
(FreeUnit — a fork of NGINX Unit — with an embedded PHP module) with the
toolchain a Drupal site needs at runtime.
What this image adds on top of the base:
- Composer — downloaded as a
.pharand verified against the pinned Composer maintainer GPG key (key161DFBE342889F01DDAC4E61CBB3D576F2A0946F). - supercronic — a crontab-compatible foreground cron runner, pinned by version and verified by SHA256, wired in as a second launch mode via the base entrypoint hook system.
- APT packages for a Drupal runtime:
git+openssh-client(pull and deploy config and code over SSH-authenticated git),mariadb-client+less(inspect the database, e.g.SELECT … \Gthrough a pager),msmtp(an SMTPsendmaildrop-in, symlinked as/usr/sbin/sendmailso PHP'smail()works out of the box),patch(socweagans/composer-patchescan apply drupal.org patches duringcomposer install), andunzip(Composer archive extraction).
The Drupal application code is not baked into the image. It is mounted or installed by Composer at runtime. This image is the runtime environment, not the site.
- Base image:
ghcr.io/6run0/freeunit-php(trixie, amd64) - PHP versions: 8.3, 8.4, 8.5 (one image per PHP line)
- Distribution: Debian trixie, amd64 only
A single parameterized Dockerfile covers the whole matrix via build args;
the Makefile builds every variant.
Pre-built images are published to the GitHub Container Registry at
ghcr.io/6run0/freeunit-drupal. Each release pushes one image per PHP line
(8.3 / 8.4 / 8.5) under several tags:
| Tag pattern | Example | Resolves to |
|---|---|---|
latest |
ghcr.io/6run0/freeunit-drupal |
newest release, default PHP (8.4) |
<version> |
:0.3.0 |
that repo release, default PHP |
<base-tag>-php<X.Y> |
:trixie-1.36.0-build1-php8.4 |
that release's substrate on a PHP line |
<version>-php<X.Y> |
:0.3.1-php8.4 |
a specific release on a PHP line |
docker pull ghcr.io/6run0/freeunit-drupal:latestThe latest tag floats to the newest release (default PHP). The <version>…
tags are pinned to a single release. The <base-tag>-php<X.Y> tag mirrors the
pinned freeunit-php substrate (BASE_TAG), so it changes whenever the
substrate is bumped — see the Build section.
# Default image (trixie, php8.4)
docker build -t freeunit-drupal .
# A specific PHP version
docker build --build-arg PHP_VER=8.3 -t freeunit-drupal:8.3 .
# Track the floating freeunit-php suite tag instead of the pinned default
docker build --build-arg BASE_TAG=trixie -t freeunit-drupal .Or use the Makefile:
make # build all PHP versions (8.3, 8.4, 8.5)
make php8.3 # build one variant
make latest # build the default PHP (8.4) and tag :latest
make test # build the default PHP and run the smoke test
make lint # run all installed linters
make scan # CVE-scan the default image (trivy/grype if installed)
make BASE_TAG=trixie php8.4 # track the floating substrateThe per-release defaults (BASE_IMAGE, BASE_TAG, PHP_VER) live in the
Dockerfile ARGs. The Makefile reads them from there, so a substrate bump
is a single edit in the Dockerfile. Two assets are pinned in this repo, both
in the Dockerfile ARGs: supercronic (version + SHA256) and Composer
(COMPOSER_VERSION; the phar is GPG-verified on every build — pass an empty
value to track the latest release instead).
The primary use: run a Drupal site under the FreeUnit web server.
docker run -d --name drupal \
-p 8080:8080 \
-v "$PWD/web:/www:ro" \
-v "$PWD/config.json:/docker-entrypoint.d/config.json:ro" \
ghcr.io/6run0/freeunit-drupal:latestOn first start the base entrypoint applies everything in
/docker-entrypoint.d/ (scripts, certificates, FreeUnit config) and then starts
the FreeUnit daemon in the foreground. See the base image documentation for the
full first-run behaviour and the /docker-entrypoint.d/ conventions.
A minimal FreeUnit config.json for Drupal:
{
"listeners": { "*:8080": { "pass": "applications/drupal" } },
"applications": {
"drupal": {
"type": "php",
"root": "/www",
"script": "index.php",
"user": "freeunit",
"group": "freeunit"
}
}
}Run the same image as a supercronic cron runner alongside the web container, without a separate image and without overriding the entrypoint.
docker run -d --name drupal-cron \
-v "$PWD/crontab:/etc/supercronic/crontab:ro" \
-e DRUPAL_CRON_URL=http://localhost:8080/cron/YOUR_CRON_KEY \
ghcr.io/6run0/freeunit-drupal:latest supercronicThe shipped crontab is a commented template with no active jobs, so mount your own (as above) or enable a job in a derived image — see Default crontab.
Passing supercronic as the container command activates the cron role. The
base entrypoint's dispatch_handler recognises it and calls
handle_supercronic from the hook at
/docker-entrypoint-hook.d/supercronic.sh, which:
- Runs any operator
*.shdrop-ins from/docker-entrypoint.d/(the same convenience the web role provides). - Drops from root to the app user (
APPLICATION_USER/APPLICATION_GROUP, defaultfreeunit:freeunit) viasetpriv. execssupercronic, forwarding any arguments you appended after thesupercroniccommand and adding the default crontab path when you didn't pass one (see Tuning supercronic).
The image ships /etc/supercronic/crontab as a commented template with no
active jobs — it documents the schedule syntax and carries two commented
example jobs: project-local Drush (preferred, see the tip below) and the
Drupal HTTP trigger:
# */5 * * * * curl -fsS -o /dev/null "${DRUPAL_CRON_URL:-http://localhost:8080/cron.php}"So the cron role idles until you enable a job: uncomment one in a downstream image, or mount your own crontab at the same path:
-v "$PWD/crontab:/etc/supercronic/crontab:ro"DRUPAL_CRON_URL should be the full URL of the Drupal cron endpoint,
including the cron key (e.g.
http://localhost:8080/cron/YOUR_CRON_KEY).
Tip
When your project ships Drush, prefer running cron through it instead of
the HTTP trigger — mount the code volume into the cron container and use a
crontab line like */15 * * * * /www/vendor/bin/drush --root=/www/web cron.
Drush runs cron in its own PHP CLI process: no cron key to expose, no
web-server request timeout on long runs, and it keeps working while the
site is in maintenance mode. The HTTP trigger remains the zero-dependency
option when the cron container has no code or database access.
supercronic also accepts schedules POSIX cron can't: an optional leading
seconds field for sub-minute jobs (*/30 * * * * * = every 30 s), an
optional trailing year field, the @yearly/@monthly/@weekly/@daily/@hourly
macros, and the L/W/# day specifiers. The shipped crontab carries the
full reference in its header comments.
Anything you append after the supercronic command is forwarded straight to
the runner, so its flags need no image rebuild:
# Reload the crontab on change (no container restart) and log verbosely
docker run -d --name drupal-cron \
-v "$PWD/crontab:/etc/supercronic/crontab" \
ghcr.io/6run0/freeunit-drupal:latest supercronic -inotify -debugWhen your trailing argument names a readable file it is used as the crontab;
otherwise the image default (/etc/supercronic/crontab) is appended, so adding
a flag still runs the baked-in crontab. Run supercronic -help for the full
list (-inotify, -overlapping, -split-logs, -prometheus-listen-address, …).
The cron role is implemented as an entrypoint hook — a *.sh file in
/docker-entrypoint-hook.d/ that defines a handle_supercronic function.
The base entrypoint sources every hook there before dispatch, and when the
container command matches, the handler owns the launch (it must exec the
final process).
Child images do not override /docker-entrypoint.sh, so every robustness
and security fix to the base entrypoint is inherited automatically.
Hook authoring rules (enforced by the base dispatcher):
- A hook file must only define
handle_*functions — no top-level side effects. - A
handle_<cmd>mustexecthe final process. Returning or exiting non-zero is a fatal contract violation. - Do not shadow base library function names or
APPLICATION_*/UNIT_ENTRYPOINT_*variables.
This image inherits the base entrypoint's /docker-entrypoint.d/ processing
unchanged. Files are applied in lexical order, by extension:
| Extension | Action |
|---|---|
*.sh |
Executed as root. |
*.pem |
Uploaded as a certificate bundle named after the file (minus .pem). |
*.json |
PUT to the FreeUnit config via the control socket. |
Other file types are logged and ignored.
Multiple
*.jsonare not merged:PUT /configreplaces the whole configuration, so only the lexically-last file takes effect (the entrypoint warns when more than one is present). Ship a single combined config file.
In the cron role the *.sh drop-ins run, but *.pem and *.json are not
processed (FreeUnit is not started in that role).
PHP's mail() invokes /usr/sbin/sendmail, which this image symlinks to
msmtp. The shipped /etc/msmtprc is a commented template with no active
settings — mail stays unconfigured until you point it at your SMTP relay:
uncomment and edit the template in a derived image, or mount your own config
at the same path:
-v "$PWD/msmtprc:/etc/msmtprc:ro"Keep credentials out of the world-readable config file: the template uses
passwordeval to read the password from a mounted secret (e.g.
/run/secrets/smtp_password) instead of a plaintext password line.
Variables inherited from the base image (APPLICATION_* and
UNIT_ENTRYPOINT_QUIET_LOGS) are documented in the
freeunit-php README.
This image adds one variable of its own:
| Variable | Default | Purpose |
|---|---|---|
DRUPAL_CRON_URL |
unset | URL of the Drupal cron endpoint (including the cron key) used by the default crontab. Falls back to http://localhost:8080/cron.php when unset. |
The FreeUnit master process and the cron role both require CAP_SETUID and
CAP_SETGID — the FreeUnit master to spawn per-app workers, and the cron hook to
drop root via setpriv. Run with:
docker run --cap-drop=ALL \
--cap-add=SETUID \
--cap-add=SETGID \
--security-opt=no-new-privileges \
...Drop privileges for the Drupal application itself with the user/group
keys in the FreeUnit config (web role) or via APPLICATION_USER / APPLICATION_GROUP
(cron role — handle_supercronic passes these to exec_as_user).
Treat /docker-entrypoint.d/*.sh as trusted input only — those scripts run
as root inside the container.