Deploying Phoenix application with Kamal 2

Kamal is a deployment tool designed for deploying containerized applications. It’s specifically optimized for use with docker based applications, simplifying the process of building, deploying and managing applications across multiple servers.

Recently (26 Sept 2024), Kamal 2 was released. This new version, which we are going to use in this tutorial, brings some important improvements over its predecessor, simplifying deployments and adding new features, like the ability to deploy multiple application to a single server.

While Kamal originated the Ruby on Rails ecosystem, it is versatile enough to be used with application that can be containerized with Docker. Its design focus on container orchestration and deployment, meaning that, as long as the application runs in a Docker container, Kamal can deploy it. To demonstrate that, in this tutorial we will be deploying a Phoenix (Elixir) application.

Requirements

To follow along, we will need to meet some requirements: a server where we’ll be deploying our application; the application (phoenix application in our case) to be deployed; and Kamal 2.

For the application, let’s create a new brand new phoenix application. The only requirement for this application, is that it needs to have a route for heath checking. By default this path is `/up`.

Preparing the application

$ elixir --version
Erlang/OTP 27 [erts-15.1] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]

Elixir 1.17.3 (compiled with Erlang/OTP 27)

$ mix phx.new --version
Phoenix installer v1.7.14

$ mix phx.new blogx
* creating blogx/lib/blogx/application.ex
* creating blogx/lib/blogx.ex
...
Fetch and install dependencies? [Yn] Y
* running mix deps.get
...

$ cd blogx
$ mix ecto.create

$ mix phx.gen.html Blog Post posts title:string content:text
$ mix ecto.migrate

We just created a new phoenix application, we setup the database (PostgreSQL) and we generated our blog context and post controller for the Post resource. Before we move on, there is few changes we need to make.

First I added the routes we need for this tutorial. I added the root path, the `/up` path and the post resources paths:

scope "/", BlogxWeb do
  pipe_through :browser

  get "/", PostController, :index
  get "/up", PageController, :health_check

  resources "/posts", PostController
end

I also changed the `PageController`, replacing the `home/2` action by our new `health_check/2` action:

defmodule BlogxWeb.PageController do
  use BlogxWeb, :controller

  def health_check(conn, _params) do
    send_resp(conn, 200, "OK")
  end
end

We this, we can fire up our local server and be able to visit the following paths. We can even interact with our new app creating, edit and deleting posts. So, we just created a basic CRUD for posts.

http://localhost:4000/
http://localhost:4000/up
http://localhost:4000/posts (CRUD)

At this point, we have our application running on localhost. Now we need to prepare it, to be released as a docker image, by assembling the release inside de Docker container. To learn more about releases, please check: https://hexdocs.pm/phoenix/releases.html.

$ mix phx.gen.release --docker

Check the previous command output to se which files were generated. For the purposes of this tutorial, we just need to make two small changes in the Dockerfile. first, where we will be exposing the PORT. So, open the file “/path_to_app/Dockerfile” and at the end, before the last line (CMD [“/app/bin/server”]), add the instruction to expose the port 4000. The end of your Dockerfile will look like this:

# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]

EXPOSE 4000

CMD ["/app/bin/server"]

The other change is also in Dockerfile. In the begging of the file, approx. on line 35, we will find an instruction to set the MIX environment to “prod”. Under this line, put another variable (ENV ERL_FLAGS=”+JPperf true”), like the following image:

Our release is prepared. Now we need to setup Kamal.

Setup the server

Before we continue, we need to setup a server. for that you can use a VPS from some cloud providers like, Hetzner, Linode, DigitalOcean, OVH, etc. but for this tutorial, I’m going to use Multipass to create servers locally.

Multipass is a lightweight virtualization tool created by Canonical that allows users to easily crate, manage and run Ubuntu virtual machines on local system. Multipass works on macOS, Windows and Linux. To learn more, visit: https://multipass.run/docs.

As you can see, we just created and launched a brand new virtual machine, running Ubuntu 24.04. By the way, this VM is using the IP address 192.168.64.7, which we will need to setup Kamal. And for the database we will be using a separate server, where we already have PostgreSQL installed. ITs IP is 192.168.64.6 and the credentials are de defaults one: “postgres” as both username and password. Note that, if you follow this approach, you need to make sure that you setup the Postgresql to accept connection from outside.

Now we just need to login via SSH into our server and make sure its up-to-date.

