Powered by AppSignal & Oban Pro

Deploy a Caddy static site with HostKit

notebooks/learn/deploy_caddy_site.livemd

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