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.

Why HostKit

Infrastructure code should be boring Elixir, not an opaque pile of shell scripts.

HostKit gives you:

  • Declarative host bootstrap — OS packages, accounts, directories, files, env files, systemd units, firewall rules, and mise runtimes.
  • 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.

One file: host, runtime, isolated service, reverse proxy

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

    env :runtime do
      secret :database_url, env: "DATABASE_URL"
    end

    daemon do
      env :runtime
      exec ["/opt/api/bin/server"]

      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
end

This compiles to inspectable HostKit structs and renders ordinary Linux primitives: packages, files, env files, accounts, systemd units, Caddy site config, and systemd hardening directives such as NoNewPrivileges=, ProtectSystem=, RestrictAddressFamilies=, ReadWritePaths=, and memory limits. 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.exs

secret_env/1 stores an environment-variable reference. Plan artifacts include the variable name, not the resolved secret value.

Managed local demo instance

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
end

Manage 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.exs

See examples/livebook_demo_instance.exs for the local Livebook demo target used by the notebook workflow.

Interactive notebook

Deploy real services from Livebook with Kino inputs for SSH target/auth, plan review, explicit apply, and HTTP verification:

Static Caddy site:

Run Caddy notebook in Livebook

Phoenix app from Git, with pinned source revision and source-aware build stamps:

Run Phoenix notebook in Livebook

The notebooks are self-contained and their deployment DSL cells are also exercised by the integration test suite.

Documentation

Development

mix deps.get
mix ci

Run 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 integration

Status

HostKit is early and intentionally evolving. Runtime APIs come first; Mix tasks wrap them. DSLs compile to plain structs so plans and artifacts remain inspectable.

License

MIT