Henka ships as a container image that bundles the language servers it drives
(jdtls, rust-analyzer, typescript-language-server) along with the JRE and Node
they need and git/jj for worktree/workspace detection. There is nothing to
install on the host beyond a container runtime.
cp docker-compose.yml.example docker-compose.yml
cp .env.example .env # then edit: set HENKA_WORKSPACES_DIR
docker compose up -ddocker-compose.yml and .env are gitignored, so your local edits stay out of
version control.
HENKA_WORKSPACES_DIR is a host directory of working copies, mounted read-write
at /workspaces. This serves MCP over streamable HTTP at
http://127.0.0.1:8181/mcp. Each working copy under /workspaces is
auto-registered as a project (named after its directory), so a client can
call operations against it without a register_project step; a working copy
mounted elsewhere can still be registered explicitly by its in-container path. A
project is a repository; its git worktrees / jj workspaces can sit as sibling
directories under /workspaces, and Henka groups them by repository so they
share one index — the workspace argument on an operation then selects which
working copy an edit lands in.
A client running outside the container speaks host paths, which do not exist at
that spelling inside it. Henka bridges this with HENKA_PATH_MAP: host=container
prefix rewrites it applies to caller-supplied paths (a project root, a
workspace, an absolute file), so the client can register and target projects
by the paths it knows. The binary knows nothing of the mount layout — the compose
file owns that convention and derives the variable from HENKA_WORKSPACES_DIR and
the /workspaces mount, appending any extra rewrites you set in HENKA_PATH_MAP.
Running the image directly, set it yourself, e.g.
-e HENKA_PATH_MAP=/home/me/src=/workspaces.
When the client is itself containerized, the host side of a rewrite is the
client container's mount path, not the underlying host's — e.g. a client that
sees the tree at /root/src wants HENKA_PATH_MAP=/root/src=/workspaces. A client
that cannot be path-mapped need not reason about paths at all: project_status
reports the revision and the changed-file set with each file's git blob object
id (plus a combined digest), which the client reproduces in its own checkout
with git hash-object to confirm Henka reads the same tree — the same edit at a
different mount path yields the same ids.
Configuration knobs (environment variables, all optional):
| Variable | Default | Purpose |
|---|---|---|
HENKA_IMAGE |
ghcr.io/martint/henka:latest |
Image to run. |
HENKA_PUBLISH_ADDR |
127.0.0.1 |
Host interface Docker publishes the port on (the container always binds 0.0.0.0). |
HENKA_PUBLISH_PORT |
8181 |
Host port. |
HENKA_WORKSPACES_DIR |
(required) | Host directory of working copies, mounted read-write at /workspaces. The compose file also uses it to build the path-translation map. |
HENKA_PATH_MAP |
(none) | Extra host=container prefix rewrites (comma-separated) for additional mounts, appended to the HENKA_WORKSPACES_DIR→/workspaces rewrite the compose file derives. |
HENKA_AUTO_REGISTER_ROOTS |
path-map mounts + /workspaces |
Directories whose immediate children are auto-registered as projects (comma-separated). Set empty to disable and register every project by hand. |
HENKA_UID / HENKA_GID |
0 / 0 (root) |
Uid/gid the container runs as. Set to your own (id -u / id -g) so files Henka writes are owned by you. |
HENKA_DATA_DIR |
henka-data (named volume) |
Where the registry and indexes persist. |
HENKA_LOG |
info |
Log filter (tracing env-filter syntax). |
All persistent state lives under /data (the image sets HENKA_DATA=/data):
the project registry at /data/projects.toml and the warm per-repository
indexes under /data/workspaces. Mounting /data to a host directory or volume
is all it takes to persist everything across restarts.
Henka writes in two places: edits into the working copies under /workspaces
(including the .jj/.git snapshots VCS makes), and its own state under
/data. By default the container runs as root, so those files end up root-owned
on the host. Run it as your own user instead — set HENKA_UID/HENKA_GID (the
compose file passes them to user:), or launch with
HENKA_UID=$(id -u) HENKA_GID=$(id -g) docker compose up -d. The image makes
/data and HOME writable by any uid, so an arbitrary user can run it; the
managed henka-data volume is seeded world-writable for the same reason.
docker run --rm -p 127.0.0.1:8181:8181 \
--user "$(id -u):$(id -g)" \
-v henka-data:/data \
-v "$PWD/workspaces:/workspaces:rw" \
ghcr.io/martint/henka:latest--user makes the files Henka writes owned by you rather than root; drop it to
run as root.
The default command is --transport http --bind 0.0.0.0:8181. Pass extra
configuration through the environment rather than overriding it (see the
Host-header guard below).
The streamable-HTTP transport rejects Host headers outside the loopback set
(localhost, 127.0.0.1, ::1) as a DNS-rebinding guard. A client that
reaches the server under another name — for instance a containerized MCP client
connecting to the host as host.docker.internal — must have that host allowed.
Set HENKA_MCP_ALLOWED_HOST (space-separated for several) in .env or the
container environment:
docker run -e HENKA_MCP_ALLOWED_HOST=host.docker.internal ... \
ghcr.io/martint/henka:latest(The equivalent CLI flag is --allowed-host, repeatable; the environment
variable is the same allowlist and is the easier knob in a container.)
Henka is unauthenticated. Anyone who can reach the port can operate on every registered project and apply edits to the mounted repositories. Keep the published port on loopback (the default), and if you need it reachable over a network, put a reverse proxy in front that terminates authentication and forwards to Henka on loopback. Do not bind it to a public interface directly.
docker build -t henka:dev .The build fetches the language-server distributions, so it needs network access
and takes a few minutes the first time. Pin versions with build args:
--build-arg JDTLS_VERSION=…, --build-arg RUST_ANALYZER_VERSION=…,
--build-arg JJ_VERSION=….
Pushing a v*.*.* tag triggers .github/workflows/release.yml, which builds a
multi-arch (amd64 + arm64) image and publishes it to
ghcr.io/<owner>/henka with :<version>, :<major>.<minor>, and :latest
tags. The workflow can also be run on demand from the Actions tab (it publishes
a sha-… tag).