DEV Community

Max Moreno
Max Moreno

Posted on

Deploying a Rails API-Only App with Postgres using Kamal 2

In this article, we'll walk through the steps to deploy a Rails API-only application using Kamal. Whether you're a seasoned developer or someone who's just discovered that "bundle install" isn't a jewelry store promotion, this guide will help you get your app up and running in no time.

Prerequisites

A locally working Ruby on Rails app (rails 7 in this example).
This app, for this example, we won't use redis so if you use action cable, you need to have solid_cable configured. And for jobs, solid_queue (I use both here)
Docker Installed: Kamal uses Docker, so you'll need Docker installed. No, you can't skip this step—Docker is the star of this show!
A Server with SSH Access (DigitalOcean here). Think of it as your app's new home.
A container registry in DigitalOcean.

Configuring Kamal

Add this to your gemfile:

gem 'kamal', require: false
gem 'thruster', require: false
Enter fullscreen mode Exit fullscreen mode

Then

bundle install
kamal init
Enter fullscreen mode Exit fullscreen mode

This will create a couple of files:

  • config/deploy.yml
  • .kamal/secrets

config/deploy.yml should look like this (make sure to update the IPs and the env vars accordingly):

service: your_app_name_api

image: your_user/your_app_name_api

servers:
  web:
    - 199.xxx.xxx.xx
proxy:
  ssl: false
registry:
  server: registry.digitalocean.com
  username: your_user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
    - POSTGRES_PASSWORD
  clear:
    SOLID_QUEUE_IN_PUMA: true
    DB_HOST: your_app_name_api-db

aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole"

volumes:
  - "your_app_name_api_storage:/app/storage"

accessories:
  db:
    image: postgres:17
    host: 199.xxx.xxx.xx
    port: 127.0.0.1:5432:5432
    env:
      clear:
        POSTGRES_USER: your_app_name_api
        POSTGRES_DB: your_app_name_api_production
        POSTGRES_HOST_AUTH_METHOD: trust
      secret:
        - YOUR_APP_NAME_API_DATABASE_PASSWORD
    files:
      - config/init.sql:/docker-entrypoint-initdb.d/setup.sql
    directories:
      - data:/var/lib/postgresql/data

Enter fullscreen mode Exit fullscreen mode

And in .kamal/secrets add:

KAMAL_REGISTRY_PASSWORD=dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
RAILS_MASTER_KEY=$(cat config/master.key)
YOUR_APP_NAME_API_DATABASE_PASSWORD=anythingyouwant
POSTGRES_PASSWORD=anythingyouwant
Enter fullscreen mode Exit fullscreen mode

You will need a couple of additional files. Run this in your terminal:

touch bin/thrust
touch bin/docker-entrypoint
touch Dockerfile
touch config/init.sql
Enter fullscreen mode Exit fullscreen mode

In bin/thrust add:

#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"

load Gem.bin_path("thruster", "thrust")
Enter fullscreen mode Exit fullscreen mode

In bin/docker-entrypoint add:

#!/bin/bash -e

# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
    LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
    export LD_PRELOAD
fi

# If running the rails server then create or migrate existing db
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"
Enter fullscreen mode Exit fullscreen mode

The Dockerfile, since it's an api only app, this will be enough:

# syntax=docker/dockerfile:1
# check=error=true

# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t your_app_name_api .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name your_app_name_api your_app_name_api

# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.3.1
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips libpq-dev postgresql-client && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# 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 pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# 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/


# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

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

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

# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
Enter fullscreen mode Exit fullscreen mode

Finally, in config/init.sql add:

CREATE DATABASE your_app_name_api_production;
CREATE DATABASE your_app_name_api_production_cable;
Enter fullscreen mode Exit fullscreen mode

Next step is to make sure your database.yml matches the deploy configuration:

production:
  primary: &primary_production
    <<: *default
    database: your_app_name_api_production
    username: your_app_name_api
    password: your_app_name_api_password
    host: your_app_name_api-db
  cable:
    <<: *primary_production
    database: your_app_name_api_production_cable
    migrations_paths: db/cable_migrate
Enter fullscreen mode Exit fullscreen mode

You will also need to make sure that config/environments/production.rb has:

  config.assume_ssl = true
  config.force_ssl = true
  config.solid_queue.connects_to = { database: { writing: :production } } # in case you are using solid_queue
Enter fullscreen mode Exit fullscreen mode

If you don't have a commited file in storage/ you will need to add one:

touch storage/.keep
Enter fullscreen mode Exit fullscreen mode

Deploy

Afte commiting all changes with git, we are ready to go:

kamal setup
Enter fullscreen mode Exit fullscreen mode

After a few minutes, you should see your app deployed =)

Top comments (1)

Collapse
 
feleval_app profile image
Feleval App

It worked right first time! thanks!

Having said that, I think secrets are not handled in a 'production grade' manner