In this post, we're making sure we have all the required Rails configurations in place to achieve our goal: Host our apps on just one Hetzner machine. For this, we need to setup Kamal and configure Traefik.
This article is structured into four seperate posts:
This is part 2.
Ideally upgrade to or setup your Rails with version 7.1
, but it should also work with 7.0
.
First of all, check your routes.rb
file and ensure you have the healthcheck routes Kamal needs. This route just returns a 200
code and will let your server know that all is well.
# config/routes.rb
get "up" => "rails/health#show", as: :rails_health_check
Add Kamal to your Gemfile
:
gem install kamal
Before we get started, we want to ensure that our application can be bundled in the right systems. Let's add the following to our Gemfile.lock
:
bundle lock --add-platform aarch64-linux
bundle lock --add-platform x86_64-linux
Then, inside of your app directory, run:
kamal init
This will generate a config/deploy.yml
file for you. This file will contain all setup to deploy your app to our Hetzner VM. Replace the one you got from the gem with the following one:
# config/deploy.yml
---
service: YOUR-SERVICE-NAME
image: YOUR-DOCKER-USERNAME/YOUR-SERVICE-NAME (e.g. fmuster/my-app)
servers:
web:
hosts:
- YOUR-HETZNER-IP-ADDRESS
options:
"add-host": host.docker.internal:host-gateway
labels:
traefik.http.routers.YOUR-ROUTER-NAME.entrypoints: websecure
traefik.http.routers.YOUR-ROUTER-NAME.rule: "Host(`YOUR-APPS-URL.com`) || Host(`www.YOUR-APPS-URL.com`)"
traefik.http.routers.YOUR-ROUTER-NAME.tls.certresolver: letsencrypt
traefik.http.routers.YOUR-ROUTER-NAME.tls.domains[0].main: YOUR-APPS-URL.com
traefik.http.routers.YOUR-ROUTER-NAME.tls.domains[0].sans: www.YOUR-APPS-URL.com
worker:
hosts:
- YOUR-HETZNER-IP-ADDRESS
cmd: bundle exec sidekiq
registry:
username: YOUR-DOCKER-USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
traefik:
options:
publish:
- "443:443"
volume:
- "/root/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: "YOUR-EMAIL@PROVIDER.COM"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
env:
clear:
DB_HOST: YOUR-HETZNER-IP-ADDRESS
POSTGRES_USER: YOUR-SERVICE-NAME_user (e.g. my-app_user)
POSTGRES_DB: YOUR-SERVICE-NAME_production (e.g. my-app_production)
redis: YOUR-HETZNER-IP-ADDRESS
secret:
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
- REDIS_URL
ssh:
user: root
accessories:
db:
image: postgres:16
host: YOUR-HETZNER-IP-ADDRESS
port: 5432
env:
clear:
POSTGRES_USER: OUR-SERVICE-NAME_user
POSTGRES_DB: YOUR-SERVICE-NAME_production
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
redis:
image: redis:7.0
roles:
- web
- worker
port: 6379
directories:
- data:/data
Here's how you should modify your file:
YOUR-SERVICE-NAME
with the name of your app. For this, go to your config/database.yml
file and find the production
database at the bottom. Take the username as your service name.YOUR-DOCKER-USERNAME/YOUR-SERVICE-NAME
with it and YOUR-SERVICE-NAME
with the name above.myapp
.YOUR-APPS-URL
with your app's URL, which you should have boght on a registrar, e.g. Namecheap. You'll let Traefik know to redirect also to your www. subdomain.YOUR-EMAIL@PROVIDER.COM
with your actual email address, which is required to generate your certificates on letsencrypt (happens automatically in the background, no need to set up an account or something)certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
) that we're telling letsencrypt to store the certificate inside of a acme.json
file. We need to create this file for the DNS challenge like this: In your terminal, enter ssh root@YOUR-HETZNER-IP-ADDRESS
. Once you're in your VM, create a file right at the root location: touch acme.json
. Give it the right authorizations: chmod 600 acme.json
. Ensure the file is there by ls
ing. You can also check the contents by cat acme.json
. Obviously this is empty right now, but it will soon contain a bunch of certificates. clear
section it should look something like this: DB_HOST: 138.112.127.162, POSTGRES_USER: my-app_user, POSTGRES_DB: my-app_production
etc. Under the secret section, leave the ENV varibales as they are, we'll set them up in the next step.Note that we're always giving the same Hetzner IP address for all our services.
It's also important to note, that the very first app you're going to deploy will have a different deploy.yml
file than the others. Why?
Because the first app will include Traefik. Traefik acts as a reverse proxy and load balancer for web applications. It sits between the client (such as a web browser) and the server hosting the application, directing incoming requests to the correct service based on the request's domain or other criteria. For developers, Traefik simplifies the process of deploying and scaling web applications by automatically handling the routing of requests, SSL termination (which means converting secure HTTPS requests into regular HTTP that the application can understand), and offering a way to easily update configurations without downtime.
Traefik in this setup is being used as a reverse proxy and an SSL/TLS certificate manager for your web application, running in a Docker container. Here's what Traefik is configured to do:
This setup enhances security by using HTTPS and simplifies the certificate management process, as Traefik handles the issuance and renewal of SSL/TLS certificates automatically.
But most importantly, Traefik will be redirecting traffic to all subsequent apps we're going to deploy, meaning it's going to be the traffic orchestrator inside our VM. All of this is configured here, inside of your first app:
traefik:
options:
publish:
- "443:443"
volume:
- "/root/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: "YOUR-EMAIL@PROVIDER.COM"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
Your subsequent apps will not have this traefik
section as they will be managed by the one-and-only traefik instance above. This also means that, if you ever have to run traefik specific Kamal commands, you'll have to do that from your first app's directory / cli.
In your app's root directory, you should find a file called .env
. If not, create it. This is where Kamal is going to retrieve the credentials it needs for setting up Docker. For the above setup, it should look like this:
KAMAL_REGISTRY_PASSWORD=YOUR-DOCKER-REGISTRY-ACCESS-TOKEN
RAILS_MASTER_KEY=YOUR-MASTER-KEY (stored under config/master.key)
POSTGRES_PASSWORD=SAME-AS-YOUR-MASTER-KEY
REDIS_URL=redis://YOUR-HETZNER-IP-ADDRESS:6379/1
As always, replace the capital-letter kebab-case placeholders with your actual values.
Also, don't forget to add your .env
file to your .gitignore
and dockerignore
files, if it's not there already. You don't want to be pushing your secrets online.
Sidekiq
We need to setup our sidekiq
initialiser to be able to use it. Inside config/initializers
create a sidekiq.rb
file with follwoing content:
# config/initializers/sidekiq.rb
redis_config = Rails.env.development? ? { url: "redis://redis:6379/1" } : { url: ENV["REDIS_URL"] }
Sidekiq.configure_server do |config|
config.redis = redis_config
end
Sidekiq.configure_client do |config|
config.redis = redis_config
end
Then follow the normal sidekiq installation. This will ensure that on production, we're using the port we've assigned in our .env
file for our redis
instance.
The Sidekiq configuration will vary depending on how Redis is set up. If Redis is in another container, the URL might be different than what's listed.
production.rb
In our config/environments/production.rb
file, we need to make a few adjustments. Rails 7.1 gives us the opportunity to config.assume_ssl = true
. Add that to the configuration and comment out config.force_ssl = true
.
Also change this line, if not already changed:
# config/environments/production.rb
# if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
# end
It's important you comment out the if statement, otherwise we won't have good logging on production.
application_controller.rb
If you have any default_url setting enabled in your application_controller, remove it, as this will almost certainly mess with the routing proxies.
database.yml
This step is very important. Go to your config/database.yml
file and replace the production database with the follwing entry:
# config/database.yml
production:
<<: *default
host: '<%= ENV["DB_HOST"] %>'
database: '<%= ENV["POSTGRES_DB"] %>'
username: '<%= ENV["POSTGRES_USER"] %>'
password: '<%= ENV["POSTGRES_PASSWORD"] %>'
That's it for the Rails setup, let's move over the part 3, where we setup our Dockerfile.