Upgrading to Kamal 2

08 Oct 2024
Are you eager to elevate your security skills and safeguard your applications against cyber threats? I created a Rails Security course is designed specifically for developers like you who aim to build robust, secure Rails applications!
Buy my course: Security for Rails Developers.

Kamal 2 was released recently and it brings a few singnificant changes. Traefik is replaced by kamal-proxy, Kamal runs all containers in a custom Docker network and secrets are passed differently to new containers. All these changes mean that the upgrade is not simple, but in this article I will walk you through an example to help with the process.

To make sure you can roll back if needed, it is advised to upgrade to Kamal 1.9 first, and make sure you app deploys with that version. It is a simple process, you just need to install the gem:

gem install kamal --version 1.9.0

And run kamal deploy to deploy the app.

I wrote an article about deploying a Rails app with Kamal before and we will use the same deploy.yml file as a starting point:

# Name of your application. Used to uniquely configure containers.
service: my_awesome_app
# Name of the container image.

image: container_registry/my_awesome_app

# Deploy to these servers.
servers:
  web:
    hosts:
      - 111.11.111.11
    labels:
      traefik.http.routers.domain.rule: Host(`yourdomain.com`)
      traefik.http.routers.domain.entrypoints: websecure
      traefik.http.routers.domain.tls.certresolver: letsencrypt
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "./bin/rails server"
  job:
    hosts:
      - 111.11.111.11
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "bundle exec sidekiq -C config/sidekiq.yml -v"

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: registry.digitalocean.com
  username:
    - KAMAL_REGISTRY_PASSWORD

  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
env:
  clear:
    REDIS_URL: "redis://host.docker.internal:36379/0"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD
    - SMTP_USER
    - SMTP_PASSWORD
    - DO_BUCKET_KEY
    - DO_BUCKET_SECRET
    - DO_BUCKET
    - SIDEKIQ_USERNAME
    - SIDEKIQ_PASSWORD
builder:
  multiarch: false
# Use accessory services (secrets come from .env).
accessories:
  redis:
    image: redis:latest
    roles:
      - web
    port: "36379:6379"
    volumes:
      - /var/lib/redis:/data
# Configure custom arguments for Traefik
traefik:
  # host_port: 8080
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt/acme.json:/letsencrypt/acme.json"
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    entryPoints.web.http.redirections.entryPoint.to: websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https
    entryPoints.web.http.redirections.entrypoint.permanent: true
    entrypoints.websecure.http.tls: true
    entrypoints.websecure.http.tls.domains[0].main: "yourdomain.com"
    certificatesResolvers.letsencrypt.acme.email: "youremail@domain.com"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web

The above config will deploy a Rails app to a VPS, get an SSL certificate with Traefik, use a managed database, run Redis as an accessory and a worker to process jobs.

Let’s upgrade this to Kamal 2. The first step is to move the secrets to the new location in .kamal/secrets. If you don’t use the secrets from your .env file anywhere else, you can just move the file to the new location:

mkdir .kamal && mv .env .kamal/secrets

If you use the .env file somewhere else, you can just reference the environment variables in the new secrets file, or use command substitution to get the values:

# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)

To keep this example simple, we just copy the .env file to .kamal/secrets.

To make sure you don't commit secrets to your git repository accidentally, add `.kamal/secrets` to your `.gitignore` file!

Once the secrets are sorted, we can start to change the configuration file. First thing, we can drop the Traefik related config:

# Name of your application. Used to uniquely configure containers.
service: my_awesome_app
# Name of the container image.

image: container_registry/my_awesome_app

# Deploy to these servers.
servers:
  web:
    hosts:
      - 111.11.111.11
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "./bin/rails server"
  job:
    hosts:
      - 111.11.111.11
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "bundle exec sidekiq -C config/sidekiq.yml -v"

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: registry.digitalocean.com
  username:
    - KAMAL_REGISTRY_PASSWORD

  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
env:
  clear:
    REDIS_URL: "redis://host.docker.internal:36379/0"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD
    - SMTP_USER
    - SMTP_PASSWORD
    - DO_BUCKET_KEY
    - DO_BUCKET_SECRET
    - DO_BUCKET
    - SIDEKIQ_USERNAME
    - SIDEKIQ_PASSWORD
builder:
  multiarch: false
# Use accessory services (secrets come from .env).
accessories:
  redis:
    image: redis:latest
    roles:
      - web
    port: "36379:6379"
    volumes:
      - /var/lib/redis:/data

The builder configuration changed, so we need to adapt that to the new structure:

...
builder:
  arch: amd64
...

The next part we need to configure is the proxy. We will tell it to use SSL, the hostname of the application, the port the app is running on and the healthcheck path, interval and timeout:

proxy:
  ssl: true
  host: yourdomain.com
  app_port: 3000
  healthcheck:
    path: /up
    interval: 3
    timeout: 30

As I mentioned earlier the networking also changed, so the final change is to cleanup that part of the config and we will end up with the following config file:

# Name of your application. Used to uniquely configure containers.
service: my_awesome_app
# Name of the container image.

image: container_registry/my_awesome_app

# Deploy to these servers.
servers:
  web:
    hosts:
      - 111.11.111.11
    cmd: "./bin/rails server"
  job:
    hosts:
      - 111.11.111.11
    cmd: "bundle exec sidekiq -C config/sidekiq.yml -v"

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: registry.digitalocean.com
  username:
    - KAMAL_REGISTRY_PASSWORD

  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
env:
  clear:
    REDIS_URL: "redis://my_awesome_app-redis:6379/0"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD
    - SMTP_USER
    - SMTP_PASSWORD
    - DO_BUCKET_KEY
    - DO_BUCKET_SECRET
    - DO_BUCKET
    - SIDEKIQ_USERNAME
    - SIDEKIQ_PASSWORD
builder:
  arch: amd64
# Use accessory services (secrets come from .env).
accessories:
  redis:
    image: redis:latest
    roles:
      - web
    volumes:
      - /var/lib/redis:/data

To verify the config file, you can run the kamal config command and if that doesn’t give you any errors, you can run the kamal upgrade command and if that’s successful, you can run kamal accessory redis reboot to restart the Redis accessory.

Now you just need to make sure everything in your app works as expected and you can give yourself a high five!

Or follow me on Twitter

Related posts