Azer Koçulu
November 7th, 2023

Deploying Phoenix with Kamal

Managing servers on your own can be very time consuming and pull you away from what you really want to do. That's why folks have leaned towards managed services like AWS and GCP – they do the heavy lifting devops-wise, although at a higher cost and with unpredictable pricing.

Kamal by DHH offers a middle ground, simplifying deployment while still giving you control on hardware and cost. It works by deploying your software to a list of IPs, leaving you free to choose the cloud provider that suits your needs best.

For superior hardware and fair pricing, I've migrated two Elixir/Phoenix applications, sway and kaynak, to Hetzner using Kamal. They now enjoy upgraded hardware at a fraction of the former cost.

I hope this blog post will be useful for others who want to deploy their Elixir apps with Kamal.

First steps

Let's install Kamal as a first step and initialize it in your project repository:

$ gem install kamal
$ kamal version # confirm it's installed

Now initialize kamal in your project:

$ kamal init

This should create bunch of files (e.g config/deploy.yml) for configuring kamal in your project.

Docker Image

If the project doesn't have a Dockerfile yet, create one by running:

$ mix phx.gen.release --docker

Builder

If you don't need to compile NPM assets, you can skip to runner image section.

To make sure NPM assets are compiled, make sure NPM is installed in the builder image by adding npm to the apt install line:

RUN apt-get update -y && apt-get install -y build-essential git openssl libncurses5 locales curl npm \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

Add compile command before mix assets.deploy call:

RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

Runner

Make sure curl is installed in the runner image:

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales curl \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

Also in the runner image section, expose port 3000:

EXPOSE 3000

If the project already has Dockerfile, make sure you don't have any leftover instructions. e.g Fly.io instructions

Configure .env

Open .env and add these lines;

SECRET_KEY_BASE=
KAMAL_REGISTRY_PASSWORD=
DATABASE_URL=
PHX_HOST=

And add corresponding values;

Configure deploy.yml

Open up config/deploy.yml and make following changes:

Add IP address of the server that you want to deploy to:

servers:
  web:
    hosts:
      - <ip address>

Registry should be pointed to Github:

registry:
  server: ghcr.io
  username: <github user>

Specify port and secrets in the env variables:

env:
   clear:
     PORT: 3000
   secret:
     - DATABASE_URL
     - SECRET_KEY_BASE
     - PHX_HOST

I build the image in arm64 and also deploy to arm64, so disabled multiarch:

builder:
   multiarch: false

Health-check

Kamal needs an health check endpoint to ensure deploy is whether if successful. I've found this code in Baptiste Chaleil's blog straightforward;

Create health check plugin:

defmodule BlogexWeb.HealthCheckPlug do
  @moduledoc """
  A Plug to return a health check on `/up`
  """

  import Plug.Conn

  @behaviour Plug

  def init(opts), do: opts

  def call(%{path_info: ["up"]} = conn, _opts) do
    conn
    |> send_resp(200, "ok")
    |> halt()
  end

  def call(conn, _opts), do: conn
end

And enable it at lib/myapp_web/endpoint.ex:

  plug BlogexWeb.HealthCheckPlug

Test if it works locally by hitting the /up endpoint:

curl -v localhost:4000/up

Deploy

Bootstrap kamal in the target servers:

$ kamal server bootstrap

Push environment config:

$ kamal env push

And deploy:

$ kamal deploy

SSL

Make sure Let's Encrypt is installed in your servers by;

$ apt install certbot
$ mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json

And add Traefik configuration for SSL by opening config/deploy.yml and adding these lines:

servers:
  web:
    hosts:
      - <ip address>
    labels:
      traefik.http.routers.kiqr_cloud.rule: Host(`kaynak.app`)
      traefik.http.routers.kiqr_cloud_secure.entrypoints: websecure
      traefik.http.routers.kiqr_cloud_secure.rule: Host(`kaynak.app`)
      traefik.http.routers.kiqr_cloud_secure.tls: true
      traefik.http.routers.kiqr_cloud_secure.tls.certresolver: letsencrypt

And, in the later sections of config/deploy.yml:


traefik:
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt/acme.json:/letsencrypt/acme.json"
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    certificatesResolvers.letsencrypt.acme.email: "your email"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web

Open config/runtime.ex and enable SSL:

  config :myapp, MyApp.Repo,
    ssl: true,
    ssl_opts: [
      verify: :verify_none
    ],

Finally, reboot traefik after adding SSL configuration:

$ kamal traefik reboot

Useful Commands

Check logs:

$ kamal app logs

List containers:

$ kamal app containers

Remote Elixir shell:

$ kamal app exec -i --reuse '/app/bin/<app name> remote'

Run migrate command remotely:

$ kamal app exec -i --reuse '/app/bin/<app name> remote'
Sway.Release.migrate

Rollback:

$ kamal rollback [git hash]

See also

Check out these blog posts which were helpful for me: