Deploying Multiple Rails Apps on The Same Server with Puma + Nginx

Deploying Multiple Rails Apps on The Same Server with Puma + Nginx

·

11 min read

If Kamal is not for you then this article is for you. There’s a lot of my opinions in this article, feel free to jump to the setup section

After coming back to rails from Laravel I found that it wasn’t clear how to run multiple rails apps on the same server. If you google “deploy rails” right now you see something about deploying to heroku which was the thing when I started using rails in rails 5.

With PHP-FPM + Nginx I could run an infinite number of apps on a $5/month server. When it was time to deploy Pulse, as always I did a deep dive, most of the articles on the internet assumed I wanted to run 1 rails app on 1 vm, my first attempt worked, I ran puma, I don’t even remember how, it worked but puma was listening on port 300, I just imagined multiple apps each on it’s own port would be very confusing

To deploy multiple rails apps on the same sever we need the following

  • Nginx

  • Ruby (of course)

  • Puma (bundled with rails)

  • Mina

Here is how it works, we tell nginx that if it sees a given host name say pulse.welodge.co.zw forward that http request to this http server. This server, we will call “upstream” from now on can be listening on a port or unix socket, which is not technically a file but for this article it’s just a file. If we have 3 apps each could be listening on

  • /var/www/app1/tmp/app1.sock

  • /var/www/app2/tmp/app2.sock

  • etc

This makes way more sense than opening several local ports which maybe hard to keep track of as you add more apps to the server

Talk is cheap, show me the code

To follow along this tutorial you will need a Linux server. I recommend getting one from Hertzner, they have very good pricing with really good performance. Use Ubuntu or Debian, for servers I would recommend Debian because we generally want our servers to be stable. Any Linux distro would do by the way

Initial setup

After getting your server, ssh into the server as root

ssh root@<your-server-ip>

Install the required software

apt-get install nginx mariadb-server git build-essential zip unzip curl neovim

We just installed nginx, a database, I prefer mysql you can install postgres if you use postgres git to clone our repos build-essential for build tools, zip utils and curl and finally neovim [I refuse to use nano more than once per computer]

Enable nginx auto start

systemctl enable --now nginx

This should be everything we need. Next create 2 user accounts, 1 is yours as the admin of this server the other is deployment user which is zero privileges, this will be the user our apps are going to run as.

useradd given -m -G sudo -U

This creates a user called given the -m flag creates a home directory in /home/given -G adds the user to sudo group so we can run sudo commands -U creates a user group with name given

Now set a password for this user

passwd given

Open another terminal window and add ssh keys to this newly created account

ssh-copy-id <newuser>@<your-server-ip>

Enter the password you just created, if it fails go back to the root terminal window and restart ssh

systemctl restart ssh

Next, create our deployment user

useradd -m deploy -U

After this set a password and repeat the process of adding ssh keys.

It’s not advisable to run a linux machine as root, we need to disable root login on this machine, disable password authentication. Edit the /etc/ssh/sshd_config file

nvim /etc/ssh/sshd_config

Find an entry that says PermitRootLogin and change that to no PasswordAuthentication change to no

Save the file and restart ssh. Before we leave this root account, setup a firewall, using the uncomplicated firewall

apt-get install ufw

Allow only port 80, 443, and 22

ufw allow 22
ufw allow 80
ufw allow 443

Enable the firewall and exit the root account

ufw enable && exit

Now ssh to this server with the deploy account and install ruby and node

Follow this tutorial to install ruby https://gorails.com/setup/ubuntu/24.04

Install node with nvm: https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating

Do the same for your user account

By now you have ruby and node installed on your server and it’s ready

Deployment with mina

To semi automate our deployment from now we will use mina to setup our environment and deploy from github

In the deploy user account generate ssh keys to authenticate with github

ssh-keygen -t rsa

copy the the contents ~/.ssh/id_rsa.pub and paste on GitHub Settings > SSH and GPG Keys > New SSH Key

In your rails project add mina as a dependency

bundle add mina && bundle exec mina init

Open your config/deploy.rb and replace everything with the following code

require "mina/rails"
require "mina/git"

require "mina/rbenv"

set(:application_name, "app_name")
set(:domain, "app_domain_name")
set(:deploy_to, "/var/www/app_domain_name")
set(:repository, "git url")
set(:branch, "main")

set :ssh_options, "-o StrictHostKeyChecking=no"

