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
- Create a private server on Hetzer Cloud that will replace Heroku
- Install Docker on your machine and create an account on Docker hub, because Kamal needs Docker to deploy apps
- Set up your Rails application for deployment with Kamal + PostgreSQL (note that most Kamal tutorials are not PostgreSQL compatible!)
- Configure Traefik (Kamal's reverse proxy tool) for SSL and HTTPS
- Prepare your DNS on your domain registrar
- Send your configuration to your server
- 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)
- 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 :
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.
- 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>
2. Install Docker on your machine and setup Docker Hub's API Key
This is straightforward : go to Docker Desktop's download page, download the latest version and install it.
Launch Docker Desktop, you will see a Docker icon in your menu bar.
In your terminal, run the following command to check that Docker is installed correctly:
docker --version
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.Click on your profile icon in the upper right corner and select "My Account" from the drop-down menu
In the "Security" section of your account settings, click on "New Access Token"
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
.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.
3. Set up your Rails application for deployment with Kamal + PostgreSQL
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 thatRUBY_VERSION
matches the Ruby version in .ruby-version and Gemfile. Here's the Dockerfile I use.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 yourconfig/routes.rb
file. Be careful though, the /up route should be above any other catch all route:get '/up', to: ->(env) { [204, {}, ['']] }
Time to install Kamal using
bundle install kamal
and initialize Kamal withkamal init
. This command will create a bunch of files in your app folder like.env
,config/deploy.yml
.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 myDocker
username andkamal_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 everytimeipv4_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
andRAILS_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 namedkamal_example.com
pointed to my Hetzner IPV4 address and proxied (Main record for the top level domain) - A
CNAME
record named*
pointed tokamal_example.com
and proxied (Record for all subdomains)
Then I add a page rule (Cloudflare Dashboard > Site > Rules > Redirect Rules) to redirect the traffic from www.kamal_example.com
to kamal_example.com
:
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:
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 inproduction.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 withdocker 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 newkamal 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.