There one other think you need to do, unless you are using “root” user. In my case, I am using “ubuntu” user. In order to be able to manage docker inside this server, this user must be part of the “docker” group. At this stage this group does not exist, so I will create it and add the user to it.

$ sudo groupadd docker
$ sudo usermod -aG docker ubuntu

Our servers are ready. We have both IPs (192.168.64.7 for the application and 192.168.64.6 for the database). You could run both on the same server, though. I just prefer to separate them.

Also, if you are using Multipass, don’t forget to setup you SSH public key on the server or the password to be able to connect to the server as “ubuntu” user.

Setting up Kamal 2

First we need to install Kamal. There is two ways to install Kamal. If you have a Ruby environment available, you can install kamal like this:

$ gem install kamal

Since we are trying to deploy an Elixir (Phoenix) application, I will assume that we don’t have Ruby environment available. For such situation, the option is to use the dockerized version of Kamal, by creating an alias as follows:

# For macOS users:
alias kamal='docker run -it --rm -v "${PWD}:/workdir" -v "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock" -e SSH_AUTH_SOCK="/run/host-services/ssh-auth.sock" -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/basecamp/kamal:latest'

# For Linux users:
alias kamal='docker run -it --rm -v "${PWD}:/workdir" -v "${SSH_AUTH_SOCK}:/ssh-agent" -v /var/run/docker.sock:/var/run/docker.sock -e "SSH_AUTH_SOCK=/ssh-agent" ghcr.io/basecamp/kamal:latest'

With this, we will have “kamal” command available and we can proceed initializing the configuration by running the following command. Note that this command must be run inside the app directory.

$ kamal init

The previous command will create “.kamal” directory, within a “secrets” file we will use to manage our secrets, and a “config/deploy.yml” file. Lets start modifying this file:

The visible part from the previous image is the only part of the file I had to change. The comments on the file does a good job explains each key in the file. But I would still like to comment about few of them. Under proxy key, we first disabled “ssl”, because I still on local development and its for testing purposes, so I don’t want to deal with SSL certificates at this time; “host” is the domain I will be using to access this app. I am also using a test domain. For that I had to change my “/etc/hosts” file. You could use something else like “dnsmasq” is you prefer. And I also added “app_port”. But default, Kamal 2 expects the container to be exposing port 80. But in our case, phoenix is running on port 4000. So we need to specify this custom port.

The other change I had to do is on “builder” key. As you can see from the previous image, I added “arm64” architecture, since I am using Mac with apple chip, and consequently my VMs are using this architecture.

Next we defined the environment variables that will be available for the container. Starting again with PORT and ENV where our app will be running. The other two environment variables are secret, meaning their values comes from .kamal/secrets. Is there where we will define how Kamal will found this values for KAMAL_REGISTRY_PASSWORD (the token from you your container image registry), SECRET_KEY_BASE and for DATABASE_URL.

Last not least, Kamal expect by default to be deploying using the root user. Normally I prefer to have a separate user for this tasks. In my case, ubuntu user. Remember to setup the SSH key so that you can access the server from your computer without tying the password.

The last thing we need to check and adjust is the “.kamal/secrets” file. From where will will pick the values to the environment variables we just specified in our “config/deploy.yml” file.

As you can see from the comments, there are different ways to read the values. I prefer using the cat command. But for this option, pay double attention to never commit those files. They need always to be ignored both on .gitignore and .dockerignore files.

To generate the SECRET_KEY_BASE, you can run the following command:

$ mix phx.gen.secret

With all this in place. We are ready to run the “kamal setup”. Which will setup our server and deploy our application.

After running “kamal setup” we will get the confirmation that our app was successfully deployed.

Now, we can try to visit our application, using the domain we chose previously. In my case: http://blogx2.test/ . But doing so, I got an error. 🥲

To understand what is going on, we can start checking the logs, by running:

$ kamal app logs 

From the output, it will be easy to figure out what is going on. In our case, we forgot to run the migrations. So, how could we do that?

Turns out, with kamal, we can run commands on the server using “kamal app exec CMD”. and the command we need to run is “./bin/migrate”.

We just run the command and our migration is now applied. Now, we can visit again our application and voilá! its now running. We can even create new post, edit, and do on.

And that is it for today. I hope you liked it. If you find an issue, please let me know so we can figure out together.

,,,,,