Deploy a Caddy static site with HostKit

Copy Markdown View Source

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

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

project

Plan

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_path

Apply

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

HostKit 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