set(:user, "deploy")
set(:shared_dirs, fetch(:shared_dirs, []).push("tmp/sockets", "tmp/pids", "log"))
# add storage to shared dirs
set(:shared_dirs, fetch(:shared_dirs, []).push("storage"))
set(:shared_files, fetch(:shared_files, []).push("config/credentials/production.key"))

# load nvim
task(:remote_environment) do
  invoke(:"rbenv:load")
  command('export NVM_DIR="$HOME/.nvm"')
  command('[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"')
  command("nvm install 22")
end

# install latest ruby if it does not exisit
task(:setup) do
  command("rbenv install 3.3.5 --skip-existing")
  command("gem install bundler")
end

desc("Deploys the current version to the server.")
task(:deploy) do
  deploy do
    invoke(:"git:clone")
    invoke(:"deploy:link_shared_paths")
    invoke(:"bundle:install")
    invoke(:"rails:db_create")
    invoke(:"rails:db_migrate")

    command("yes y | yarn set version stable") # optional if you don't use yarn
    invoke(:"rails:assets_precompile")
    invoke(:"deploy:cleanup")

    on(:launch) do
      in_path(fetch(:current_path)) do
        command("mkdir -p tmp/")
        command("touch tmp/restart.txt")
        command("systemctl --user restart #{fetch(:application_name)}-sidekiq.service")
        # command("bundle binstubs puma")
        invoke(:"puma:restart")
        invoke(:"sidekiq:restart")
      end
    end
  end
  # you can use `run :local` to run tasks on local machine before or after the deploy scripts
  run(:local) { command("echo \"Done!\"") }
end

namespace(:sidekiq) do
  desc("Start Sidekiq")
  task(:start) do
    in_path(fetch(:current_path)) do
      command("systemctl --user start #{fetch(:application_name)}-sidekiq.service")
    end
  end

  desc("Restart Sidekiq")
  task(:restart) do
    in_path(fetch(:current_path)) do
      command("systemctl --user restart #{fetch(:application_name)}-sidekiq.service")
    end
  end

  desc("Check Sidekiq status")
  task(:status) do
    command("systemctl --user status #{fetch(:application_name)}-sidekiq.service")
    command("journalctl --user -xeu #{fetch(:application_name)}-sidekiq.service")
  end

  desc("Tail Sidekiq logs")
  task(:log) do
    command("journalctl --user -u #{fetch(:application_name)}-sidekiq.service -f")
  end

  desc("Setup Sidekiq systemd service")
  task(:setup) do
    puts("Setting up systemd service for Sidekiq..")
    path = fetch(:deploy_to)
    content = File.read("./config/sidekiq.service").gsub("<APP_PATH>", path)
    File.write("./tmp/sidekiq.service", content)
    run(:local) do
      command("scp ./tmp/sidekiq.service #{fetch(:user)}@#{fetch(:domain)}:~/.config/systemd/user/#{fetch(:application_name)}-sidekiq.service")
    end

    command("systemctl --user daemon-reload")
    command("systemctl --user enable #{fetch(:application_name)}-sidekiq.service")
  end
end

namespace(:puma) do
  desc("Start Puma")
  task(:start) do
    in_path(fetch(:current_path)) do
      # command("bundle binstubs puma")
      command("systemctl --user start #{fetch(:application_name)}-puma.service")
    end
  end

  desc("Restart Puma")
  task(:restart) do
    in_path(fetch(:current_path)) do
      command("systemctl --user restart #{fetch(:application_name)}-puma.service")
    end
  end

  desc("Check Puma status")
  task(:status) do
    command("systemctl --user status #{fetch(:application_name)}-puma.service")
    command("journalctl --user -xeu #{fetch(:application_name)}-puma.service")
  end

  desc("Tail Puma logs")
  task(:log) do
    command("journalctl --user -u #{fetch(:application_name)}-puma.service -f")
  end

  desc("Setup Puma systemd service")
  task(:setup) do
    puts("Setting up systemd service for Puma..")
    path = fetch(:deploy_to)
    content = File.read("./config/puma.service").gsub("<APP_PATH>", path)
    File.write("./tmp/puma.service", content)
    run(:local) do
      command("scp ./tmp/puma.service #{fetch(:user)}@#{fetch(:domain)}:~/.config/systemd/user/#{fetch(:application_name)}-puma.service")
    end

    command("touch #{fetch(:deploy_to)}/shared/tmp/pids/server.pid")
    command("systemctl --user daemon-reload")
    command("systemctl --user enable #{fetch(:application_name)}-puma.service")
  end
