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;
- SECRET_KEY_BASE: Run
mix phx.gen.secret
to generate the secret key needed. - KAMAL_REGISTRY_PASSWORD: Open up Github - New Token page, create a new token by selecting the
write:packages
scope. Copy generated token here. - DATABASE_URL: Connection string for the database. Make sure setting protocol as
ecto
, notpostgres
ormysql
. - PHX_HOST: Hostname for production runtime.
Configure deploy.yml
Open up config/deploy.yml
and make following changes:
- service: name of your app
- image:
/
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: