# Deploy a Phoenix app with HostKit
Start with the notebook dependencies. `kino` provides friendly inputs, `req` verifies the deployed endpoint, 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 aliases so the rest of the notebook reads like application code.
```elixir
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.
```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_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.
```elixir
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.
```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 Phoenix app settings separately from the host settings.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
# 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.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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.
```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. Livebook can display them without custom callback code.
```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 systemd services during apply. Verification can stay focused on the app URL.
```elixir
if verify? do
response = Req.get!(public_url)
{response.status, response.body, public_url}
else
{:skipped, public_url}
end
```