19
.
03
.
2025
13
.
03
.
2025
Backend

Migration from Heroku using Kamal

Jarosław Kowalewski
Ruby Developer
Jarosław Kowalewski - Migration from Heroku using Kamal

Recently, we started working on an MVP for a client. As with many new projects, we initially set it up on Heroku, which provides an easy-to-use PaaS for deploying and managing applications in the cloud. However, in the long run, Heroku can become quite expensive and, in some cases, challenging to manage. Anticipating future needs, we decided to migrate the application early in its development, especially since, at that stage, it was still relatively simple.

With the release of Rails 8, Kamal has become the default deployment tool for Rails applications, so we chose to leverage it for this migration. Of course, we also needed a server to host our application. Here, DigitalOcean Droplets came to the rescue, as we’ve already been using them for some of our other projects.

Another key decision in this migration was switching our database from PostgreSQL to SQLite. In recent years, SQLite has become a solid choice for small to medium-sized projects that can handle traffic with a single server. Additionally, Heroku’s ephemeral filesystem does not allow modifications to files outside the deployment process, which means SQLite isn’t an option on Heroku - forcing developers to use an external database service.

By making this migration early, we ensured greater flexibility, lower costs, and more control over our infrastructure while still keeping our deployment process simple with Kamal.

Create a DigitalOcean Droplet

The first step is to create a DigitalOcean Droplet. The full guide on setting it up is available here:

🔗 DigitalOcean Droplet Setup Guide

For our needs, we used the basic droplet configuration. If necessary, we can upgrade it later.

During the droplet creation process, we need to add the SSH key of the machine we’ll be deploying from. This allows us to:

  • Securely log in to the virtual machine
  • Properly configure deployment using Kamal

Once the droplet is set up correctly, we should be able to log in from the terminal:

ssh root@<server-ip>

Replacing Database Configuration

Since we’re switching our database from PostgreSQL to SQLite, we need to adjust our application’s configuration accordingly.

  • Replace the pg gem with the sqlite3 gem:
- gem "pg", "~> 1.1"
+ gem "sqlite3", ">= 2.1"
  • Update the database.yml file:
default: &default
  adapter: sqlite3
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  primary:
    <<: *default
    database: storage/development.sqlite3
  cache:
    <<: *default
    database: storage/development_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/development_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/development_cable.sqlite3
    migrations_paths: db/cable_migrate
test:
  <<: *default
  database: storage/test.sqlite3
production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  cache:
    <<: *default
    database: storage/production_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/production_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/production_cable.sqlite3
    migrations_paths: db/cable_migrate

This database.yml file configures the SQLite databases for different environments (development, test, and production) in a multi-database setup.

  • The primary database stores the main application data.
  • Additional databases (cache, queue, and cable) are defined for caching, background jobs, and ActionCable, with separate migration paths.
  • In development and production, all databases are explicitly configured, while test only uses a single database.

For our case, that was it! 🚀 Since we could regenerate our data from seeds, no data migration was necessary.

Of course, if we had to migrate existing data or ensure data continuity, the process would be much more complex - but that’s a topic for another article. 😉

Kamal Configuration

Enter Kamal, our modern deployment tool. In previous Rails solutions, we relied on Capistrano for direct SSH deployments. Capistrano connected to servers via SSH, executed commands, and managed releases directly on the server. However, with Rails 8, Kamal takes a more modern approach, streamlining the process by using Docker to build and push container images to a registry before orchestrating the deployment on the server.

Getting started is simple - just add the kamal gem to the Gemfile. For new Rails projects, it’s already included by default. After that, install dependencies and initialize Kamal using kamal init. In new projects, this initialization happens automatically as part of the project setup.

Now, let’s configure Kamal to get our deployment running. The first step is setting up the config/deploy.yml file.

service: example
image: example/example
servers:
  web:
    hosts:
      - 12.123.123.12
proxy:
  app_port: 3000
  ssl: true
  hosts:
    - example.com
    - www.example.com
  healthcheck:
    interval: 2
registry:
  server: ghcr.io
  username: KAMAL_REGISTRY_USERNAME
  password:
    - KAMAL_REGISTRY_PASSWORD
volumes:
  - "/db_storage:/rails/storage"
builder:
  arch: amd64
env:
  clear:
    WEB_CONCURRENCY: 0
    RAILS_ENV: production
    HOST: example.com
  secret:
    - SECRET_KEY_BASE

This is a Kamal deployment configuration file (deploy.yml), which defines how the application is built, deployed, and managed on the server. Here's a breakdown of its key sections:

Service Configuration

  • service: example – Specifies the name of the application or service being deployed.
  • image: example/example – Defines the Docker image name used for deployment.

Servers

  • web: – Declares a group of servers (in this case, the web server).
  • hosts: – Lists the server IP addresses where the application will be deployed (12.123.123.12).

Volumes

  • The /db_storage:/rails/storage volume binds the local storage directory to the application's storage directory inside the container for persistent data storage.

Proxy Settings

  • app_port: 3000 – Specifies the port on which the application runs inside the container.
  • ssl: true – Indicates that SSL is enabled for secure connections.
  • hosts: – Defines the domain names that will be used (example.com, www.example.com).
  • healthcheck: – Configures a health check interval (2 seconds) to monitor the application's availability.

Registry Configuration

  • server: ghcr.io – Specifies the container registry (in this case, GitHub Container Registry).
  • username: and password: – Use environment variables (KAMAL_REGISTRY_USERNAME and KAMAL_REGISTRY_PASSWORD) to authenticate with the registry.

Build Configuration

  • arch: amd64 – Ensures that the application is built for AMD64 (x86_64) architecture.

