# Deploy a Caddy static site with HostKit
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.
```elixir
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.
```elixir
alias HostKit, as: HK
alias HostKit.{Apply, Host, Project}
alias HostKit.Apply.Event, as: ApplyEvent
alias HostKit.Plan.{Artifact, Format}
alias HostKit.Providers.Caddy
```
This notebook is a small, readable HostKit demo:
1. collect target and site settings,
2. declare a target and a static site,
3. inspect the plan,
4. optionally apply it,
5. 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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
# 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
project
```
## Plan
Build an inspectable plan for the selected host. Nothing has been changed on the target yet.
```elixir
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.
```elixir
plan |> Format.format() |> Kino.Markdown.new()
```
## Inspect generated resources
The DSL compiles to plain structs. You can inspect the resources before applying them.
```elixir
Project.resources(project)
```
## Save an artifact
Plans can be saved as artifacts for review, CI, or later rollback workflows.
```elixir
:ok = Artifact.save(artifact_path, plan)
artifact_path
```
## Apply
Applying is explicit. Set `Apply now` in the settings form when you are ready.
```elixir
reporter = self()
apply_result =
if apply? do
HK.apply(plan, Keyword.merge(target_opts, confirm: true, reporter: reporter))
else
{:skipped, :set_apply_to_true}
end
```
HostKit sends progress events to the notebook process, so the UI does not need callback plumbing.
```elixir
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.
```elixir
if verify? do
response = Req.get!(public_url)
{response.status, response.body, public_url}
else
{:skipped, public_url}
end
```