MpxjEx runs MPXJ, a Java library, in a JVM next to your Elixir application. This guide covers what that means in production and how to package it, using Docker as the example.
A complete, working setup lives in
examples/mpxj_service:
a small HTTP API with a production Dockerfile and docker-compose.yml. Its
README explains how to run it.
What mpxj_ex needs at runtime
| Requirement | Why |
|---|---|
| A Java runtime, version 8 or newer | MPXJ is a Java library. A headless JRE is enough; Java 21 LTS is a good default. |
| MPXJ's jars (about 30 MB) | Downloaded once, see Installing MPXJ. |
| A writable temporary directory | MPXJ writes each schedule as JSON to System.tmp_dir!/0 before it's decoded. The JVM also uses it, and some readers extract a native library there, so it must allow executing files. |
| Memory for the JVM | MPXJ holds the whole schedule in memory while reading it. |
Nothing else: the Java bridge (priv/mpxj_ex_bridge.jar) ships with the
package and is included in your release automatically.
Checklist
- Install MPXJ at build time and turn off
auto_install. - Put a Java runtime in the image that runs your release.
- Start an
MpxjEx.Serverin your supervision tree. - Size the JVM heap, the pool and the container memory together.
- Limit upload sizes and set timeouts.
- Add a health check.
Installing MPXJ at build time
By default MpxjEx downloads MPXJ the first time it's used. That's handy in development, but in production it means a 70 MB download from GitHub when the first request arrives, and a failure if GitHub isn't reachable.
Download it while building instead:
mix mpxj_ex.install --path /opt/mpxj
The task downloads the MPXJ distribution, verifies its SHA-256 checksum and keeps only the jars. Then tell MpxjEx where they are and never to download:
# config/prod.exs (or config/config.exs inside `if config_env() == :prod`)
config :mpxj_ex, auto_install: false# runtime environment
MPXJ_EX_JAR_DIR=/opt/mpxj
You can also set config :mpxj_ex, jar_dir: "/opt/mpxj" instead of the
environment variable. With auto_install: false, a missing install fails
with a :not_installed error rather than downloading.
Choosing the MPXJ version
MpxjEx pins a tested MPXJ version and knows its checksum. To use another
one, set it in config/config.exs so both the install task and the
application see it:
config :mpxj_ex, version: "16.10.0"For a version MpxjEx doesn't know, the download isn't verified and a warning is logged. Run your test suite against it before deploying.
Using an MPXJ you already have
Point jar_dir (or MPXJ_EX_JAR_DIR) at a directory that contains
mpxj.jar and all of its dependencies side by side (the contents of the
distribution's lib directory, flattened), and set auto_install: false.
The Java runtime
Install a JRE in the image that runs the release (not only in the build image):
| Base image | Package |
|---|---|
| Debian trixie | openjdk-21-jre-headless |
| Debian bookworm | openjdk-17-jre-headless |
| Ubuntu 24.04 | openjdk-21-jre-headless |
| Alpine | openjdk21-jre-headless |
The example image is built and tested on Debian trixie.
Alternatively copy a JRE from the official image, for example
COPY --from=eclipse-temurin:21-jre /opt/java/openjdk /opt/java/openjdk, and
set JAVA_HOME=/opt/java/openjdk.
MpxjEx looks for java in this order: the :java option, config :mpxj_ex, java: ..., $JAVA_HOME/bin/java, then java on the PATH.
Running MPXJ: one JVM per call, or a server
Every MpxjEx.read/2 or MpxjEx.convert/3 call without a server starts a
JVM, which costs about half a second. That's fine for a nightly job, but for
anything serving users, run an MpxjEx.Server, which keeps one JVM up and
handles several files at once:
# lib/my_app/application.ex
children = [
{MpxjEx.Server, name: MpxjEx.Server, pool_size: 4, max_memory: "1g"},
# ... your other children, such as your endpoint
]# config/config.exs: send every call to it
config :mpxj_ex, server: MpxjEx.ServerMeasured on the test fixtures: about 565 ms per file with a JVM per call, about 33 ms per file through a warm server, and less when files are read concurrently.
If the JVM dies (killed by the out-of-memory killer, for instance), pending
callers get {:error, %MpxjEx.Error{reason: :jvm_crashed}}, the server
stops, and its supervisor starts a new JVM. If it keeps crashing faster than
the supervisor's restart intensity allows, your application stops, and the
container's restart policy takes over.
You can run several servers under different names, for example a separate one with a larger heap for bulk imports.
Sizing
pool_size: how many files the JVM processes at the same time. Parsing is CPU bound, so the number of cores available to the container is a good start (System.schedulers_online/0, the default). Extra requests wait in the JVM's queue.max_memory: the JVM heap (-Xmx). Small and medium schedules are fine with1g. Large MPP or XER files (tens of MB) with timephased data may need2g-4g. A file that doesn't fit fails with:out_of_memory.- Container memory limit: the heap, plus about 250 MB of JVM overhead
(metaspace, threads, code cache), plus the BEAM (about 100 MB), plus
anything you keep in a
tmpfs(it counts as memory). With a 1 GB heap, 1.5 GB to 2 GB is reasonable. If the limit is lower than the heap can grow, the kernel kills the JVM and you'll see:jvm_crashedwith exit status 137. :timeout(per call, default 60 seconds): how long a caller waits for the server. MPXJ can't be interrupted, so a request that timed out keeps running in the JVM until it finishes (the server then removes its temporary output). Timeouts protect callers, not capacity: sizepool_sizefor your slowest files.
An idle container running the example service uses about 225 MB.
Docker
The example's
Dockerfile
is a standard two stage Elixir release build. The lines specific to mpxj_ex
are:
# Build stage: ca-certificates to download MPXJ over HTTPS ...
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ... and install it after fetching dependencies, so the layer is cached
RUN mix deps.get --only prod
RUN mix deps.compile
RUN mix mpxj_ex.install --path /opt/mpxj
# Runtime stage: a Java runtime ...
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends \
openjdk-21-jre-headless libstdc++6 openssl libncurses6 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ... the jars, and where to find them
ENV MPXJ_EX_JAR_DIR=/opt/mpxj
COPY --from=build /opt/mpxj /opt/mpxjThe example builds mpxj_ex from this repository, so its build context is the repository root and it copies the library's sources. In your own app, depend on mpxj_ex from Hex, build from your app's root and skip that step.
The resulting image is about 550 MB, most of it the JRE.
Docker Compose
The example's
docker-compose.yml
runs the service hardened for production:
services:
mpxj_service:
build:
context: ../..
dockerfile: examples/mpxj_service/Dockerfile
ports:
- "4000:4000"
environment:
MPXJ_POOL_SIZE: "4"
MPXJ_MAX_MEMORY: "1g"
MPXJ_TIMEOUT_MS: "60000"
MAX_UPLOAD_MB: "50"
read_only: true
tmpfs:
- /tmp:exec,mode=1777,size=512m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "2"
memory: 1600m
restart: unless-stoppedNotes:
read_only: trueworks because every runtime write goes to/tmp: setRELEASE_TMP=/tmp(the release writes its runtime configuration there) andERL_CRASH_DUMP=/tmp/erl_crash.dump./tmpis mounted withexec: Docker mountstmpfswithnoexecby default, which stops MPXJ readers that load sqlite-jdbc's native library.- The image runs as an unprivileged
appuser.
Build and run it from the example directory:
cd examples/mpxj_service
docker compose up --build -d
docker compose ps # wait for "healthy"
curl -F file=@plan.mpp http://localhost:4000/projects
Handling untrusted files
Schedule files uploaded by users are untrusted input parsed by complex binary and XML readers. Contain the work:
- Limit upload sizes (
Plug.Parsers':lengthoption). - Set a
:timeoutand a heap size (:max_memory), and a memory limit on the container. - Run as an unprivileged user with a read-only filesystem,
no-new-privilegesand no capabilities, as the example does. - Don't send
MpxjEx.Errormessages to clients verbatim: they can contain server paths and Java internals. Log them, and answer with a description based on the error'sreason(see the example's router).
Password protected files fail with :password_protected; MpxjEx doesn't
read them.
Health checks and logs
The example's GET /health answers 200 while MpxjEx.Server is running,
and the image's HEALTHCHECK calls it. For a deeper check, read a small
schedule bundled with your app and check the result.
When the JVM exits, MpxjEx.Server logs the termination with the JVM's exit
status. Anything the JVM writes to stderr goes to your application's stderr,
so it ends up in the container logs.
Configuration reference
Application environment (config :mpxj_ex, ...):
| Key | Default | Description |
|---|---|---|
:version | "16.10.0" | MPXJ version to install and use |
:jar_dir | $MPXJ_EX_JAR_DIR, else <user cache>/mpxj_ex/<version> | Directory containing mpxj.jar and its dependencies |
:auto_install | true | Download MPXJ on first use when it's missing |
:server | nil | MpxjEx.Server used when a call doesn't pass :server |
:java | $JAVA_HOME/bin/java, else java on the PATH | Java executable |
:max_memory | JVM default | Heap size for JVMs started per call, such as "1g" |
:jvm_args | [] | Extra JVM arguments for JVMs started per call |
MpxjEx.Server options: :name, :pool_size, :max_memory, :java,
:jvm_args.
Per call options for MpxjEx.read/2, MpxjEx.read_all/2 and
MpxjEx.convert/3: :server, :timeout, :max_memory, :java,
:jvm_args, plus :time_units, :timephased and :raw for reads.
Upgrading MPXJ
- Set
config :mpxj_ex, version: "x.y.z"(or upgrade mpxj_ex, which may bump its pinned version). - Run your tests: they download the new version into the user cache.
- Rebuild the image: the
mix mpxj_ex.installlayer is rebuilt because the configuration changed.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
:java_not_found | No Java in the runtime image. Install a JRE or set JAVA_HOME. |
:not_installed | auto_install is off and jar_dir has no mpxj.jar. Check MPXJ_EX_JAR_DIR and that the image copies the jars. |
:install_failed during the build | GitHub isn't reachable, a proxy blocks it, or ca-certificates is missing from the build image. |
:out_of_memory | The schedule doesn't fit in the heap. Raise max_memory, and the container limit with it. |
:jvm_crashed with exit status 137 | The kernel killed the JVM: the container limit is too low for the heap. |
:timeout | The file takes longer than :timeout, or every pool slot is busy. Raise the timeout or pool_size. |
UnsatisfiedLinkError mentioning sqlite in the logs | /tmp is mounted noexec. |
Read-only file system when the release starts | Set RELEASE_TMP to a writable directory. |
| The first requests after a deploy are slower | The JVM is warming up (JIT compilation); they speed up after a few files. |