Start with the small set of notebook dependencies. kino gives us friendly inputs, req verifies the deployed site, and host_kit provides the deployment DSL and runtime APIs.
Mix.install([
{:kino, "~> 0.14"},
{:req, "~> 0.5"},
{:host_kit, github: "elixir-vibe/host_kit"}
])Use short aliases in the rest of the notebook so the code reads like a walkthrough instead of a wall of module names.
alias HostKit, as: HK
alias HostKit.{Apply, Host, Project}
alias HostKit.Apply.Event, as: ApplyEvent
alias HostKit.Plan.{Artifact, Format}
alias HostKit.Providers.CaddyThis notebook is a small, readable HostKit demo:
- collect target and site settings,
- declare a target and a static site,
- inspect the plan,
- optionally apply it,
- verify the site.
The deployment declaration lives directly in the notebook. The integration test extracts the marked DSL cell below.
Demo settings
First, expose the values someone actually cares about. Advanced SSH values still come from ordinary environment variables so the demo form stays approachable.
settings_form =
Kino.Control.form(
[
server: Kino.Input.text("Server", default: System.get_env("HOSTKIT_TARGET_HOST", "127.0.0.1")),
user: Kino.Input.text("SSH user", default: System.get_env("HOSTKIT_TARGET_USER", "root")),
public_port: Kino.Input.number("Public port", default: 18_080),
message: Kino.Input.text("Message", default: "Deployed by HostKit"),
apply?: Kino.Input.checkbox("Apply now", default: false),
verify?: Kino.Input.checkbox("Verify after apply", default: false)
],
submit: "Plan deployment"
)
Kino.render(settings_form)Read the submitted form. From here on, everything is plain Elixir data that the HostKit declaration can reference.
settings = Kino.Control.await(settings_form)Turn the UI fields into explicit target settings. This keeps credentials and transport details visible without mixing them into the deployment declaration.
target = %{
host: settings.server,
user: settings.user,
sudo: true,
ssh: [
port: String.to_integer(System.get_env("HOSTKIT_TARGET_PORT", "22")),
identity_file: Path.expand(System.get_env("HOSTKIT_IDENTITY_FILE", "~/.ssh/id_ed25519")),
silently_accept_hosts: true
]
}Describe the tiny site we want to publish.
site = %{
address: ":#{settings.public_port}",
message: settings.message
}
apply? = settings.apply?
verify? = settings.verify?Derive stable names and paths once. The declaration below can now focus on resources instead of string building.
deployment_name = "hostkit-caddy-demo"
target_host = target.host
target_user = target.user
target_sudo = target.sudo
ssh_opts = target.ssh
site_address = site.address
message = site.message
acme_email = "admin@example.com"
site_root = "/srv/#{deployment_name}"
caddy_config_path = "/etc/#{deployment_name}/Caddyfile"
caddy_config_dir = Path.dirname(caddy_config_path)
caddy_sites_dir = "/etc/#{deployment_name}/sites"
caddy_service_name = "#{deployment_name}.service"
artifact_path = "/tmp/#{deployment_name}.plan.json"
verify_url = "http://127.0.0.1#{site_address}"
public_url = "http://#{target_host}#{site_address}"Deployment declaration
This is the important part: ordinary HostKit DSL, not a generated script. The same declaration works in tests, scripts, Mix tasks, and this Livebook.
# hostkit:deploy-caddy-site-dsl
html = """
<!doctype html>
<html>
<head><meta charset=\"utf-8\"><title>Hello from HostKit</title></head>
<body>
<h1>Hello from HostKit</h1>
<p>#{message}</p>
</body>
</html>
"""
caddyfile = """
{
admin off
email #{acme_email}
}
import #{caddy_sites_dir}/*.caddy
"""
alias HostKit.Providers.Caddy
use HostKit.DSL, providers: [Caddy]
project =
project :deploy_caddy_site do
host :target do
hostname target_host
user target_user
sudo target_sudo
ssh ssh_opts
end
provider :caddy, Caddy do
set :sites_dir, caddy_sites_dir
end
service :hello_site do
package :caddy, as: "caddy"
directory site_root, owner: "root", group: "root", mode: 0o755
directory caddy_config_dir, owner: "root", group: "root", mode: 0o755
directory caddy_sites_dir, owner: "root", group: "root", mode: 0o755
file Path.join(site_root, "index.html"),
content: html,
owner: "root",
group: "root",
mode: 0o644
file caddy_config_path,
content: caddyfile,
owner: "root",
group: "root",
mode: 0o644
daemon caddy_service_name do
description "HostKit demo Caddy site"
exec_start ["/usr/bin/caddy", "run", "--config", caddy_config_path]
restart :on_failure
wanted_by :multi_user
end
caddy_site :hello, site_address do
root site_root
file_server()
end
ready :hello_site do
systemd caddy_service_name, restart: true
http verify_url
end
end
end
projectPlan
Build an inspectable plan for the selected host. Nothing has been changed on the target yet.
host = hd(project.hosts)
target_opts = Host.target_opts(host)
{:ok, plan} = HK.plan(project, target_opts)Render the plan as a human-readable checklist.
plan |> Format.format() |> Kino.Markdown.new()Inspect generated resources
The DSL compiles to plain structs. You can inspect the resources before applying them.
Project.resources(project)Save an artifact
Plans can be saved as artifacts for review, CI, or later rollback workflows.
:ok = Artifact.save(artifact_path, plan)
artifact_pathApply
Applying is explicit. Set Apply now in the settings form when you are ready.
reporter = self()
apply_result =
if apply? do
HK.apply(plan, Keyword.merge(target_opts, confirm: true, reporter: reporter))
else
{:skipped, :set_apply_to_true}
endHostKit sends progress events to the notebook process, so the UI does not need callback plumbing.
progress =
Stream.repeatedly(fn ->
receive do
{Apply, event} -> ApplyEvent.format(event)
after
0 -> nil
end
end)
|> Enum.take_while(& &1)
{apply_result, progress}Verify
Readiness checks in the declaration already restart and wait for the service during apply. Verification can stay simple: fetch the public URL.
if verify? do
response = Req.get!(public_url)
{response.status, response.body, public_url}
else
{:skipped, public_url}
end