πŸ’‘

Ditch Heroku and deploy Rails with Kamal, PostgreSQL & SSL

Ruby on Rails DevOps Kamal

Heroku is no longer your default hosting solution for Rails apps, thanks to Kamal, and that's great news for your wallet. Follow this 20 minutes comprehensive guide to set up a private server on Hetzner and configure your deploys with Kamal, Docker and PostgreSQL, including SSL encryption!

Heroku has been my go-to solution to host Ruby on Rails applications over the past years. Set up is extremely easy and fast. Its deployment process is unbeatable, allowing you to push your code directly from your Git repository. Documentation is very well written for the most part, customer support has always been good to me. Server management, patching, and scaling is completely taken care of. Pre-configured settings for Rails work like a charm, 99% of gems work flawlessly on Heroku.

However, some major drawbacks have also been painful for me. Constant price increases and separate selling of database usage. Image storing can never be done on the same server. It is very difficult to migrate your application away from Heroku due to its unique infrastructure.

All in all, Heroku's do-everything-for-you approach is always going to be more expensive than DIY solutions.

That's why today we're deploying with Kamal on Hetzner πŸŽ‰. Just to give you an idea, if you use the most basic and cheap Heroku set up for Rails (web dyno + an eco monthly subscription fee + a postgresql:mini database add-on); this setup will bring down your monthly bill from 17$ to 5$.

