devscoach.

Kamal + Hetzner = Joy

2024-04-01

rails

kamal

hetzner

After so much frustration deploying with Fly.io, Render, Vercel, and those ilk. I decided to give the new basecamp tool Kamal a try. I love running my own infra if possible for the low cost, ease of debugging, and just general control over my code.

This is an extremely simple Ruby on Rails application that isn't much more than a static site. In other posts I will go over deploying with postgres, redis, and everything else you might need for a more complicated setup.

We will also be using Hetzner Cloud because it is cheap and easy to get setup. I will not be covering how to setup a server there because it is straightforward.

Kamal Overview

Kamal uses agentless ssh (a-la ansible) to deploy onto servers. Then with a combination of Docker (re-purposing Docker Compose syntax) and Traefik it runs your application on one or more servers.

This makes it simple to get setup without having to do any manual installation on the servers -- except a tiny bit for auto SSL. If you can ssh to the server you can deploy there. The first time you setup Kamal it will install Docker and cURL which is all it needs to run.

Setup

Install is a quick and easy gem install kamal. From there you can run kamal init.

kamal init does a few things.

  • One creates a config/deploy.yml file which holds all the relevant configuration for our deploy.
  • Two creates a .kamal/ directory which holds some things like hooks -- allowing you to send off notifications about deployments, etc.
  • Three creates a .env file which is used for sending secrets off to your deployment servers. Don't commit to git.

Rails Deployment

For a minimal setup, all we need is a single IP address of the server we want to deploy to, and a docker repo (the default is docker hub, but I use AWS ECR). Let's walk through the basic yaml. We will add auto-generating SSL certs later.

# Container name
service: my-example-app

# Docker image to use
# Kamal uses the git hash as the image tag
image: my-example-app

# The servers to deploy to.
# You can define multiple services here.
# `web` is the default to serve traffic
servers:
  web:
    hosts:
      - 1.1.1.1

# Use aws registry
# You can use erb here to run bash commands to get
# a token for push/pulling
registry:
  server: <account_id>.dkr.ecr.us-west-2.amazonaws.com
  username: AWS
  password: <%= %x(aws ecr get-login-password --region us-west-2) %>

# Inject these variables into the docker container
# at runtime. The secrets are pulled from the `.env` file
# in the root of your app.
env:
  secret:
    - RAILS_MASTER_KEY

The comments should hopefully be straight-forward. But essentially what it does is build a docker image with your current directory, uploads it the docker registry of your choice (here AWS), and then runs it on the servers of your choosing.

Once you have the deploy.yml updated with your configuration you can run kamal setup to run the initial setup on all of the servers listed. This will run an initial deploy as well. In subsequent invocations you only need to run kamal deploy.

SSL Setup

Now this is fine for testing, but the SSL certificate that Traefik uses by default is self-signed. This is okay if you are going to put it behind a load balancer which terminates SSL for you. But for this I just want everything to be self-contained on the server.

Luckily its fairly simple since Traefik supports Let's Encrypt. Traefik is interesting because it can do all of it's configuration via Docker labels. So we only need to add a few labels and we can get it to generate a custom cert for us.

Let's look at the update yaml.

#...trunacated

servers:
  web:
    hosts:
      - 1.1.1.1
    labels:
      traefik.http.routers.example_app.rule: Host(`oursite.com`)
      traefik.http.routers.example_app_secure.entrypoints: websecure
      traefik.http.routers.example_app_secure.rule: Host(`oursite.com`)
      traefik.http.routers.example_app_secure.tls: true
      traefik.http.routers.example_app_secure.tls.certresolver: letsencrypt

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: "ssl@example.com"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web

#...truncated

This configuration will setup certificate with Let's Encrypt with the values provided. You will need to update the email. Additionally, you will need to run mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json on your server for this to work first (see GitHub discussion for reference).

Since we are updating the Traefik configuration we need to make sure to reboot in on the next deploy. To get this chunk deployed run kamal deploy && kamal traefik reboot.

If you have your DNS pointing correctly to your server, you should be seeing your site up and running! For me Kamal is the perfect tool for deployments, easy to understand, simple to execute, and has the ability to scale as your needs do.

Later on we will look at a full web app deployment using Kamal. Email me with any questions - sam@devscoach.com

Learn more

If you are looking to learn more about Kamal, check out the great book by Josef Strzibny. (Affiliate link)