Deploying a Rails app with Kamal
Tags: rails, kamal
Published: 2023-09-26 11:52:05

## What is Kamal?

You probably already heard about [kamal](https://kamal.dev/), a new tool from DHH to deploy Rails apps with docker containers. It is pretty similar to Capistrano, with the difference of using containers, so preparing the servers is less effort and you don't really need to know much in that area to be able to deploy a Rails app.

In this tutorial, I will show you how to
- deploy a Rails app to a VPS
- get automatic SSL certificates with Traefik
- use a hosted database server
- run Redis on the same droplet
- run a worker to process background jobs


You need to point your DNS records to the IP address of the droplet, and they will probably propagate by the time you finish your first deployment.

### Setup Kamal

To set up kamal in your app, you need to install the gem. It doesn't have to be in the Gemfile, just needs to be available in your terminal, so you can just run `gem install kamal`. Once that's done, you need to call `kamal init --bundle` inside your Rails app:
```
[~/git/kamal-example] +(main) kamal init --bundle

Created configuration file in config/deploy.yml
Created .env file
Created sample hooks in .kamal/hooks
Adding MRSK to Gemfile and bundle...
  INFO [55b3727e] Running /usr/bin/env bundle add kamal as gregmolnar@localhost
  INFO [55b3727e] Finished in 3.111 seconds with exit status 0 (successful).
  INFO [12808c1a] Running /usr/bin/env bundle binstubs kamal as gregmolnar@localhost
  INFO [12808c1a] Finished in 0.120 seconds with exit status 0 (successful).
Created binstub file in bin/kamal
```
The first thing I recommend to do is to gitignore the `.env` file, so you are not committing your secrets accidentally.<br />
If you don't already have access to a container registry, now is the time to sign up for one. Some hosting providers, like Digital Ocean offer one with a free tier.<br />
After that, let's open the `.env` file and add your container registry credentials and your Rails master key.

If you generated your Rails app with 7.1+, you already have a Dockerfile, but if you are on an older version, you need to create one, and it needs to look like this:

```bash
# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config curl

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile


# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile


# Final stage for app image
FROM base

# Install packages needed for deployment
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y libvips postgresql-client curl && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Copy built artifacts: gems, application
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN useradd rails --home /rails --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER rails:rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
```

This Dockerfile configures the base image, the working directory, and the environment variables. It installs the necessary apt packages(make sure `curl` is added since it was not in the default Dockefile generated by Rails at the beginning), bundles the gems, copies over the application code, precompiles the assets, creates a non-root user to own the files and run the Rails process, sets the entry point and exposes port 3000.
In the default Rails Dockerfile, there is also a command to start the Rails server, but since we will also have a worker, we will run different commands in the containers and set those in the kamal config file.

And that is the next one you need to modify. This is what you will end up with. I will break it down below:

```yaml
# 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:
    - MRSK_REGISTRY_PASSWORD

  # Always use an access token rather than real password when possible.
  password:
    - MRSK_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
# Configure builder setup.
# builder:
#   args:
#     RUBY_VERSION: 3.2.0
#   secrets:
#     - GITHUB_TOKEN
#   remote:
#     arch: amd64
#     host: ssh://app@192.168.0.1
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
#   args:
#     accesslog: true
#     accesslog.format: json
# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
#   path: /healthz
#   port: 4000
```
In this file, you need to specify the name of your app and the image you want to use. Then you can configure servers. In this example, there is a "web" server and a "job" server. They are both running on the same host, so let's make docker to put them on the internal network so they can access each other.

On the web server, kamal will start a Rails server, and on the job server it will start a Sidekiq process.

For the web server, we configure the traefik router for Let's Encrypt.

Then we configure the registry access details, and then we set the environment variables we need. `REDIS_URL` is not a secret, so we can just enter it directly. The rest will be pulled from the `.env` file.

The next step is to configure our builder. If you are on a Linux machine and deploying to Linux servers with the same architecture(like me), you can disable multiarch like in the above example to speed up the build process.

The next section configures the "accessories". An accessory can be a database server, memcached, etc., or Redis in this case. As for the database, let's use a hosted one by your provider(like Digital Ocean). For Redis, you will use the same VM. <br/>
You need to configure the image, set the role you want to put it on, forward the ports to the host, and mount a volume.

And the final part of this config file sets Traefik to listen on port 80 and 443 and configures Let's Encrypt. You need to adapt the above config with your domain name and email. You also need to SSH to your server and create the acme.json file with the correct permissions:

```
mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json
```

One final step is to turn on a firewall, preferably at your provider, and block everything except port 80 and 443, so Treafik and Redis are not accessible from the internet.

Everything is configured now, and it is time to build the server. For that, you need to call `bin/kamal setup` and wait patiently until your first build completes and deploys. Afterward, you can just run `bin/kamal deploy` to deploy a new build.

Once your app is deployed and your DNS records are propagated, you can access your newly deployed application.

Here are a few handy kamal commands:

- If you want to start a Rails console:
  `kamal app exec -i "bin/rails c"`
- If you want to see your logs:
  `kamal app logs -f`
- If you have a stuck lock file, ssh to the host and delete the `kamal_lock` folder
- `kamal --help` lists all the available commands

I hope this article helps to get your feet wet with kamal.