Here's what we'll do in about 20 minutes

  1. Create a private server on Hetzer Cloud that will replace Heroku
  2. Install Docker on your machine and create an account on Docker hub, because Kamal needs Docker to deploy apps
  3. Set up your Rails application for deployment with Kamal + PostgreSQL (note that most Kamal tutorials are not PostgreSQL compatible!)
  4. Configure Traefik (Kamal's reverse proxy tool) for SSL and HTTPS
  5. Prepare your DNS on your domain registrar
  6. Send your configuration to your server
  7. Start deploying your app's changes and using Kamal 🏁

Note : this tutorial assumes that you already have purchased a domain to make your server easily reachable and that your app uses Rails > 6.0 + a PostgreSQL database

1. Create a private server on Hetzer Cloud

  • Login to Hetzner Cloud and create a new "Project"

  • After a project is created, add a server to that project by clicking the "Add Server"

  • You can follow the following configuration : Your desired location (impacts the price) + Ubuntu + shared vCPU + CPX11 + IPv6 and IPv4 (both)

hetzner config

  • One more thing: Hetzner needs you to copy paste an SSH key to connect to your server from your machine. You copy that key from your terminal with the following command:
pbcopy < ~/.ssh/id_rsa.pub
  • Make sure that Hetzner approves of the key formatting :

hetzner key

  • Done. That's all the settings you need on your new server. Confirm by clicking on "Create and Buy Now"

  • After a few seconds, the creation of the server should be confirmed in green with an IPv4 address next to it.

server ready

  • Check if you can SSH to the created server by doing the following command, you should get the response seen below :
ssh root@<ipv4_address_of_the_server>

hetzner ssh

2. Install Docker on your machine and setup Docker Hub's API Key

  1. This is straightforward : go to Docker Desktop's download page, download the latest version and install it.

  2. Launch Docker Desktop, you will see a Docker icon in your menu bar.

  3. In your terminal, run the following command to check that Docker is installed correctly: docker --version

  4. Now we need to create a Docker registry, this will allow us to store and share Docker images with Hetzner via Kamal. Go to Docker Hub and create an account if you haven't one. A Docker image is used to create a container in which our app will live! Kamal will build, push and pull the Docker image automatically, that is why it needs your credentials to connect to Docker's registry platform.
    Remember: with Kamal you don't have to build the Docker image and publish it yourself.

  5. Click on your profile icon in the upper right corner and select "My Account" from the drop-down menu

  6. In the "Security" section of your account settings, click on "New Access Token"

    docker hub token

  7. In the form, enter the name of your app for the access token and grant "Read, Write, Delete" permissions and click on "Create" to generate the access token. The token will be displayed on the screen, copy the access token and save it in a secure location. This token is very important as it will be used later in this tutorial as the value for KAMAL_REGISTRY_PASSWORD.

  8. In your Docker Desktop app, make sure that you are logged in with your Docker account and the app shows "Engine running" in the bottom left corner. Remember: Docker should always be running during Kamal's setup or deploy commands.

docker

3. Set up your Rails application for deployment with Kamal + PostgreSQL

  1. Now navigate to the root directory of your Rails app and make sure you have a Dockerfile present because Kamal uses a docker image for deployment. Note that if you're creating a Rails project on 7.1 or greater, it is already included. Make sure that RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile. Here's the Dockerfile I use.

  2. If you don’t have an /up health check route in your Rails app (necessary for Kamal's defaults healthchecks), you can add this following code to your config/routes.rb file. Be careful though, the /up route should be above any other catch all route:

      get '/up', to: ->(env) { [204, {}, ['']] }
    
  3. Time to install Kamal using bundle install kamal and initialize Kamal with kamal init. This command will create a bunch of files in your app folder like .env, config/deploy.yml .

  4. Very important: modify the .env file. This file should regroup:

  • Docker Registry's' password (mentioned above)
  • Rails' master key, it will be used to decrypt Rails credentials. Rails' master key can be found in the config folder of your Rails app. If it's not there, you can generate one by running EDITOR='code --wait' rails credentials:edit (if you're using VSCode like me).
  • Postrgre's password, as it will be needed to access your PostgreSQL database. This password can be anything you want. With these 3 things, your .env file should look like this:
KAMAL_REGISTRY_PASSWORD=f9eC7F7q5Up97pnnj37YMQV
RAILS_MASTER_KEY=3T4iMG2mF2Wszx4cU8Eg49a
POSTGRES_PASSWORD=10987654321

5. Change config/database.yml so that it picks up the correct environment variables

# Before
production:
  <<: *default
  database: kamal_example_production
  username: kamal_example
  password: <%= ENV["POSTGRES_PASSWORD"] %>

# After
production:
  <<: *default
  database: kamal_example_production
  username: kamal_example
  password: <%= ENV["POSTGRES_PASSWORD"] %>
  host: <%= ENV["DB_HOST"] %>

6. Finally we need to edit the config/deploy.yml file

This file is crucial as it manages Kamal's deployment configuration for our Rails application, sets up Traefik to automatically generate SSL certificates, and ensures the persistence of a PostgreSQL database in production.

   # Name of your application. Used to uniquely configure containers.
   service: kamal_example

   # Name of the container image.
   image: manufarez/kamal_example

   # Deploy to these servers.
   servers:
     web:
       hosts:
         - <ipv4_address_of_Hetzner_server>
       labels:
         traefik.http.routers.maildown-web.rule: Host(`kamal_example.com`)
         traefik.http.routers.maildown-web.entrypoints: websecure
         traefik.http.routers.maildown-web.tls.certresolver: letsencrypt

   # Credentials for your image host.
   registry:
     username: kamal_example

     # Always use an access token rather than real password when possible.
     password:
       - KAMAL_REGISTRY_PASSWORD

   # Inject ENV variables into containers (secrets come from .env).
   # Remember to run `kamal env push` after making changes!
   env:
     clear:
       DB_HOST: <ipv4_address_of_Hetzner_server>
     secret:
       - RAILS_MASTER_KEY
       - POSTGRES_PASSWORD

   # Configure builder setup.
   builder:
     args:
       RUBY_VERSION: 3.2.2
     remote:
       arch: amd64

   # Use accessory services (secrets come from .env).
   accessories:
     db:
       image: postgres:15
       host: <ipv4_address_of_Hetzner_server>
       port: 5432
       env:
         clear:
           POSTGRES_USER: "kamal_example"
           POSTGRES_DB: "kamal_example_production"
         secret:
           - POSTGRES_PASSWORD
       files:
         - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
       directories:
         - data:/var/lib/postgresql/data

   # Configure custom arguments for Traefik
   traefik:
     options:
       publish:
         - "443:443"
       volume:
         - "/letsencrypt/acme.json:/letsencrypt/acme.json" # To save the configuration file.
     args:
       entryPoints.web.address: ":80"
       entryPoints.websecure.address: ":443"
       entryPoints.web.http.redirections.entryPoint.to: websecure # We want to force https
       entryPoints.web.http.redirections.entryPoint.scheme: https
       entryPoints.web.http.redirections.entrypoint.permanent: true
       certificatesResolvers.letsencrypt.acme.httpchallenge: true
       certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web # Must match the role in `servers`
       certificatesResolvers.letsencrypt.acme.email: "[email protected]"
       certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json" # Must match the path in `volume`

Some details about the deploy.yml file above:

  • Here manufarez is my Docker username and kamal_example is the name of the Rails application I'm deploying to Hetzner, you need to change that, and insert your Hetzner public IP address everytime ipv4_address_of_Hetzner_server is mentioned
  • Make sure that your ruby version is the same as the one in your Dockerfile and Gemfile
  • If you do not use SSL encryption, set force_ssl=false in config/production.rb
  • Kamal automatically references KAMAL_REGISTRY_PASSWORD and RAILS_MASTER_KEY from the .env file

4. Configure Traefik for SSL and HTTPS

Traefik's configuration has already been taken care of in the deploy.yml example given previously. You can find all the options available on the official documentation. In your server, you just have to create the file that Traefik will use for certificates storage (ssh roo@youripv4address):

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

Once you are done, restart the Traefik container:

kamal traefik reboot

5. Prepare your DNS on your domain registrar

This step may vary depending on your domain registrar. I use Cloudflare, so I can redirect all the subdomains with the following configuration:

  • An A record named kamal_example.com pointed to my Hetzner IPV4 address and proxied (Main record for the top level domain)
  • A CNAME record named * pointed to kamal_example.com and proxied (Record for all subdomains)

Then I add a page rule (Cloudflare Dashboard > Rules > Page Rules) to redirect the traffic from www.kamal_example.com to kamal_example.com :

Cloudflare

6. Send your configuration to your server

Once you're done with the previous steps, kamal setup is the command required to finish setting up our Rails application to the Hetzer server. This step is only required once, and you have to make sure it works otherwise your app won't deploy later.

As mentioned in Kamal's doc, this will:

  • Install Docker on the server if it's not already installed.
  • Start and configure the Traefik container.
  • Load all your environment variables present in your .env file.
  • Build and push your Docker image to the registry.
  • Start a new container with the version of the app.

When setup is completed, you should see this in your console: ray-so-export (2)

If your container is healthy and all the steps have successfully completed, you can navigate to the URL of your server and see if it is up. Go to <SERVER_IP>/up ) and you should see a green screen.

