Deploy a Phoenix app 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
alias HostKit.Providers.Elixir, as: ElixirProvider
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.phoenix_target_form(
server: "127.0.0.1",
user: "root",
password: "hostkit-demo",
ssh_port: 2222,
public_hostname: "phoenix.example.com",
public_port: 18_081,
app_port: 14_000
)
settings = Demo.await_target(form)
target = %{
host: settings.server,
user: settings.user,
sudo: true,
ssh: Demo.ssh_opts(settings)
}
app = %{
public_hostname: settings.public_hostname,
app_port: settings.app_port,
public_port: settings.public_port,
source_repo: Map.get(settings, :source_repo, "https://github.com/elixir-vibe/host_kit.git"),
git_ref: Map.get(settings, :git_ref, "master"),
package_repo: Map.get(settings, :package_repo, "ubuntu_24_04"),
erlang_version: Map.get(settings, :erlang_version, "29.0.2"),
elixir_version: Map.get(settings, :elixir_version, "1.20.1")
}
Declare
public_hostname = app.public_hostname
source_repo = app.source_repo
git_ref = app.git_ref
package_repo = app.package_repo
erlang_version = app.erlang_version
elixir_version = app.elixir_version
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}"
package_lock_path = "/tmp/hostkit-beam.package.lock"
secret_key_base = Base.encode64(:crypto.strong_rand_bytes(64))
package_lock = ~S'''
{
"version": 1,
"target": null,
"packages": {
"autoconf": "autoconf",
"ca_certificates": "ca-certificates",
"curl": "curl",
"cxx_compiler": "g++",
"gcc": "gcc",
"git": "git",
"m4": "m4",
"make": "make",
"ncurses_dev": "libncurses-dev",
"openssl_dev": "libssl-dev",
"perl": "perl",
"unzip": "unzip",
"xsltproc": "xsltproc"
}
}
'''
File.write!(package_lock_path, package_lock)
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
"""
The app recipe expands to source checkout, BEAM runtime, systemd, readiness, and Caddy ingress.
use HK,
providers: [Caddy, ElixirProvider]
project =
project :deploy_phoenix_app 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 :phoenix_caddy do
package :curl, as: "curl"
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 unit: caddy_service_name do
description "HostKit Phoenix demo Caddy"
exec ["/usr/bin/caddy", "run", "--config", caddy_config_path]
restart :on_failure
end
ready :phoenix_caddy do
systemd caddy_service_name, restart: true
end
end
elixir_app app_name,
source: [
git: source_repo,
path: "examples/hello_phoenix",
ref: git_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
]
service :phoenix_public do
ready :phoenix_public do
systemd caddy_service_name, restart: true
http public_url
end
end
end
project
Plan
Preview the changes. Nothing has been applied yet.
host = hd(project.hosts)
target_opts =
host
|> HostKit.Host.target_opts()
|> Keyword.put(:package_repo, package_repo)
|> Keyword.put(:package_lock, package_lock_path)
{:ok, plan} = HK.plan(project, target_opts)
Demo.plan_summary(plan)
Demo.plan_table(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 systemd services during apply. Verification can stay focused on the app URL.
response = Req.get!(public_url)
[
Demo.verify_summary(response, public_url),
Kino.HTML.new(response.body)
]