end

desc("Copy production key to server")
task(:copy_secrets) do
  run(:local) do
    command(
      "scp ./config/credentials/production.key #{fetch(:user)}@#{fetch(:domain)}:#{fetch(:deploy_to)}/shared/config/credentials/production.key"
    )
  end
end

Let’s break down what this code does, mina needs to know the application name, domain name, the ssh user it will login with, we set that at the beginning

We then set shared directories that will not change across deployments like the tmp dir storage and the credentials

Next we setup environment, we load rbenv and nvm, the setup task will install ruby and bundler

the deploy task will git clone symlink the shared directories, run bundler, migrate the database and precompile assets, after deployment is done, we restart puma and sidekiq if you use sidekiq

We have setup for sidekiq, puma

in the config dir add a file called puma.service we will be using systemd to manage multiple puma instances running at the same time

# config/puma.service
[Unit]
Description=Puma HTTP Server
After=network.target

# Uncomment for socket activation (see below)
# Requires=puma.socket

[Service]
# Puma supports systemd's `Type=notify` and watchdog service
# monitoring, as of Puma 5.1 or later.
# On earlier versions of Puma or JRuby, change this to `Type=simple` and remove
# the `WatchdogSec` line.
Type=notify

# If your Puma process locks up, systemd's watchdog will restart it within seconds.
WatchdogSec=10

# Preferably configure a non-privileged user


# The path to your application code root directory.
# Also replace the "<YOUR_APP_PATH>" placeholders below with this path.
# Example /home/username/myapp
WorkingDirectory=<APP_PATH>

# Helpful for debugging socket activation, etc.
# Environment=PUMA_DEBUG=1

# SystemD will not run puma even if it is in your path. You must specify
# an absolute URL to puma. For example /usr/local/bin/puma
# Alternatively, create a binstub with `bundle binstubs puma --path ./sbin` in the WorkingDirectory
# ExecStart=<FULLPATH>/sbin/puma -C <YOUR_APP_PATH>/puma.rb


Environment="PATH=/home/deploy/.rbenv/shims:/home/deploy/.rbenv/bin:/usr/local/bin:/usr/bin:/bin:$PATH"

ExecStart=<APP_PATH>/current/bin/bundle exec puma -e production --pidfile <APP_PATH>/shared/tmp/pids/server.pid -C <APP_PATH>/current/config/puma.rb -b unix://<APP_PATH>/shared/tmp/puma.sock <APP_PATH>/current/config.ru

# Variant: Use `bundle exec puma` instead of binstub
# Variant: Specify directives inline.
# ExecStart=<FULLPATH>/puma -b tcp://0.0.0.0:9292 -b ssl://0.0.0.0:9293?key=key.pem&cert=cert.pem


Restart=always

[Install]
WantedBy=multi-user.target

Copy paste as it is, you might change the PATH config if your deploy user is not actualy deploy but everything else stay the same. Also note that we are binding puma to a unix socket instead of a port

If you are using sidekiq create a sidekiq.service in the config dir

[Unit]
Description=sidekiq
After=syslog.target network.target

[Service]
Type=simple
Environment="PATH=/home/deploy/.rbenv/shims:/home/deploy/.rbenv/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
WorkingDirectory=<APP_PATH>
ExecStart=<APP_PATH>/current/bin/bundle exec sidekiq -e production -C <APP_PATH>/current/config/sidekiq.yml -r <APP_PATH>/current
ExecReload=/usr/bin/kill -TSTP $MAINPID

# if we crash, restart
RestartSec=1
Restart=on-failure
#Restart=always

# output goes to /var/log/syslog
#StandardOutput=syslog
#StandardError=syslog

# This will default to "bundler" if we don't specify it
#SyslogIdentifier=sidekiq

[Install]
WantedBy=multi-user.target

Again don’t change anything else except for the username from deploy to your configured username

Now setup our deployment environment with mina setup

bundle exec mina setup

After it’s done, copy the secrets to product, I prefer to use secretes instead of environment variables

bundle exec mina copy_secrets

This will copy secrets from your local machine to production

Setup puma and sidekiq

bundle exec mina puma:setup && bundle exec mina sidekiq:setup

Finally deploy the app with

bundle exec mina deploy

