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 thesqlite3
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
, andcable
) 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:
andpassword:
– Use environment variables (KAMAL_REGISTRY_USERNAME
andKAMAL_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 asSECRET_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. 🚀