Environment Variables

  • clear: – Defines environment variables that are publicly available:
    • WEB_CONCURRENCY: 0 – Controls the number of worker processes (defaulting to automatic scaling).
    • RAILS_ENV: production – Sets the Rails environment to production.
    • HOST: example.com – Defines the application host.
  • secret: – Contains sensitive environment variables that should be securely managed, such as SECRET_KEY_BASE.

Next one is .kamal/secrets :

KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
SECRET_KEY_BASE=$SECRET_KEY_BASE

The .kamal/secrets file stores environment variables that are passed to the deployment process. Instead of storing direct values, we reference existing environment variables. This file should be safe to commit to the repository since and not contain sensitive data directly.

Instead of keeping secrets in .kamal/secrets, we should opt for environment variables managed with dotenv-rails. To use it, simply add it to the Gemfile and run bundle install. Another alternative is Rails credentials, which requires generating a master key and passing it to Kamal.

Here are lines added to .env file :

KAMAL_REGISTRY_USERNAME=username
KAMAL_REGISTRY_PASSWORD=password
SECRET_KEY_BASE=1234

The final step is setting up the Docker image. Since this article focuses primarily on Kamal, we won’t go into the details of Dockerfile configuration. The setup largely depends on the tools and versions required for a specific project.

For our needs, the default Rails-generated Docker configuration was sufficient in 95% of cases, requiring only minor adjustments when necessary.

Deployment time!

With all prerequisites in place, it’s time to deploy.

For the initial setup, run:

dotenv kamal setup

This ensures Kamal is properly configured and verifies that Docker is installed on the VM.

For subsequent deployments, use:

dotenv kamal deploy

The dotenv command is required when using environment variables. If you're relying on Rails credentials, you can omit it.

Now, sit back and enjoy a coffee ☕ - don’t be alarmed by red text in the logs. Unless you see ERROR, most messages are just warnings or general output.

Once deployment completes, your app should be accessible via the server’s IP address. Since we enabled SSL, the final step is updating your DNS records. Simply go to Cloudflare (or your domain provider) and point your domain to the new server IP.

Final thoughts

Moving from Heroku to DigitalOcean with Kamal turned out to be a solid decision. Heroku is great for quick setups, but costs can pile up fast, and you don’t have much control over the infrastructure. With Kamal, we now build, push, and deploy Docker images easily, keeping things lightweight and efficient.

Once we had deploy.yml configured and our secrets management in place, deployments became a breeze. Instead of relying on Heroku’s platform-specific tools, we now have full control over our server while keeping the deployment process just as simple.

In the end, this move gave us more flexibility, lower costs, and a straightforward deployment flow - without losing the ease of use we enjoyed with Heroku. 🚀

Jarosław Kowalewski
Ruby Developer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

Jarosław Kowalewski - Migration from Heroku using Kamal

Migration from Heroku using Kamal

14
.
11
.
2023
Jarosław Kowalewski
Backend
store-vs-store_accessor by Michał Łęcicki

Active Record - store vs store_accessor

14
.
11
.
2023
Michał Łęcicki
Ruby
Ruby on Rails
How to become a Ruby Certified Programmer Title image

How to become a Ruby Certified Programmer

14
.
11
.
2023
Michał Łęcicki
Ruby
Visuality
Vector Search in Ruby - Paweł Strzałkowski

Vector Search in Ruby

17
.
03
.
2024
Paweł Strzałkowski
ChatGPT
Embeddings
Postgresql
Ruby
Ruby on Rails
LLM Embeddings in Ruby - Paweł Strzałkowski

LLM Embeddings in Ruby

17
.
03
.
2024
Paweł Strzałkowski
Ruby
LLM
Embeddings
ChatGPT
Ollama
Handling Errors in Concurrent Ruby, Michał Łęcicki

Handling Errors in Concurrent Ruby

14
.
11
.
2023
Michał Łęcicki
Ruby
Ruby on Rails
Tutorial
Recap of Friendly.rb 2024 conference

Insights and Inspiration from Friendly.rb: A Ruby Conference Recap

02
.
10
.
2024
Kaja Witek
Conferences
Ruby on Rails

Covering indexes - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Postgresql
Backend
Ula Sołogub - SQL Injection in Ruby on Rails

The Deadly Sins in RoR security - SQL Injection

14
.
11
.
2023
Urszula Sołogub
Backend
Ruby on Rails
Software
Michal - Highlights from Ruby Unconf 2024

Highlights from Ruby Unconf 2024

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality
Cezary Kłos - Optimizing Cloud Infrastructure by $40 000 Annually

Optimizing Cloud Infrastructure by $40 000 Annually

14
.
11
.
2023
Cezary Kłos
Backend
Ruby on Rails

Smooth Concurrent Updates with Hotwire Stimulus

14
.
11
.
2023
Michał Łęcicki
Hotwire
Ruby on Rails
Software
Tutorial

Freelancers vs Software house

02
.
10
.
2024
Michał Krochecki
Visuality
Business

Table partitioning in Rails, part 2 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
Ruby on Rails

N+1 in Ruby on Rails

14
.
11
.
2023
Katarzyna Melon-Markowska
Ruby on Rails
Ruby
Backend

Turbo Streams and current user

29
.
11
.
2023
Mateusz Bilski
Hotwire
Ruby on Rails
Backend
Frontend

Showing progress of background jobs with Turbo

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Ruby
Hotwire
Frontend
Backend

Table partitioning in Rails, part 1 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
Ruby on Rails

Indexing partitioned table - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql

Table partitioning types - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
SQL Views in Ruby on Rails

SQL views in Ruby on Rails

14
.
11
.
2023
Jan Grela
Backend
Ruby
Ruby on Rails
Postgresql
Design your bathroom in React

Design your bathroom in React

14
.
11
.
2023
Bartosz Bazański
Frontend
React