← Back to posts

Part 2 - Rails Setup to Deploy Your App on One Hetzner Machine

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:

  1. Deploy All Your Rails Apps On One Server Using Kamal
  2. Rails Setup to Deploy Your App on One Hetzner Machine
  3. Rails Docker Setup to Host your Apps on One Virtual Machine
  4. Deploy With Kamal And Add More Apps To Your Server

This is part 2.

Rails

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

Kamal

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

Deploy.yml

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:

  1. Replace 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.
  2. Take your docker username and replace YOUR-DOCKER-USERNAME/YOUR-SERVICE-NAME with it and YOUR-SERVICE-NAME with the name above.
  3. For your url configuration, replace YOUR-ROUTER-NAME with a descriptive name, ideally just one word, to identify your app. Can be anything, you're defining it here and there. e.g. myapp.
  4. Replace 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.
  5. Go to the Traefik section and replace 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)
  6. You'll notice on the line below (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.
  7. Update your env varibles: under the 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?

Traefik

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:

  1. Routing Traffic: Traefik routes incoming HTTP and HTTPS traffic to the appropriate container based on the domain names (your-apps-url.com and www.your-apps-url.com). It listens on ports 80 (HTTP) and 443 (HTTPS) for web traffic, which we whitelisted in our firewall.
  2. SSL/TLS with Let's Encrypt: Traefik is configured to automatically obtain and renew SSL/TLS certificates from Let's Encrypt for the specified domains, enabling secure, encrypted connections over HTTPS.
  3. Redirection: Traefik redirects all HTTP traffic on port 80 to HTTPS on port 443, ensuring all connections to the web service are secure.

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.

.env

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.

Remaining Rails settings

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.