Now the only thing left is our nginx config. Where you store this file is up to you, I’ve been thinking about this for while, you can store it in the config/nginx.conf, add it to the shared_files with mina like this

set(:shared_files, fetch(:shared_files, []).push("config/nginx.conf"))

Or put it in the /etc/nginx/sites-available dir it’s up to you, here is the base config that I use for my apps

upstream <app_name>-puma {
  server unix:///var/www/<app-domain-name>/shared/tmp/puma.sock;  # Define upstream server for myapp
}

server {
  server_name <app-domain-name>;  # Server name for this nginx server block
  listen 80;

  root /var/www/<app-domain-name>/current/public;  # Root directory for static files
  access_log /var/www/<app-domain-name>/shared/log/nginx.access.log;  # Access log path
  error_log /var/www/<app-domain-name>/shared/log/nginx.error.log info;  # Error log path and verbosity

  if (-f $document_root/maintenance.html) {
    rewrite  ^(.*)$  /maintenance.html last;  # Rewrite to maintenance page if it exists
  #  break;  # Stop further processing
  }

   location ~ ^/rails/active_storage {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://<app-name>-puma;

    proxy_buffering off;

    expires max;
  }

  location / {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  # Set X-Forwarded-For header for proxy
    proxy_set_header Host $host;  # Set Host header for proxy
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-Proto https;
     proxy_set_header  X-Forwarded-Port $server_port;
    proxy_set_header  X-Forwarded-Host $host;
    proxy_redirect off;
    if (-f $request_filename) {
      break;  # Serve directly if file exists
    }

    if (-f $request_filename/index.html) {
      rewrite (.*) $1/index.html break;  # Rewrite to index.html if it exists
    }

    if (-f $request_filename.html) {
      rewrite (.*) $1.html break;  # Rewrite to .html if it exists
    }

    proxy_pass http://<app-name>-puma;  # Proxy pass to upstream if no static file found
  }

  location ~* \.(ico|css|gif|jpe?g|png|js)(\?[0-9]+)?$ {
    expires max;  # Set max expiration for static files
    gzip_static on;
    add_header Cache-Control public;
  }

location /cable {
    proxy_pass http://<app-name>-puma;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Ssl on; # Optional
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_set_header X-Forwarded-Host $host;
    proxy_redirect off;
  }

  # Error page configuration
  location = /500.html {
    root /var/www/<app-domain-name>/current/public;  # Serve 500.html from this directory
  }
  keepalive_timeout 10;
}

wherever you stored this file, make a symbolic link to /etc/nginx/sites-enabled/<app-doman>.conf

Login with your user account with sudo privileges for this

sudo ln -s /path/to/nginx-config-file /etc/nginx/sites-enabled/your-app-domain.conf

Check for errors with

sudo nginx -t

If it says okay, restart nginx, visit your domain name in the browser and your app should be live.

During all these commands if you run into permissions errors change ownership of the /var/www/ to deploy and www-data group

sudo chown -R deploy:www-data /var/www

This will give our deploy user ownership of that directory as well as the webserver if it uses that directory for some other reason

Now let’s add ssl with let’s encrypt

Follow the steps on this link to add ssl support https://certbot.eff.org/instructions?ws=nginx&os=snap

And that’s it you’ve successfully deployed rails with nginx + puma + mina. The downside is that right now we have to manually run bundle exec mina deploy from our local machine, I tried to find a github action for mina but I couldn’t. One that I found was using ruby 2 and didn’t work quite as I intended. I could create one myself but skill issues, I don’t know docker and don’t quite understand github actions syntax.

I understand that this was quite long to just deploy an app but you only have to do this once and never again, you can reuse the deploy.rb file as it is, the .service files as they are, the nginx config as it is for the next project

Going forward, kamal? docker?

I will keep adding updates to this blog post but for now this setup meets my needs. Kamal 2 is out at the time of writing this and everyone is singing praises on how good it is to setup servers and it is. But, I don’t think it’s for everyone, from what I understand [at the time of writing this] the docs say kamal is good to run the same app on multiple servers, like instances of the same on multiple servers but what I want the vice versa of this, I know this is skill issues on my part I don’t know docker but I don’t have time to understand the ins and outs of docker right now but I do know ruby shell nginx and with this I can deploy to the server with semi minimal effort.

But this is just my way of doing things, follow me on Hashnode for more articles like this and let me know your thoughts in the comments below