7. Start deploying your app's changes!

Now that everything is ready, you can deploy the first version of your app with kamal deploy. As you can see, it may have felt like a lot of work, but once you're set up, deploys are super easy!

Here are the most frequent commands you will need to work with Kamal:

  • To deploy a new version of your app, again: kamal deploy
  • To update your environment variables: kamal env push
  • To start a bash session in a new container made from the most recent app image: kamal app exec -i bash
  • To start a Rails console in a new container made from the most recent app image: kamal app exec -i 'bin/rails console'
  • To see your logs: kamal app logs

    8. Some gotchas

  • Other than the ones mentioned in this tutorial, do not store credentials in the .env file, this can break Kamal's deploys (but works in Heroku) and lead to errors in production. It's better to store them in Rail's built-in encryption manager and to call them using

Rails.application.credentials.some_credential
  • I've faced some autoloading issues during builds that didn't throw any errors in the logs. A good way to catch them is to do rails zeitwerk:check locally.
  • If you do not use SSL encryption, make sure you remove all of Traefik's corresponding configuration in the deploy.yml file and disable force_ssl in production.rb
  • Sometimes, Kamal throws an error saying it can't build a new container with the same name of a previous container : the container name "something" is already in use by container "069c97eb7". In that case, stop and delete all the corresponding local volumes, images and containers (in that order) manually via the Docker desktop app. You can then check the list of active containers in your terminal with the docker container ls -a command (view Docker's doc for more info). If there's still a homonymous container, try to stop it with docker stop container_name. If there aren't any matching containers, you can remove's Kamal ongoing setup with: kamal remove, this will allow to erase your conflictive setup and start a new kamal setup (do not do this after a succesful setup/deploy). After that, the conflict disappears. If there's still an error, it might be necessary to restart Docker or even your machine.

This article pulls references from Guillaume Briday's tutorial on SSL certificates.