Deploy a Phoenix app with HostKit

Copy Markdown View Source

Start with the notebook dependencies. kino provides friendly inputs, req verifies the deployed endpoint, 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 aliases so the rest of the notebook reads like application code.

alias HostKit, as: HK
alias HostKit.{Apply, Host, Ingress, Project}
alias HostKit.Apply.Event, as: ApplyEvent
alias HostKit.Plan.{Artifact, Format}
alias HostKit.Providers.{Caddy, Elixir}
alias HostKit.Resources.Source

This notebook deploys the example Phoenix app from the HostKit repository:

  1. collect target and app settings,
  2. declare the app source/runtime,
  3. build a release on the target,
  4. run it with systemd,
  5. publish it through semantic ingress rendered by Caddy,
  6. optionally apply and verify.

The deployment declaration lives directly in the notebook. The integration test extracts the marked DSL cell below.

Demo settings

Expose a small form for the values people expect to choose during a demo. Advanced SSH values come from environment variables so they are easy to automate in tests and CI.

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_081),
      app_port: Kino.Input.number("Phoenix port", default: 14_000),
      source_ref: Kino.Input.text("Git ref", default: "master"),
      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 once. Every later cell works with ordinary Elixir data.

settings = Kino.Control.await(settings_form)

Turn the form values into an explicit HostKit target. The SSH key path can still come from your shell or Livebook deployment environment.

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 Phoenix app settings separately from the host settings.

app = %{
  public_hostname: "phoenix.example.com",
  app_port: settings.app_port,
  public_port: settings.public_port,
  source_ref: settings.source_ref
}

apply? = settings.apply?
verify? = settings.verify?

Derive target variables that the declaration can use directly.

target_host = target.host
target_user = target.user
target_sudo = target.sudo
ssh_opts = target.ssh

Derive app, runtime, and artifact settings in one place.

public_hostname = app.public_hostname
source_repo = System.get_env("HOSTKIT_PHOENIX_SOURCE_GIT", "https://github.com/elixir-vibe/host_kit.git")
source_ref = app.source_ref
erlang_version = System.get_env("HOSTKIT_PHOENIX_ERLANG", "29.0.2")
elixir_version = System.get_env("HOSTKIT_PHOENIX_ELIXIR", "1.20.1")
app_name = :hello_phoenix
app_port = app.app_port
http_port = app.public_port
ingress_address = ":#{http_port}"
public_url = "http://#{target_host}:#{http_port}"
artifact_path = "/tmp/hostkit-phoenix-demo.plan.json"
secret_key_base = Base.encode64(:crypto.strong_rand_bytes(64))

Derive the Caddy wrapper service paths. HostKit will manage these resources rather than asking the notebook to shell out.

deployment_name = "hostkit-phoenix-demo-#{http_port}"
app_service_name = "hello-phoenix.service"
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"

caddyfile = """
{
  admin off
}

import #{caddy_sites_dir}/*.caddy
"""

Deployment declaration

This is the important part: the app recipe expands to ordinary resources—source checkout, mise runtime, build commands, systemd, readiness, endpoint metadata, and ingress.

# hostkit:deploy-phoenix-app-dsl
alias HostKit.Providers.{Caddy, Elixir}

use HostKit,
  providers: [Caddy, Elixir]

project =
  project :deploy_phoenix_app 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 :phoenix_caddy do
      directory caddy_config_dir, owner: "root", group: "root", mode: 0o755
      directory caddy_sites_dir, owner: "root", group: "root", mode: 0o755

      file caddy_config_path,
        content: caddyfile,
        owner: "root",
        group: "root",
        mode: 0o644

      daemon caddy_service_name do
        description "HostKit Phoenix demo Caddy"
        exec_start ["/usr/bin/caddy", "run", "--config", caddy_config_path]
        restart :on_failure
        wanted_by :multi_user
      end

      ready :phoenix_caddy do
        systemd caddy_service_name, restart: true
        http public_url
      end
    end

    elixir_app app_name,
      source: [
        git: source_repo,
        path: "examples/hello_phoenix",
        ref: source_ref
      ],
      runtime: [
        erlang: erlang_version,
        elixir: elixir_version
      ],
      phoenix: [
        host: public_hostname,
        port: app_port,
        secret_key_base: secret_key_base
      ],
      caddy: [
        host: ingress_address
      ]
  end

project

Plan

Build an inspectable plan for the selected host. This is still a dry declaration-to-plan step; the target has not changed yet.

host = hd(project.hosts)
target_opts = Host.target_opts(host)
{:ok, plan} = HK.plan(project, target_opts)

Render the plan so you can review the exact changes before applying them.

plan |> Format.format() |> Kino.Markdown.new()

Inspect what the recipe generated

Recipes still compile to plain resources. Here we focus on source and ingress resources because they explain where the app comes from and how traffic reaches it.

Project.resources(project)
|> Enum.filter(&(match?(%Source{}, &1) or match?(%Ingress{}, &1)))

Source provenance

HostKit records source identity so plans and artifacts can explain which Git source/ref produced the deployment.

plan.resources
|> Enum.filter(&match?(%Source{}, &1))
|> Enum.map(fn source -> {source.name, Source.identity(source)} end)

Save an artifact

Plans can be saved 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. Livebook can display them without custom callback code.

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 systemd services during apply. Verification can stay focused on the app URL.

if verify? do
  response = Req.get!(public_url)
  {response.status, response.body, public_url}
else
  {:skipped, public_url}
end