2024-09-07
Kamal is a fanastic tool for managing deployments across many different machines, but it also works very well as a way to deploy your Rails application to a single server which you can then vertically scale to your heart’s content.
I’ve used the following step-by-step guide well over a dozen times to deliver projects to clients and to launch apps of my own. It takes you from rails new
to an app running alongide Postgres and Redis on a single server, with deployments managed by GitHub Actions.
Let’s get started by creating a new Rails application. Here are the versions of Ruby and Rails I’ll be using:
$ ruby --version
ruby 3.3.0
$ rails --version
Rails 7.2.1
As we want to build a production-ready app that can be horizontally scaled later (if the time comes), we’ll opt for PostgreSQL for the database rather than SQLite (though MySQL will work in much the same way).
$ rails new my_app -d=postgresql
If you already have PostgreSQL and Redis running locally, you’re good to boot up the app with bin/rails server
and move on to Step 2. However, if you’re like me, you might prefer to run these accessories in Docker to make things a bit easier. With Docker installed, getting the accessories up and running is as simple as creating a docker-compose.yml
file in the project root:
services:
postgres:
image: postgres:latest
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:latest
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Now you just need to run docker compose up
to bring the accessories online and update your config/database.yml
to match the credentials you passed to the PostgreSQL image:
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: localhost
username: postgres
password: postgres
(ActionCable should already be setup to use the Redis instance running on localhost in development, you can check in config/cable.yml
)
Then run bin/rails db:create
to create the development and test databases, and start the server with bin/rails s
. If you visit localhost:3000
in your browser now, you should see the Rails welcome screen, which means everything’s connected.
If your Rails app includes Tailwind or a JavaScript build-step, your app will include a Procfile.dev
, to which you can add the following line:
accessories: docker compose up
That way, when you boot the development server with bin/dev
, your accessories will also be booted (providing the Docker engine has been started).
With the app initialised, let’s add some functionality so that we can see the app working when it’s deployed (as the Rails welcome screen won’t show in production):
Create a Pages controller:
$ bin/rails g controller Pages index
Add a welcome message to the generated view:
<h1>Hello, Kamal!</h1>
Update your config/routes.rb
:
root "pages#index"
Now your ready to deploy your app.
(Note: If you’re following along exactly and have implemented static functionality as above, you’ll need to run bin/rails db:migrate
to generate a schema.rb
file, otherwise the db:test:prepare
call will fail in CI.)
Kamal uses SSH to connect to our servers, and as we’ll be using GitHub Actions for deployment, we’ll need to store an SSH key in the Actions Secrets. For this reason, I prefer to generate a new SSH key for each project (just make sure you don’t set a passphrase!):
$ ssh-keygen -C my_app -f ~/.ssh/my_app
Now provision a Ubuntu server with your preferred provider and add the public key you just generated during setup (cat ~/.ssh/my_app.pub
). When the server is ready, it’s probably a good idea to check that you can connect to the server locally with SSH before proceeding:
$ ssh -i ~/.ssh/my_app [email protected]
To get started with Kamal, we need the gem installed globally (gem install kamal
). Then we can run kamal init
in our project directory which will create the deploy.yml
file, a .env
template and the .kamal
directory.
To ensure all our containers can communicate with one another, we’ll use a Docker network (which also has the added benefit of preventing external access to these services and ports). To create the network, we just need to rename the .kamal/hooks/docker-setup.sample
file to .kamal/hooks/docker-setup
. As you can see, this hook creates a Docker network called ‘kamal’:
#!/usr/bin/env ruby
hosts = ENV["KAMAL_HOSTS"].split(",")
hosts.each do |ip|
destination = "root@#{ip}"
puts "Creating a Docker network \"kamal\" on #{destination}"
`ssh #{destination} docker network create kamal`
end
This hook will be executed (providing the ‘.sample’ extension is removed) after Docker has been installed on the server, which will happen when we use the kamal server bootstrap
command shortly.
Now we can create our deploy.yml
file:
service: my-app
image: registry_user/my-app
registry:
server: ghcr.io
username: registry_user
password:
- KAMAL_REGISTRY_PASSWORD
builder:
cache:
type: registry
options: mode=max
servers:
web:
hosts:
- MY.SERVER.IP
options:
network: "kamal"
job:
hosts:
- MY.SERVER.IP
cmd: bundle exec sidekiq
options:
network: "kamal"
env:
clear:
POSTGRES_HOST: "my-app-db"
REDIS_URL: "redis://my-app-redis:6379/0"
secret:
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
accessories:
db:
image: postgres:latest
host: MY.SERVER.IP
env:
clear:
POSTGRES_USER: "my_app"
POSTGRES_DB: "my_app_production"
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
options:
network: "kamal"
redis:
image: redis:latest
host: MY.SERVER.IP
directories:
- data:/data
options:
network: "kamal"
traefik:
options:
network: "kamal"
Note that we’re using the Docker network-ed names when we construct the environment variables for the POSTGRES_HOST and the REDIS_URL. It’s also important to point out that we have to add the “kamal” network to the Traefik container, or else Traefik won’t be able to communicate with our application container and our app won’t work.
Make sure you replace MY.SERVER.IP with the IP of the server you just provisioned, registry_user with your GitHub account name, and my-app (and my_app) with your desired application name.
(You may have noticed that the job container runs a Sidekiq process, if you also want to use Sidekiq, you’ll just have to install the gem by adding it to your Gemfile
and running bundle
.)
In the deploy.yml
file, we’ve asked the image builder to cache intermediate layers (with mode=max
) in our container registry, which will reduce subsequent deployment time by a lot (~5 minutes compared with ~25 minutes).
As we’re not using the DATABASE_URL environment variable to connect to our database in production, we also need to tweak the config/database.yml
file to match our deployment configuration:
production:
<<: *default
host: <%= ENV["POSTGRES_HOST"] %>
database: my_app_production
username: my_app
password: <%= ENV["POSTGRES_PASSWORD"] %>
Lastly, let’s create a db/production.sql
file which Kamal will execute on the database container when it is created:
CREATE DATABASE my_app_production;
And disable forced SSL (for now) in config/environments/production.rb
:
config.force_ssl = false
Now our app is ready to deploy.
As we want our app to build and deploy via GitHub Actions, we won’t use kamal setup
here. Instead we’ll just get our servers ready for deployment by installing Docker and booting the accessories.
So Kamal can find the SSH key necessary to connect to the server, let’s add it to our SSH Agent:
$ ssh-add ~/.ssh/my_app
Then we can get Kamal to install Docker on the server:
$ kamal server bootstrap
Next, update your .env
file with your RAILS_MASTER_KEY (from config/master.key
), your KAMAL_REGISTRY_PASSWORD (if using the GitHub Container Repository, create a personal access token) and a POSTGRES_PASSWORD of you choice:
KAMAL_REGISTRY_PASSWORD=my_personal_access_token
RAILS_MASTER_KEY=my_master_key
POSTGRES_PASSWORD=my_secure_password
Then, sync up the enviroment variables with our server and boot the accessories:
$ kamal env push
$ kamal accessory boot all
We’re almost done with the basic setup of our production-ready deployment. Now all we need to do is add a CD workflow which builds and deploys our app when we push to the main branch (which also includes merging pull requests to main from other branches).
Our CD workflow is really quite simple. It checks out the code, installs Ruby, installs the Kamal gem and then calls the kamal deploy
command:
name: CD
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Install Kamal
run: gem install kamal
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy using Kamal
run: kamal deploy
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
Navigate to your GitHub repo and add the necessary Actions Secrets (Settings -> Security -> Secrets and variables). You can view the SSH key you created with:
$ cat ~/.ssh/my_app
Now, if all has gone according to plan, when you push your changes to GitHub, your app should build and deploy and be visible when you visit your server’s IP in your browser - give it a try!
Now we know our app is running and accessible, let’s make it secure by having Traefik manage SSL certificate provisioning.
Before we do that, we’ll need to register a domain and point it to our server (as Traefik needs to know which domain we’ll be using as a host to set up a router).
Then, we’ll undo the change we made before and force SSL in production and push to main to redeploy the application.
# config/environments/production.rb
config.force_ssl = true
Our Rails app will now attempt to redirect any HTTP traffic to HTTPS, which our Traefik instance doesn’t yet accept, as it hasn’t provisioned a certificate. This means that the app will no longer load, so let’s fix it.
The first thing we need to do is connect to our server and create the file that will be used to store the certificates:
$ ssh -i ~/.ssh/my_app [email protected]
$ mkdir -p /letsencrypt
$ touch /letsencrypt/acme.json
$ chmod 600 /letsencrypt/acme.json
Now we need to edit the deploy.yml
to add labels to our application container and configure Traefik to accept only HTTPS connections. First the application container:
servers:
web:
hosts:
- MY.SERVER.IP
options:
network: "kamal"
labels:
traefik.http.routers.my_app.entrypoints: websecure
traefik.http.routers.my_app.rule: Host(`my.domain.com`)
traefik.http.routers.my_app.tls.certresolver: letsencrypt
And now the Traefik container:
traefik:
options:
network: "kamal"
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
certificatesResolvers.letsencrypt.acme.email: "[email protected]"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
As we have added lables to our application container, we’ll need to redeploy as labels cannot be changed after a container is started.
We’ll also need to reboot Traefik so that our new arguments are passed to that container:
$ kamal traefik reboot
Now you can trigger another deployment by pushing your changes to GitHub. Once the CD Action has completed, you should be able to access your web application at the specified URL (with HTTPS only).
(Note: if you’re using Cloudflare, ensure that your TLS setting is ‘Full’ otherwise Cloudflare will try to access your server via HTTP and your server will deny the connection.)
If and when it comes time to scale your web application, you can do so by throwing more RAM and CPU power at the server the application is already deployed to. Or, you can opt to have multiple servers run your application, which is as simple as adding / changing the IP addresses listed in the deploy.yml
file.
If you do choose to migrate to a multi-server set up, I’d recommend leaving the database on the original server, and that way you don’t have to deal with any database migrations!
This (lengthy) post detailed how you can run your entire infrastructure for a production-ready Rails application on a single low-cost server. In my opinion, this is the way all new Rails projects should be deployed, as scaling things up is so simple (and cost-effective) when the time comes.
In a future post, I’ll talk about how to monitor your Kamal-deployed projects so you can know when an application could use more resources. Thanks for reading!