Elixir-native host management: declare a Linux host, bootstrap packages and runtimes, isolate services with systemd, wire provider integrations, review a plan artifact, then apply it locally or over SSH.
HostKit is for operating real machines without assuming the target already has Elixir, Mix, Docker, or your application runtime installed.
Note
HostKit is currently published as a beta. The core planning/apply workflow is usable and documented, but DSL, provider, and recipe APIs may still change before a stable release.
Infrastructure code should be boring Elixir, not an opaque pile of shell scripts.
HostKit gives you:
- Declarative host bootstrap — OS packages, accounts, directories, files, templates, dotenv/INI/YAML configs, systemd units, firewall rules, and
miseruntimes. - Docker-less service isolation — systemd sandboxing, resource limits, network policy, read/write path allowlists, loopback listeners, and managed env files.
- Plan before apply — read current state, produce a diff, write an inspectable JSON artifact, then apply exactly what was reviewed.
- Distribution-aware packages — semantic package names resolve through Repology and can be locked for deterministic applies.
- No hidden Mix requirement on target hosts — bootstrap can install prerequisites and BEAM tools through
mise. - Host config in
.exs— syntax highlighting, macros, composition, and project-local DSLs. - Provider boundary — integrations such as Caddy live as providers while core owns systemd/unitctl primitives.
- Linux-native integration testing — Incus containers/VMs replace macOS-only Lima flows on Linux.
The complete example lives in examples/full_host.exs and is loaded by the test suite so it does not drift.
use HostKit.DSL, providers: [HostKit.Providers.Caddy]
project :prod do
roots data: "/srv/apps", config: "/etc/apps"
host :app, at: "app.example.com" do
ssh do
user "root"
identity_file Path.expand("~/.ssh/id_ed25519")
accept_hosts true
retry attempts: 3
end
end
bootstrap do
package :ca_certificates
mise do
tool :erlang, "29.0.2"
tool :elixir, "1.20.1"
end
end
service :api do
account system: true
storage :data, mode: 0o750
storage :config, owner: "root", group: service_user(), mode: 0o750
env :runtime do
secret :database_url, env: "DATABASE_URL"
end
ini path(:config, "app.ini"), owner: "root", group: service_user(), mode: 0o640 do
set "APP_NAME", "Example API"
section "server" do
set "HTTP_ADDR", "127.0.0.1"
set "HTTP_PORT", 4000
secret "JWT_SECRET", env: :redacted
end
end
dotenv path(:config, "worker.env"), owner: "root", group: service_user(), mode: 0o640 do
set "MIX_ENV", "prod"
set "WORKER_POOL", 4
secret "GENERATED_TOKEN", env: :redacted
end
yaml path(:config, "health.yaml"),
owner: "root",
group: service_user(),
mode: 0o640,
content: [
endpoints: [
[name: "api", url: "http://127.0.0.1:4000/health", conditions: ["[STATUS] == 200"]]
]
]
daemon :api do
env :runtime
exec argv("/opt/api/bin/server",
opts: [config: path(:config, "app.ini"), port: 4000]
)
isolate do
memory_max "512M"
writable :data
network :loopback
end
listen :http, port: 4000
end
caddy_site "api.example.com" do
reverse_proxy :http
end
end
endThis compiles to inspectable HostKit structs and renders ordinary Linux primitives: packages, files, templates, dotenv/INI/YAML config files, accounts, systemd units, Caddy site config, and systemd hardening directives such as NoNewPrivileges=, ProtectSystem=, RestrictAddressFamilies=, ReadWritePaths=, and memory limits. Secret/redacted structured config entries are omitted from public drift comparison by dotenv key, INI key, or YAML path, so generated values can be modeled without leaking them into plans. Public dotenv/INI/YAML drift is shown as structured, JSON Patch-style plan diffs with readable paths and before/after values, while redacted entries are listed only by path. Templates diff public assign metadata and redacted assign names; rendered template content is not structurally inferred from arbitrary text. Unit names and command argv can be built from structured declarations instead of hand-written strings. See the DSL design guidelines for naming, block shape, defaults, and reference style.
Plan, review, apply:
mix host_kit.plan --host app \
--write-package-lock host_kit.package.lock \
--out host_kit.plan.json \
infra/config.exs
mix host_kit.apply --host app \
--plan host_kit.plan.json \
--confirm \
infra/config.exssecret_env/1 stores an environment-variable reference. Plan artifacts include the variable name, not the resolved secret value. Runtime callers can use HostKit.Project.audit/2, HostKit.Project.read/2, and HostKit.Facts.collect/2 directly; mix host_kit.audit, mix host_kit.read, and mix host_kit.facts are wrappers around those inspectable APIs.
host is a connection endpoint. instance is a lifecycle-managed compute boundary. Backends such as Incus create/start/destroy the instance, while nested host and service declarations describe how HostKit connects into it and what should run inside.
use HostKit.DSL
project :demo do
instance :demo_vm do
backend :incus, sudo: true
image "images:ubuntu/24.04"
kind :container
lifecycle :ephemeral
expose :ssh, host: 2222, guest: 22
expose :web, host: 18_080, guest: 80
host :guest, at: "127.0.0.1" do
ssh do
user "root"
password "hostkit-demo"
port 2222
accept_hosts true
end
end
service :web do
package :caddy
end
end
endManage the declared instance through the backend-neutral instance CLI:
mix host_kit.instance ensure demo_vm infra/demo.exs
mix host_kit.instance status demo_vm infra/demo.exs
mix host_kit.instance destroy demo_vm infra/demo.exsSee examples/livebook_demo_instance.exs for the local Livebook demo target used by the notebook workflow.
Deploy real services from Livebook with Kino inputs for SSH target/auth, plan review, explicit apply, and HTTP verification:
Static Caddy site:
Phoenix app from Git, with pinned source revision and source-aware build stamps:
The notebooks are self-contained and their deployment DSL cells are also exercised by the integration test suite.
- Getting started
- Conventions and paths
- Remote bootstrap and plan artifacts
- Systemd isolation
- Firewall and networking
- Workspaces and tenants
- Observability and monitors
- Timers and jobs
- Deploy a Caddy site Livebook
- Deploy a Phoenix app Livebook
- CLI reference
- Full DSL/reference notes
- Internal architecture
- Changelog
mix deps.get
mix ciRun the Incus-backed remote integration on Linux:
HOSTKIT_INCUS_SUDO=true HOSTKIT_SSH_PUBLIC_KEY=$HOME/.ssh/id_ed25519.pub \
scripts/incus_integration_vm.sh ensure
HOSTKIT_INTEGRATION_TOOL=incus HOSTKIT_INCUS_SUDO=true \
mix test test/integration/cli_remote_test.exs --include integrationHostKit is early and intentionally evolving. Runtime APIs come first; Mix tasks wrap them. DSLs compile to plain structs so plans and artifacts remain inspectable.
MIT