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:

Tagslatest, and each release as <version> (exact) and <major>.<minor> (newest patch)
Basegcr.io/distroless/cc-debian13:nonroot — no shell, an unprivileged user
Entrypointesrun, 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.

DOCKERFILE
# 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:

TEXT
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:

DOCKERFILE
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.

DOCKERFILE
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.

Keep the grace below the platform's timeout

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:

DOCKERFILE
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/:

INI
[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
Shell
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>.

Last updated on
Edit this page