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