Skip to content

Non-root supervisord for edge-docker-php-dev FrankenPHP variants #4

@davidwindell

Description

@davidwindell

Goal

Bring redis and the cloud-sql-proxy sidecars back to the FrankenPHP dev variants by introducing a non-root supervisord layered on top of the upstream FrankenPHP image. The upstream image (edge-docker-php) stays single-process / PID 1 for serverless production. All changes live in the edge-docker-php-dev repo.

The existing root-supervisord setup that the non-FrankenPHP dev variants inherit from upstream is out of scope — those continue to work unchanged.

Decisions (from clarifying Q&A)

# Decision
1 Install redis-server apt package in the FrankenPHP dev overlay so supervisord can run it as edge.
2 Redis is always on. No ENABLE_REDIS toggle on the FrankenPHP dev track.
3 FrankenPHP itself becomes a supervisord-managed program. Supervisord (running as edge) is PID 1.
4 Supervisord socket / pidfile / log paths under /tmp so edge owns them and supervisorctl works without sudo.
5 Scope: FrankenPHP variants only. Non-fphp dev variants keep inheriting upstream root supervisord.
6 xdebug on/off keeps using frankenphp reload --force (no change).
7 Cloud SQL proxy: upstream commit 2042f5c only added cloud-sql-proxy to Dockerfile.common, not Dockerfile.common-frankenphp. The dev image previously had its own COPY --from=…cloud-sql-proxy (removed in dev commit 1e406a5). On the FrankenPHP track the binary is currently missing entirely. We re-add the COPY in Dockerfile.common-frankenphp in the dev repo. If upstream FrankenPHP later gains it, the dev COPY becomes a harmless overwrite.
8 sshd is NOT supported on the FrankenPHP dev track. Standard OpenSSH sshd requires root for PAM/privsep + chpasswd; running it under non-root supervisord is contrary to the design goal. Documented as unsupported.
9 cron is NOT supported on the FrankenPHP dev track. Standard Debian cron must start as root (setuids to each crontab's owner). Adding a non-root scheduler (e.g. supercronic) is out of scope for this change. README mentions supercronic as a future option.
10 Cloud SQL proxy is always on. No ENABLE_SQL_PROXY toggle on the FrankenPHP dev track.
11 No templating engine required. Because every program is unconditionally autostart=true, the supervisord conf is fully static. No j2/jinjanator install in the dev fphp overlay; the conf is shipped as a static file in the dev repo and copied into the image at build time. The upstream non-fphp track's j2 usage is unchanged (it has 4 conditional templates and is out of scope).
12 No stopasgroup / killasgroup on any program. The three managed programs (frankenphp, redis-server, cloud-sql-proxy) are single-binary processes that handle SIGTERM cleanly themselves; stopasgroup is meant for things like Flask debug mode or shell wrappers that don't propagate signals. Matches upstream non-fphp supervisord.conf.j2, which also doesn't use these.
13 Supervisord's own log: logfile=/dev/null, logfile_maxbytes=0. With nodaemon=true, supervisord prints its state-change messages (INFO success: frankenphp entered RUNNING state, etc.) to its own stdout, which docker captures. Matches upstream conf exactly.
14 No umask=002 in our conf. Upstream non-fphp uses it because that track is multi-user (nginx, www-data, edge in the edge group). The fphp dev image is single-user (every supervised process runs as edge), so umask=002 has no functional effect. supervisord's default 022 is used.
15 dev.sh is argument-aware. If invoked with no args (the default CMD ["/dev.sh"] case) it falls back to launching supervisord. If invoked with args (e.g. docker run <image> /dev.sh bash) it forwards them through launch.sh. Pattern mirrors launch-frankenphp.sh's own three-branch arg handling.

Current state (recap of investigation)

  • edge-docker-php (upstream) :*-frankenphp tags:
    • Single-process model. ENTRYPOINT ["/launch.sh"], no entrypoint shim,
      no supervisord, no redis-server, no cloud-sql-proxy,
      no openssh-server, no cron, no j2/jinjanator, no sudo.
    • launch.sh execs frankenphp run --config /etc/caddy/Caddyfile as
      edge (PID 1).
  • edge-docker-php (upstream) non-fphp :* tags now ship cloud-sql-proxy
    in the base (commit 2042f5c), with a corresponding [program:sql-proxy]
    in the upstream templates/supervisord.conf.j2.
  • edge-docker-php-dev :*-frankenphp tags (current state, post 1e406a5):
    • Inherit the upstream FrankenPHP base, then CMD ["/dev.sh"] which
      finally exec /launch.sh.
    • The dev image's own COPY --from=…cloud-sql-proxy and overlay
      etc/supervisor/conf.d/sql-proxy.conf were removed on the assumption
      upstream provides them. On the FrankenPHP track that assumption is
      currently false — the binary is missing entirely.
  • edge-docker-php-dev :8.3 / :8.4 tags inherit upstream non-fphp where
    supervisord runs as root and forks per-program users (edge, redis,
    nginx). That is the model we are NOT replicating.

Design

Process model

Supervisord (running as edge) becomes PID 1 of the dev container and
supervises three programs, all autostart=true, all running as edge:

Program Command
frankenphp /usr/local/bin/frankenphp run --config /etc/caddy/Caddyfile
redis /usr/bin/redis-server --save "" --dir /tmp --appendonly no --bind 127.0.0.1 --port 6379
sql-proxy /usr/local/bin/cloud-sql-proxy (binary copied via dev overlay — see decision #7)

sshd and cron are intentionally not managed (decisions #8, #9).
The README documents ENABLE_SSH and ENABLE_CRON as unsupported on the
FrankenPHP track. ENABLE_REDIS and ENABLE_SQL_PROXY are likewise
unsupported on this track (decisions #2, #10) — both services are always
on. Users wanting toggleable services should use the non-fphp dev variants.

All programs:

  • stdout_logfile=/dev/stdout, stderr_logfile=/dev/stderr,
    *_logfile_maxbytes=0 (so logs flow to docker logs).
  • autorestart=true (matching upstream supervisord patterns).
  • No stopasgroup / killasgroup (decision #12).

Supervisord configuration

A static, hand-written file shipped at etc/supervisord.conf in the dev
repo, copied to /etc/supervisord.conf at build time (owned by edge):

[supervisord]
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
nodaemon=true
user=edge

[unix_http_server]
file=/tmp/supervisor.sock

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///tmp/supervisor.sock

[include]
files=/etc/supervisor/conf.d/*.conf

[program:frankenphp]
command=/usr/local/bin/frankenphp run --config /etc/caddy/Caddyfile
autostart=true
autorestart=true
user=edge
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:redis]
command=/usr/bin/redis-server --save "" --dir /tmp --appendonly no --bind 127.0.0.1 --port 6379
autostart=true
autorestart=true
user=edge
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:sql-proxy]
command=/usr/local/bin/cloud-sql-proxy --log-level=warning
autostart=true
autorestart=true
user=edge
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Notes:

  • user=edge in [supervisord] is harmless when supervisord is launched
    as edge and prevents accidental escalation.
  • SERVER_NAME and SERVER_ROOT env vars are computed by the launcher
    before exec'ing supervisord; supervisord inherits and passes them to
    frankenphp via the normal child-process environment (no environment=
    line needed since the values are in the supervisord process's env).
  • cloud-sql-proxy invocation matches upstream non-fphp's stanza exactly
    (users supply DB instance config via env vars / extra args at deploy
    time, same as today).

Logging behavior

  • Child program output (frankenphp, redis, sql-proxy stdout+stderr) is
    routed by supervisord directly to /dev/stdout and /dev/stderr of
    the supervisord (PID 1) process — docker logs captures both streams.
  • Supervisord's own activity log (state changes, restart notices)
    goes to its own stdout because nodaemon=true and silent is left at
    its default false. logfile=/dev/null + logfile_maxbytes=0
    suppresses any file logging and is required because /dev/null is not
    seekable (per the supervisor docs for non-seekable logfile paths).
  • Net effect: identical to the upstream non-fphp track — all useful logs
    appear in docker logs.

Boot sequence

We deliberately do not add a second launcher script that duplicates
the env-setup logic from launch-frankenphp.sh. The upstream launcher
already supports invoking arbitrary commands as PID 1 — its third arg
branch is exec "$@". We exploit that.

The new chain on the FrankenPHP dev variants:

  1. ENTRYPOINT ["/launch.sh"] — unchanged (inherited from upstream).
  2. CMD ["/dev.sh"] — unchanged at the Docker level.
  3. So the runtime invocation is /launch.sh /dev.shlaunch.sh sources
    /etc/profile.d/edge-env.sh, exports SERVER_NAME and SERVER_ROOT,
    then execs /dev.sh (its third-branch exec "$@").
  4. dev.sh:
    • Sets COMPOSER_HOME, YARN_CACHE_FOLDER, npm_config_cache (existing).
    • Sources /ona.sh if gitpod is on PATH (existing).
    • Writes $RUNTIME_URL to /tmp/runtime.url (existing).
    • Toggles xdebug if XDEBUG_ENABLE=On (existing).
    • CHANGED: the existing final exec /launch.sh "$@" is replaced
      by an arg-aware pattern that mirrors launch-frankenphp.sh's own
      fallback model:
      # If no args were passed, default to supervisord-managed multi-proc.
      # Otherwise, forward whatever was passed through launch.sh.
      if [ $# -eq 0 ]; then
          set -- /usr/bin/supervisord
      fi
      exec /launch.sh "$@"
      This re-enters launch.sh with explicit args. launch.sh's env exports
      are idempotent (edge-env.sh source is guarded by CUSTOM_VARS_SET;
      SERVER_NAME / SERVER_ROOT re-exports are no-ops). It then execs
      whatever was selected, which becomes PID 1 with all required env in
      scope.

Container PID 1 in the default case is then supervisord running as
edge. No new launcher script lives in the dev repo, no upstream
changes are needed.

Invocation modes

The arg-aware dev.sh gives users three boot modes without sacrificing the
single-process escape hatch:

Invocation What happens
docker run <image> CMD /dev.sh runs with no args → dev.sh setup → defaults to supervisord (multi-proc, all sidecars).
docker run <image> /dev.sh frankenphp run --config /etc/caddy/Caddyfile dev.sh setup → forwards args → launch.sh execs frankenphp directly (single-proc).
docker run <image> /dev.sh bash dev.sh setup (composer/yarn caches, ona, xdebug, runtime URL) → interactive shell via launch.sh.
docker run <image> bash Bypasses dev.sh entirely (CMD overridden) — identical to upstream behavior.

Why this is safe to run as edge

  • All sockets / pidfiles / log paths are under /tmp (writable).
  • All managed programs already run fine as edge: frankenphp does so
    upstream; cloud-sql-proxy is a userland Go binary; redis-server
    doesn't need a dedicated redis user when its --dir is a writable
    path (we use /tmp).
  • No CAP_NET_BIND_SERVICE needed — frankenphp listens on ${PORT}
    (default 8080), redis on 127.0.0.1:6379, sql-proxy on >1024 ports.

Shutdown semantics

  • docker stop sends SIGTERM to PID 1 (supervisord) → supervisord sends
    SIGTERM to each child → each child handles it cleanly (frankenphp
    drains, redis exits, sql-proxy exits).
  • Ona stop: for the FrankenPHP track: supervisorctl shutdown (no
    -c flag needed because /etc/supervisord.conf is in supervisorctl's
    default search path; no sudo needed because supervisord runs as
    edge and the socket is under /tmp).

xdebug toggle (no change to user behavior)

usr/local/bin/xdebug already detects frankenphp and uses
frankenphp reload --force --config /etc/caddy/Caddyfile. That keeps
working unchanged because frankenphp is still running with the same
config file — supervisord just supervises the process now.

File-by-file changes (in /workspaces/edge-docker-php-dev)

Add

  1. etc/supervisord.conf — new static file, contents shown above.
    (Path chosen to match the upstream non-fphp track which writes its
    rendered conf to /etc/supervisord.conf. Lives under etc/ in the
    repo so the existing COPY --chown=edge . / lands it at
    /etc/supervisord.conf.)

Modify

  1. Dockerfile.common-frankenphp:

    • Add to apt install list (root section): redis-server, supervisor.
      (No pipx/jinjanator — decision #11.)
    • Re-introduce the cloud-sql-proxy COPY (decision #7) in the root
      section, before the final USER edge:
      # Install Google Cloud SQL Proxy (upstream FrankenPHP base does not ship it)
      COPY --from=gcr.io/cloud-sql-connectors/cloud-sql-proxy:2 \
          /cloud-sql-proxy /usr/local/bin/cloud-sql-proxy
  2. dev.sh:

    • Replace the final exec /launch.sh "$@" with the arg-aware
      fallback pattern (decision #15):
      if [ $# -eq 0 ]; then
          set -- /usr/bin/supervisord
      fi
      exec /launch.sh "$@"
      This routes through the existing upstream launcher (which sets up
      SERVER_NAME / SERVER_ROOT / edge-env.sh and then execs the
      command per its third arg-handling branch). No new launcher script
      is needed in the dev repo.
  3. README.md:

    • Update the "Image variants" table — the FrankenPHP variants now use
      "supervisord (multi-proc, runs as edge)" instead of "single process".
    • Add a new section that outlines the "Invocation modes" mentioned above (the table with Invocation and What happens).
    • Replace the FrankenPHP Ona automations.yaml example to use a
      supervisord-style start/stop:
      services:
        servers:
          name: supervisord
          description: Launches FrankenPHP, Redis and SQL Proxy
          commands:
            start: /dev.sh
            stop: supervisorctl shutdown
          triggeredBy:
            - postEnvironmentStart
    • Reflect that redis and cloud-sql-proxy now run on the FrankenPHP
      track too (always on, no env toggle).
    • Document explicitly that ENABLE_REDIS, ENABLE_SQL_PROXY,
      ENABLE_SSH, and ENABLE_CRON are unsupported on the FrankenPHP
      track.
      Users needing toggles or sshd/cron should use the
      non-frankenphp dev variants. Mention supercronic as a future option
      for non-root cron if demand arises.

Unchanged

  • Dockerfile.common (non-fphp dev) — out of scope.
  • Dockerfile.php83, Dockerfile.php84 — out of scope.
  • Dockerfile.php83-frankenphp, Dockerfile.php84-frankenphp — they
    only INCLUDE+ Dockerfile.common-frankenphp, no edits needed.
  • usr/local/bin/xdebug — already handles both tracks correctly.
  • home/edge/.bashrc, home/edge/.bash_profile,
    templates/20-xdebug.ini, ona.sh, publish.sh.
  • Upstream edge-docker-php repo — untouched. Production
    single-process semantics preserved. The existing launch-frankenphp.sh
    third arg-handling branch (exec "$@") already accommodates our
    pattern; no upstream tweak required.

Verification plan (post-implementation, manual)

  1. docker build -f Dockerfile.php83-frankenphp -t test:fphp .
  2. docker run --rm -p 8080:8080 test:fphp
    • Expect: supervisord starts as edge, all three child programs
      log to docker stdout. Supervisord's own
      INFO success: … entered RUNNING state lines visible too.
    • curl localhost:8080/healthzok.
  3. docker exec -it <c> supervisorctl status (no sudo) → all three
    (frankenphp, redis, sql-proxy) RUNNING.
  4. docker exec -it <c> redis-cli pingPONG.
  5. docker exec -it <c> which cloud-sql-proxy
    /usr/local/bin/cloud-sql-proxy (verifies the re-introduced COPY).
  6. docker exec -it <c> xdebug on → "Xdebug enabled", frankenphp
    reloads, PHP exposes xdebug; xdebug off reverses it.
  7. docker exec <c> ps -eo user,pid,cmd → all processes are edge,
    no stray root processes.
  8. docker stop <c> exits within the grace period; logs show clean
    shutdown messages from all three children.
  9. docker run --rm -p 8080:8080 outeredge/edge-docker-php:8.3-frankenphp
    (upstream) still PID-1-frankenphp, regression-free.
  10. Invocation-mode checks (decision #15):
    • docker run --rm -p 8080:8080 test:fphp /dev.sh frankenphp run --config /etc/caddy/Caddyfile
      → dev.sh setup runs (composer caches set, xdebug toggled per env,
      etc.) then exec's single-proc frankenphp. ps shows frankenphp
      as PID 1, no supervisord.
    • docker run --rm -it test:fphp /dev.sh bash → dev.sh setup runs,
      drops to interactive shell. id is edge.
    • docker run --rm test:fphp bash -c 'echo $XDEBUG_ENABLE'
      bypasses dev.sh entirely (CMD overridden, dev.sh setup skipped).
      XDEBUG_ENABLE reads from the image default Off. Confirms the
      pure escape hatch is intact.

Risks / open considerations

  • redis-server writes to /tmp: by design — dev redis is
    ephemeral. If a user wants persistence they can edit
    /etc/supervisord.conf to override --dir.
  • Escape hatch for single-process behavior: still works via
    docker run … <image> frankenphp run --config /etc/caddy/Caddyfile
    (or any other CMD override). The supervisord-managed multi-proc
    behavior only engages when dev.sh runs and reaches its final exec.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions