A Scalingo buildpack that installs the FrankenPHP application server and the complementary Ember monitoring TUI on top of Scalingo's official PHP buildpack.
It is a thin layer: PHP, Composer and your dependencies come from the PHP buildpack; this buildpack only adds the binaries that run and monitor the app.
If you think this buildpack could be useful to others, feel free to ⭐️ the repository or contribute.
| Binary | Source | Notes |
|---|---|---|
/app/bin/frankenphp |
pinned FrankenPHP release (FRANKENPHP_VERSION, see Versions), build matching the host arch/libc, linux-x86_64-gnu on Scalingo |
Caddy + PHP in one process. Ships the Mercure hub and the hash-password command |
/app/bin/ember |
latest Ember release, SHA-256 verified | Real-time TUI / Prometheus exporter for Caddy & FrankenPHP. Optional, see Ember |
Both are added to PATH at container boot by /app/.profile.d/frankenphp.sh,
so frankenphp and ember are available in scalingo run one-off containers
too.
Create a .buildpacks file at the root of your app (see
example.buildpacks):
https://github.com/Scalingo/php-buildpack
https://github.com/lamigne/frankenphp-buildpack
Then commit and deploy:
git add .buildpacks
git commit -m "Use PHP and FrankenPHP buildpacks"
git push scalingo mainThe order matters:
- PHP buildpack runs first: installs the PHP CLI matching
composer.json, installs Composer and runscomposer install. - FrankenPHP buildpack runs second: installs the FrankenPHP and Ember
binaries, checks that
vendor/exists and registers the start commands.
Scalingo fetches the buildpack from the
mainbranch of this repository at build time. If you fork it, point.buildpacksat your fork; changes to the buildpack only take effect on the next app rebuild.
The buildpack applies when the app root contains both a Caddyfile and a
composer.json.
- Reads the PHP requirement from
composer.json(informational, the PHP buildpack is the one installing PHP). - Detects the architecture and libc and installs the pinned FrankenPHP
binary to
/app/bin/frankenphp, from the build cache when that version was already downloaded (see FrankenPHP version). - Installs Ember to
/app/bin/ember(unlessEMBER_INSTALL=false), verifying the archive against the release'schecksums.txt. - Warns if
vendor/autoload.phpis missing (the PHP buildpack did not run orcomposer installfailed). - Writes
.profile.d/frankenphp.shinto the build directory (it becomes/app/.profile.d/frankenphp.shin the image), sourced at every container boot, which extendsPATHand sizes the PHP thread pool.
Declares the default process type:
web: /app/bin/frankenphp run --config /app/CaddyfileA Procfile in your app overrides it, and is where you add other process
types, e.g. the tcp process of the
multi-container Ember setup:
web: /app/bin/frankenphp run --config /app/Caddyfile
tcp: /app/bin/ember-daemon
your-app/
├── .buildpacks # PHP buildpack + this buildpack (required)
├── Caddyfile # FrankenPHP / Caddy config (required, see example.Caddyfile)
├── composer.json # PHP dependencies (required)
├── composer.lock
├── Procfile # optional, overrides/extends the default web process
├── bin/ember-daemon # optional, tcp process of the Ember daemon setup
├── public/ # web root (index.php, assets)
└── src/ or app/ # your code
Start from example.Caddyfile and save it as Caddyfile
at the root of your app. The parts that matter on Scalingo:
- Listen on
$PORT: Scalingo assigns the public port through thePORTenvironment variable and kills the container if nothing binds to it within 60 seconds. Use:{$PORT:8080}as the site address (the8080fallback is for local runs) andbind 0.0.0.0. auto_https off: TLS is terminated by Scalingo's router; the container only speaks HTTP.admin localhost:2019: keep Caddy's admin API on loopback. Ember reads everything through this API; see Ember for how to reach it.log { output stdout }: Scalingo collects stdout, soscalingo logsshows Caddy's and FrankenPHP's logs.
Validate it locally before pushing:
frankenphp validate --config CaddyfileValidate with the same FrankenPHP version as the one pinned on Scalingo
(frankenphp version): directives deprecated in a newer release can still
validate on an older local install.
The binary version is pinned: a build never picks up a new FrankenPHP release
on its own. The default is the version tested with this buildpack (1.13.5);
set FRANKENPHP_VERSION on the app to choose another one:
scalingo env-set FRANKENPHP_VERSION=1.13.5 # or v1.13.5
scalingo env-set FRANKENPHP_VERSION=latest # follow the newest release, unpinnedThe change applies at the next deployment. A pinned version is downloaded once
and kept in the Scalingo build cache (only the current version is kept), so the
next builds skip the ~175 MB download; latest is downloaded at every build.
The release ships its own PHP (8.5 for the 1.13 series), independent of the PHP
CLI the PHP buildpack installs from composer.json: the web runs FrankenPHP's
PHP, scalingo run and the build run the buildpack's. Check both with
frankenphp version and php -v.
Place a php.ini in your app root; FrankenPHP picks it up.
FrankenPHP defaults to 2 x CPUs PHP threads. On Scalingo, containers see every
core of the host and have no CPU quota (only a CPU priority per size), so an
S container on an 8-core host would start 16 threads while having 256 MB of RAM
for all of them. Memory, not CPU, is the real limit.
At every container boot, /app/.profile.d/frankenphp.sh derives a thread pool
from CONTAINER_MEMORY (injected by Scalingo) and exports:
| Env var | Rule |
|---|---|
FRANKENPHP_NUM_THREADS |
always-on threads: RAM / 64 MB, min 2, max 2 x CPUs |
FRANKENPHP_MAX_THREADS |
burst ceiling: RAM / 32 MB, max 4 x CPUs, never below num_threads |
FRANKENPHP_WORKER_THREADS |
num_threads - 1 (FrankenPHP requires num_threads to be strictly greater than the worker count) |
Which gives, on an 8-core host:
| Container size | RAM | num_threads |
max_threads |
worker threads |
|---|---|---|---|---|
| S | 256 MB | 4 | 8 | 3 |
| M | 512 MB | 8 | 16 | 7 |
| L | 1 GB | 16 | 32 | 15 |
| XL and above | 2 GB+ | 16 (CPU cap) | 32 (CPU cap) | 15 |
Reference the values in your Caddyfile; the fallbacks only apply outside
Scalingo, where CONTAINER_MEMORY is not set:
{
frankenphp {
num_threads {$FRANKENPHP_NUM_THREADS:4}
max_threads {$FRANKENPHP_MAX_THREADS:8}
# optional: recycle threads after N requests
max_requests {$FRANKENPHP_MAX_REQUESTS:500}
}
}
:{$PORT:8080} {
root * /app/public
php_server {
# optional worker mode (Symfony >= 8.1 supports it out of the box via
# symfony/runtime; Laravel needs Octane)
worker {
file /app/public/index.php
num {$FRANKENPHP_WORKER_THREADS:3}
}
}
}
To override, set any of FRANKENPHP_NUM_THREADS, FRANKENPHP_MAX_THREADS or
FRANKENPHP_WORKER_THREADS on the app (scalingo env-set). Values you set are
kept as-is; the script only fills in the missing ones and raises max_threads
to num_threads if needed. The 64 MB budget per thread is conservative for a
typical Symfony/Laravel request (20-40 MB); lower it in the script if your app
is lighter.
The FrankenPHP binary embeds the Mercure hub, so server-sent events need no extra process. Enable it inside the site block:
mercure {
publisher_jwt {$MERCURE_JWT_SECRET:!ChangeThisMercureHubJWTSecretKey!}
subscriber_jwt {$MERCURE_JWT_SECRET:!ChangeThisMercureHubJWTSecretKey!}
anonymous
# Scalingo's router closes idle connections after 59s
heartbeat 30s
# in-memory transport: fine for a single web container
transport local
}
The hub answers at /.well-known/mercure. Set MERCURE_JWT_SECRET on the app
(scalingo env-set MERCURE_JWT_SECRET=...) and use the same value in your
framework's Mercure configuration. With several web containers, transport local means each node only knows its own subscribers; switch to a shared
transport (e.g. Redis) if updates must reach every subscriber.
Ember is a terminal UI for Caddy and FrankenPHP: per-host traffic, latency percentiles, status codes, and, when FrankenPHP is detected, per-thread introspection, worker restarts and memory tracking. It can also run headless as a Prometheus exporter.
The buildpack installs it as /app/bin/ember:
| Env var | Default | Purpose |
|---|---|---|
EMBER_INSTALL |
true |
Set to false to skip the install |
EMBER_VERSION |
latest release | Pin a version, e.g. 1.6.1 |
Ember only needs Caddy's admin API (localhost:2019). Two additions make
it useful on Scalingo:
-
Turn on HTTP metrics (opt-in since Caddy 2.8), otherwise Ember has nothing to show:
{ admin localhost:2019 metrics { per_host } } -
Expose the admin API to Ember. Scalingo only routes
$PORTto the internet and the admin API listens on loopback, so it has to be proxied. The admin API can reconfigure the whole server: always put basic auth in front of it when it is reachable from the public URL.:{$PORT:8080} { # ... handle_path /ember-admin/* { route { basic_auth { {$EMBER_USER:ember} {$EMBER_PASSWORD_HASH:$2a$14$2OBVCEzzn90dtayNjVF7HeOfrslQblvl.yME7.DhHPQ/HRTmCrlKG} } # don't log Ember's polling (several requests per second) log_skip reverse_proxy localhost:2019 { # the admin API only accepts requests whose Host matches its listen address header_up Host {upstream_hostport} } } } php_server }The default hash belongs to a random, discarded password, so the endpoint stays locked until you configure it:
frankenphp hash-password --plaintext 'a-strong-password' scalingo env-set EMBER_PASSWORD_HASH='$2a$14$...'
Ember polls the admin API several times per second; exclude
admin.apifrom the default logger (seeexample.Caddyfile) to keepscalingo logsreadable.
Pick the option that matches how many web containers you run.
Works with one web container. Ember accepts credentials in the URL:
ember --addr https://ember:a-strong-password@your-app.osc-fr1.scalingo.io/ember-adminInstall Ember locally with brew install alexandre-daubois/tap/ember or from
the releases page.
With several web containers this still connects, but Scalingo's router load-balances every poll to a random node, so the numbers jump between containers. Use option 2 or 3 instead.
Works with any number of web containers, no public exposure needed. Requirements: the app has a Private Network enabled, and the Caddyfile serves the admin proxy on a second, private port (nothing else listens there and it is only reachable from the Private Network, hence no basic auth):
# Private-network listener for Ember. Scalingo only routes $PORT publicly.
:{$EMBER_PRIVATE_PORT:9000} {
bind 0.0.0.0
handle_path /ember-admin/* {
reverse_proxy localhost:2019 {
header_up Host {upstream_hostport}
}
}
respond 404
}
One-off containers join the Private Network and get the same environment as
the app, and ember is on their PATH, so:
scalingo --app your-app run bash
# inside the container: pick a node (1, 2, ... = web-1, web-2, ...)
ember --addr http://1.web.$SCALINGO_APPLICATION_ID.$SCALINGO_PRIVATE_NETWORK_ID.private-network.internal:9000/ember-adminPress Tab or 1-9 to switch tabs, r to restart the FrankenPHP workers of
that node, ? for the full key list, q to quit. Repeat with 2.web... for
the next container. web.<domain> (no number) resolves to one A record per
web container, which is how you can count them.
For a permanent view of all nodes at once, run Ember headless in a tcp
process type declared in your Procfile. It polls every web container over the
Private Network and serves one aggregated Prometheus /metrics endpoint
(labelled ember_instance="web_1", web_2, ...) that the
TCP Gateway addon
publishes at $TCP_URL. Point Prometheus/Grafana at it; there is no TUI in
this mode (use option 2 for that).
Requirements:
- Private Network + TCP Gateway addon on the app
- the private listener from option 2 in the Caddyfile
EMBER_METRICS_PASSWORDset on the app (basic auth for/metrics)- a
bin/ember-daemonscript in your app (below) and aProcfiledeclaringtcp: /app/bin/ember-daemonnext to thewebline, thenscalingo scale tcp:1(new process types start at 0 containers)
Ember cannot change its target list at runtime, so the script re-resolves the number of web containers every 30 s and restarts Ember when it changes:
bin/ember-daemon
#!/usr/bin/env bash
#
# Starts Ember in daemon mode for the `tcp` process type.
#
# Polls the Caddy admin API of every web container over the Scalingo Private
# Network (each node exposes it on EMBER_PRIVATE_PORT, see Caddyfile) and
# serves an aggregated Prometheus endpoint on $PORT, published by the TCP
# Gateway addon at $TCP_URL:
#
# curl -u ember:$EMBER_METRICS_PASSWORD http://<host:port from TCP_URL>/metrics
#
# Environment:
# EMBER_METRICS_PASSWORD required, basic auth password for /metrics
# EMBER_METRICS_USER basic auth user (default: ember)
# EMBER_WEB_NODES fixed number of web containers to poll; disables
# auto-detection when set
# EMBER_RESCAN_INTERVAL seconds between node re-detections (default: 30)
# EMBER_PRIVATE_PORT port of the per-node admin proxy (default: 9000)
# EMBER_INTERVAL polling interval per node (default: 5s)
set -euo pipefail
: "${PORT:?PORT is not set (this script is meant to run as the tcp process on Scalingo)}"
: "${EMBER_METRICS_PASSWORD:?EMBER_METRICS_PASSWORD is not set; /metrics would be public through the TCP Gateway}"
: "${SCALINGO_APPLICATION_ID:?SCALINGO_APPLICATION_ID is not set}"
: "${SCALINGO_PRIVATE_NETWORK_ID:?SCALINGO_PRIVATE_NETWORK_ID is not set; enable a Private Network on the project}"
domain="${SCALINGO_APPLICATION_ID}.${SCALINGO_PRIVATE_NETWORK_ID}.private-network.internal"
private_port="${EMBER_PRIVATE_PORT:-9000}"
rescan="${EMBER_RESCAN_INTERVAL:-30}"
log() { echo "ember-daemon: $*"; }
# Number of web containers: EMBER_WEB_NODES if set, else one A record per
# container behind the unnumbered private name.
resolve_nodes() {
if [ -n "${EMBER_WEB_NODES:-}" ]; then
echo "$EMBER_WEB_NODES"
return
fi
local n
n=$(getent ahosts "web.${domain}" 2>/dev/null | awk '{print $1}' | sort -u | wc -l | tr -d ' ')
if [ "${n:-0}" -lt 1 ]; then
log "could not resolve web.${domain}, assuming 1 web node (set EMBER_WEB_NODES to override)" >&2
n=1
fi
echo "$n"
}
ember_pid=""
start_ember() {
local count=$1 addrs=() i
for i in $(seq 1 "$count"); do
# Alias becomes a Prometheus label value: letters, digits and underscores only
addrs+=(--addr "web_${i}=http://${i}.web.${domain}:${private_port}/ember-admin")
done
log "polling ${count} web node(s) on port ${private_port}, exposing /metrics on :${PORT}"
/app/bin/ember --daemon \
--expose ":${PORT}" \
--metrics-auth "${EMBER_METRICS_USER:-ember}:${EMBER_METRICS_PASSWORD}" \
--interval "${EMBER_INTERVAL:-5s}" \
"${addrs[@]}" &
ember_pid=$!
}
stop_ember() {
if [ -n "$ember_pid" ] && kill -0 "$ember_pid" 2>/dev/null; then
kill "$ember_pid" 2>/dev/null || true
wait "$ember_pid" 2>/dev/null || true
fi
ember_pid=""
}
# Scalingo stops the container with SIGTERM: pass it on and exit cleanly
trap 'log "stopping"; stop_ember; exit 0' TERM INT
nodes=$(resolve_nodes)
start_ember "$nodes"
# With a fixed node count there is nothing to rescan: just follow Ember
if [ -n "${EMBER_WEB_NODES:-}" ]; then
wait "$ember_pid"
exit $?
fi
while true; do
# `sleep` in the background so the TERM trap fires immediately
sleep "$rescan" & wait $! || true
# Ember died on its own: exit non-zero so Scalingo restarts the container
if ! kill -0 "$ember_pid" 2>/dev/null; then
wait "$ember_pid" && status=0 || status=$?
log "ember exited with status ${status}"
exit "$status"
fi
current=$(resolve_nodes)
if [ "$current" != "$nodes" ]; then
log "web nodes changed: ${nodes} -> ${current}, restarting ember"
stop_ember
nodes=$current
start_ember "$nodes"
fi
doneMake it executable (chmod +x bin/ember-daemon), add the tcp line to your
Procfile, commit, then:
scalingo --app your-app env-set EMBER_METRICS_PASSWORD="$(openssl rand -hex 16)"
scalingo --app your-app scale tcp:1
scalingo --app your-app env | grep TCP_URL # e.g. tcp://tcp-1.osc-fr1.scalingo.com:22074
curl -u ember:$EMBER_METRICS_PASSWORD http://tcp-1.osc-fr1.scalingo.com:22074/metricsPrometheus scrape config:
scrape_configs:
- job_name: frankenphp
basic_auth:
username: ember
password: <EMBER_METRICS_PASSWORD>
static_configs:
- targets: ["tcp-1.osc-fr1.scalingo.com:<SCALINGO_TCP_PORT>"]The binary download from GitHub releases failed: FRANKENPHP_VERSION names a
release that does not exist (the error shows the URL), or GitHub was not
reachable. Check the build logs (scalingo deployment-logs) and
https://github.com/php/frankenphp/releases, then retry the deployment. A build
with a pinned version already in the cache does not need GitHub.
The PHP buildpack did not run before this one, or composer install failed.
Check the order in .buildpacks and the PHP buildpack's output above the
warning.
Ember is optional, so the build continues without /app/bin/ember. Pin a
known-good release with EMBER_VERSION, or set EMBER_INSTALL=false to
silence the warning.
Nothing bound to $PORT. Your Caddyfile probably hardcodes a port: use
:{$PORT:8080} as the site address (see Caddyfile). The same
applies to the tcp process: bin/ember-daemon passes --expose :$PORT for
that reason.
Add metrics { per_host } to the global options of the Caddyfile; HTTP
metrics are opt-in in Caddy.
EMBER_PASSWORD_HASH is not set (the default hash matches no password) or the
password in --addr does not match it. Regenerate with frankenphp hash-password and scalingo env-set the result; the app restarts with the new
value.
Run frankenphp validate --config Caddyfile locally, check that public/
exists and read scalingo logs --follow.
MIT