Powered by AppSignal & Oban Pro

Deploy a Phoenix app with HostKit

deploy_phoenix_app.livemd

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