Deploy a Caddy static site with HostKit
host_kit_dep =
if File.exists?(Path.join(File.cwd!(), "mix.exs")) do
{:host_kit, path: File.cwd!()}
else
{:host_kit, github: "elixir-vibe/host_kit"}
end
Mix.install([
{:kino, "~> 0.19"},
{:req, "~> 0.5"},
{:jsonpatch, "~> 2.3"},
{:yaml_elixir, "~> 2.11"},
{:ymlr, "~> 5.1"},
host_kit_dep
])
Setup
Code.require_file("notebooks/learn/hostkit_demo_helpers.exs", File.cwd!())
Logger.configure(level: :notice)
alias HostKit, as: HK
alias HostKit.LivebookDemo, as: Demo
alias HostKit.Providers.Caddy
Target
Use the local demo VM defaults, or replace them with your own server.
SSH key upload Uploaded keys are copied to a temporary `0600` file in the Livebook runtime and used as the SSH identity file.form =
Demo.target_form(
server: "127.0.0.1",
user: "root",
password: "hostkit-demo",
ssh_port: 2222,
public_port: 18_080,
message: "Deployed by HostKit"
)
settings = Demo.await_target(form)
target = %{
host: settings.server,
user: settings.user,
sudo: true,
ssh: Demo.ssh_opts(settings)
}
site_address = ":#{settings.public_port}"
message = settings.message
Declare
A tiny static site, described as HostKit resources.
deployment_name = "hostkit-caddy-demo"
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"
verify_url = "http://127.0.0.1#{site_address}"
public_url = "http://#{target.host}#{site_address}"
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
"""
use HK.DSL, providers: [Caddy]
project =
project :deploy_caddy_site do
host :target, at: target.host do
ssh Keyword.merge(target.ssh, user: target.user, sudo: target.sudo)
end
provider :caddy, Caddy do
set :sites_dir, caddy_sites_dir
end
service :hello_site do
package :caddy, as: "caddy"
package :curl, as: "curl"
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 unit: caddy_service_name do
description "HostKit demo Caddy site"
exec ["/usr/bin/caddy", "run", "--config", caddy_config_path]
restart :on_failure
end
caddy_site 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
project
Plan
Preview the changes. Nothing has been applied yet.
host = hd(project.hosts)
target_opts = HostKit.Host.target_opts(host)
{:ok, plan} = HK.plan(project, target_opts)
Demo.plan_summary(plan)
Demo.plan_table(plan)
Audit and facts
The runtime API can audit the desired state and collect bounded host facts without applying changes.
{:ok, audit_plan} = HostKit.Project.audit(project, target_opts)
{:ok, facts} = HostKit.Facts.collect(target_opts, only: [:os, :systemd, :ports])
[
Kino.Markdown.new("""
**Audit:** #{length(audit_plan.resources)} managed resources, #{Enum.count(audit_plan.changes, &(&1.action != :no_op))} drifted changes
**OS:** #{get_in(facts, [:os, :os_release, "PRETTY_NAME"]) || get_in(facts, [:os, :os_release, "NAME"]) || "unknown"}
**Systemd:** #{get_in(facts, [:systemd, :version]) || "unknown"}
"""),
Demo.plan_table(audit_plan)
]
Inspect structured config resources
HostKit can model natural data files directly as INI/YAML resources. Public keys are compared during drift detection, while redacted/generated secrets stay out of plans.
structured_config_project =
project :structured_config_preview do
roots config: Path.join(System.tmp_dir!(), "hostkit-livebook-config")
service :preview do
ini path(:config, "app.ini"), owner: "root", group: service_user(), mode: 0o640 do
set "APP_NAME", "HostKit demo"
section "server" do
set "HTTP_ADDR", "127.0.0.1"
set "HTTP_PORT", 4000
secret "JWT_SECRET", env: :redacted
end
end
yaml path(:config, "health.yaml"),
content: [
endpoints: [
[name: "demo", url: "http://127.0.0.1:4000/health", conditions: ["[STATUS] == 200"]]
]
]
end
end
{:ok, structured_config_plan} = HK.plan(structured_config_project, reader: HostKit.Local)
Demo.plan_table(structured_config_plan)
Deploy
This is the line that changes the target.
reporter = self()
apply_result = HK.apply(plan, Keyword.merge(target_opts, confirm: true, reporter: reporter))
Kino.nothing()
progress = Demo.collect_apply_progress()
Demo.apply_summary(apply_result, progress)
Demo.apply_table(apply_result)
Verify
Readiness checks in the declaration already restart and wait for the service during apply. Verification can stay simple: fetch the public URL.
response = Req.get!(public_url)
[
Demo.verify_summary(response, public_url),
Kino.HTML.new(response.body)
]