Containers
A container image for an esrun service holds two things: the esrun binary and the bundle esdev build produced. Nothing else is needed at run time — no package manager, no node_modules, no shell.
The esrun image
ghcr.io/open-tech-foundation/esrun is the released esrun binary on a distroless base, for linux/amd64 and linux/arm64:
| Tags | latest, and each release as <version> (exact) and <major>.<minor> (newest patch) |
| Base | gcr.io/distroless/cc-debian13:nonroot — no shell, an unprivileged user |
| Entrypoint | esrun, so CMD holds its arguments |
| Working directory | /app |
Every image carries signed build provenance: gh attestation verify oci://ghcr.io/open-tech-foundation/esrun:latest --owner open-tech-foundation.
The Dockerfile
Two stages: one builds with esdev, the other runs on the esrun image.
# Build: esdev compiles and bundles the app into one file. FROM debian:13-slim AS build RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://esrun.opentechf.org/install.sh \ | bash -s -- --only=esdev WORKDIR /app COPY . . RUN /root/.es-runtime/bin/esdev build --minify # Run: the esrun image and the bundle. FROM ghcr.io/open-tech-foundation/esrun:latest COPY --from=build /app/dist ./dist EXPOSE 8080 CMD ["--allow-listen=8080", "--allow-env=PORT", "--shutdown-grace=8000", "dist/server.js"]
With a .dockerignore that keeps local output out of the build context:
node_modules dist .dev
The examples use the newest release. For builds that do not change under you, pin both: the image tag (esrun:<version>) and esdev (ESDEV_VERSION=<version> before bash in the install line). The current versions are on Installation.
Another base image
To run on a base of your own — one with a shell for debugging, say — copy the binary out of the esrun image:
FROM debian:13-slim COPY --from=ghcr.io/open-tech-foundation/esrun:latest /usr/local/bin/esrun /usr/local/bin/esrun
The base needs glibc 2.39 or newer and nothing more: esrun links only the C runtime, and it carries its own TLS trust roots, so ca-certificates is not required for outbound HTTPS. debian:13-slim and ubuntu:24.04 qualify; Alpine and other musl-based images do not. See Platforms.
Grants
The CMD line is the service's security policy. esrun reaches nothing it is not granted, so name exactly what the service needs: the port it listens on, the hosts it calls, the variables it reads.
CMD ["--allow-listen=8080", "--allow-net=api.example.com", "--allow-env=PORT,DATABASE_URL", "dist/server.js"]
esdev --trace-permissions lists what a development run used. The flags are in Permissions & capabilities.
Memory
The default heap ceiling is sized from the container's memory limit, not the host's, so no flag is needed for the service to be stopped by its own heap guard rather than killed by the kernel. Pin it with --max-heap when you want a fixed number. See Memory.
Shutdown
docker stop and Kubernetes send SIGTERM. esrun stops accepting connections, lets in-flight requests finish for up to --shutdown-grace milliseconds (default 10000), and exits with code 143.
The platform sends SIGKILL when its own timeout passes — 10 seconds for docker stop, 30 for a Kubernetes pod. Set --shutdown-grace below it, as the Dockerfile above does, so requests are drained rather than cut off.
Health checks
A distroless image has no curl. esrun can make the request itself, granted only the one address it checks:
HEALTHCHECK --interval=10s --timeout=3s \ CMD ["esrun", "--allow-net=127.0.0.1:8080", "-e=const r = await fetch('http://127.0.0.1:8080/'); if (!r.ok) throw new Error(`status ${r.status}`);"]
A thrown error exits non-zero, which marks the container unhealthy.
Child processes
A service that starts programs with runtime:system should run under an init process — docker run --init, or tini in the image — so that processes orphaned by its children are reaped. esrun waits for the children it starts, not for theirs.
Podman
The same Dockerfile builds unchanged with podman build, and the image runs rootless as its unprivileged user.
To run it as a service, use a Quadlet file — systemd generates the unit from it. For a rootless service, put it in ~/.config/containers/systemd/app.container; for a system one, in /etc/containers/systemd/:
[Unit] Description=Example esrun service [Container] Image=ghcr.io/example/app:1.0.0 PublishPort=8080:8080 Environment=PORT=8080 Exec=--allow-listen=8080 --allow-env=PORT --shutdown-grace=8000 dist/server.js PodmanArgs=--memory=512m [Service] Restart=on-failure TimeoutStopSec=15 SuccessExitStatus=143 [Install] WantedBy=default.target
systemctl --user daemon-reload systemctl --user start app
Exec= holds esrun's arguments, as CMD does. SuccessExitStatus=143 matters here as it does for a systemd service: esrun exits with 143 after SIGTERM, and without it a normal stop leaves the unit failed. A rootless service starts at boot only once lingering is enabled for its user: loginctl enable-linger <user>.