OpenEMR on FrankenPHP+Caddy: Static Binaries, Benchmarking Results, and Repo Tour

Hi OpenEMR Dev Community,

I’ve been experimenting right here in openemr-frankenphp-docker, and I’d love to walk you through what’s inside the repo, why FrankenPHP+Caddy is worth a closer look, and how you can help test the approach.

What’s in this repository

  • openemr-source-code/: a vanilla OpenEMR tree plus one new entry point, frankenphp-worker.php (link here), used to preload key classes.

  • openemr-devops-source-code/: the upstream devops stack copied verbatim so we can compare this FrankenPHP path against the “first” (Apache/PHP-FPM) Docker/Kubernetes assets without context switching.

  • frankenphp_experiment/: the heart of the project, containing a purpose-built Dockerfile (docker/frankenphp/), Compose file (compose/docker-compose.yml), and the benchmarking toolkit (benchmarking/) with k6 scripts, run_benchmarks.sh, and captured results.

  • README.md: documents how the experiment is wired together, the single-file code addition, and—most importantly—the initial performance deltas plus a step-by-step recipe to rebuild them.

Early results from this repo

The current measurements in README.md show some performance improvements versus the standard 7.0.4 Apache image although it should be noted that the benchmarking used to compare the two (basically just sending a bunch of requests at the login page) needs to be improved to truly give a proper comparison of the two.

Metric FrankenPHP Apache 7.0.4 Delta (%)
Avg latency (ms) 84.99 110.74 -23.3%
p95 latency (ms) 211.90 347.11 -39.0%
Requests/sec 172.74 165.26 +4.5%
Web tier CPU % 0.00 4.15 -100.0%
Web tier RSS (MiB) 467.30 807.20 -42.1%
MySQL CPU % 0.01 0.26 -96.2%
MySQL RSS (MiB) 309.80 380.70 -18.6%

Static binaries and deployment impact

Because FrankenPHP supports static builds, the repo shows how we could ultimately ship OpenEMR in containers that look like:

FROM scratch
COPY openemr-frankenphp /
CMD ["/openemr-frankenphp"]

That unlocks:

  • a much smaller CVE surface area
  • faster pulls for autoscaling
  • the ability to run OpenEMR on bare-metal or edge systems without Docker or really any other dependencies required—any environment that can execute a Linux binary becomes viable

Ways to get involved

  1. Clone the repo, follow the six-step “How can I replicate these results?” section in README.md, and post your findings (including session handling, uploads, and portal workflows or anything else you find interesting).

  2. Contribute better load-test scripts under frankenphp_experiment/benchmarking/ so we can stress more real-world paths.

  3. Explore static builds: experiment with creating and running binaries from FrankenPHP and share what you learn.

  4. Open issues/PRs for security hardening, preload tuning in frankenphp-worker.php, or Caddy best practices (TLS, audit logging, etc.).

Thanks for reading and for any feedback you can share. With community testing and collaboration, we can decide whether FrankenPHP becomes a first-class OpenEMR runtime alongside our traditional Apache/Nginx stacks—or even powers a future static-binary distribution.

3 Likes

Would it be crazy to have an Apache or nginx reverse proxying to the Caddy apprunner, or would that burn down most of the gains?

Not crazy at all :slightly_smiling_face: — it’s the same basic pattern as “nginx in front of Node/Go,” just with Caddy+FrankenPHP as the app server instead of a PHP-FPM pool.

You don’t lose most of the FrankenPHP gains by putting Apache/nginx in front, because the big win is:

  • No FastCGI hop. FrankenPHP eliminates the need for a separate FastCGI (FCGI) hop by embedding the PHP interpreter directly within the Caddy web server process
  • warm PHP workers / opcode cache
  • reused DB connections, etc.

All of that still happens behind whatever reverse proxy you put in front.

Where you do pay a price is:

  • One extra hop in the request path (client → Apache/nginx → Caddy → FrankenPHP). On the same host/container this is usually negligible, but it’s still more moving parts.
  • You give up some of Caddy’s “own the edge” benefits if Apache/nginx is doing TLS and HTTP/2/3. A lot of the “wow this is so simple” story comes from Caddy just terminating HTTPS itself and handling redirects, HSTS, etc.

So my mental model is:

  • If you can: let Caddy be the public-facing server and keep the stack simple. That’s the “pure” FrankenPHP experience.
  • If you need to (shared host already running Apache, corporate standard is nginx on 443, want to front multiple apps with a single entrypoint, reuse existing auth/WAF modules, etc.): putting Apache/nginx in front is totally reasonable. Just make sure it’s mostly acting as a pure reverse proxy (pass through websockets, preserve X-Forwarded-* headers, don’t re-compress things twice, etc.).

So:

Would it be crazy…?

Nope. It’s a trade-off: slightly more complexity, slightly less of Caddy’s magic at the edge, but almost all of the FrankenPHP runtime benefits remain.

Hi, Jacob, I’m a big fan of this work. When you talk about a “static build”, are you talking about a single file containing all of openemr, or a single static binary of frankenphp, but still all of openemr in its source tree? Because openemr doesn’t have a front controller, I’m having trouble understanding how routing would work inside a single file.

But I would really love for that to happen.

Hey Mike! Thanks; glad you like it! I’m a big fan of your work and your contributions to OpenEMR. I’ve been following your recent PRs!

The way it works is that FrankenPHP creates a static binary with the PHP interpreter, your application (in our case OpenEMR) and Caddy (a web server).

Routing is handled by Caddy and you define its behavior through the usage of a Caddyfile which is also embedded into the binary. This Caddyfile will serve the same role it currently does in the existing FrankenPHP container of telling Caddy how to serve OpenEMR and handle incoming requests.

So while OpenEMR does not have a front controller it doesn’t need one for this use case because we’re also embedding a web server into the binary as well along with routing and serving instructions (via the Caddyfile).

The theoretical advantage here is that we’d be able to ship a minimal binary and we’d be able to serve OpenEMR on any system that was able to run a Linux-style binary executable. Rather than shipping a container that also has everything else Alpine or Ubuntu needs to function we would be shipping a binary with only what we need to serve OpenEMR (our PHP code, a PHP interpreter and a web server). This binary would still require an external MySQL database and Redis/Valkey cluster to exist to function (much like the current containers do) but it would contain OpenEMR, the PHP interpreter and a web server and, when done, would essentially be a drop in replacement for the current containers.

You also get the advantage of being able to have the compiler find potential optimizations in the code as it’s being compiled to a binary so it’s possible we may find that this also further improves performance although this would have to be validated in testing (along with whether or not OpenEMR is even amenable to FrankenPHP’s style of static compilation).

Documentation about all of this and how it works can be found here: FrankenPHP: the modern PHP app server

I was not planning to focus on creating static builds anytime soon because I believe we will get the most value for effort at this point by focusing on further improving the performance (think there’s a lot of room to further improve things) of the existing FrankenPHP container and bringing it up to feature parity with the current Apache based containers and then submitting a PR to OpenEMR Devops with a production ready FrankenPHP-based container.

Build times for static binaries (especially if you use upx to compress the binary once it’s done building) are way higher than the build times for the FrankenPHP container. We’ll be able to iterate way quicker by working on the FrankenPHP container than with static binaries. Also, especially in advance of us knowing the perfect Caddyfile configuration, it’s way easier to develop and debug when you can hop onto a container with an interactive session and use the command line and edit files in real time. Once you make a static binary you lose that ability.

I’d recommend we explore static builds once we’ve finished optimizing the FrankenPHP container and a version of it exists that’s production ready. Although if there is some pressing reason to explore static builds sooner rather than later I’m happy to help.