Server setup for Ruby web applications
posted by Ayush Newatia
23 July, 2026
The ease of using Heroku and other similar platforms means a generation of developers, including me, have never really needed to setup and manage servers during our career. As such, I was a bit afraid of server admin and self-hosting my apps.
Sadly, with the enshittifcation of PaaS providers, and an upward trend in pricing, I could no longer ignore the benefits of self-hosting. For example, Scattergun is one of my side projects with exactly $0 ARR. I’m paying $49/month to host it on Render.com. It needs 1 web service, 1 job runner, a Postgres database, and a Redis instance. All very basic for a Rails app, and at that level, I can probably run it on a single €5.49/month Hetzner box.
The trade-off for the lower price is that I need to do more ops work myself, but again, for small to medium sized apps, I feel this is a worthy trade-off with an efficient deployment setup.
Tools like Kamal have emerged recently to simplify self-hosting. However, nothing tackles the problem end-to-end, from securing the server, to log rotation, to database backups etc.
Kamal also uses Docker, which I’m not keen on as it’s a bit of a behemoth. It makes a lot of sense when you’re deploying to a fleet of servers, but I’ll be deploying to a single server and I feel it’s overkill for that.
This is the first of a series of posts where I’ll desribe my self-hosting setup for Ruby (and Rails) applications that covers the process of deploying apps to a single server end-to-end.
The first step towards self-hosting is to secure the server. In this post I’ll describe the security steps I like to take when I create a new VPS, and the software I install to run Ruby apps.
Creating a VPS
Create a VPS using your preferred cloud provider. I personally use Hetzner. This guide is based on Ubuntu 26.04, but you should be able to adapt it for other distros.
Ensure you configure an SSH key for root access, rather than using a password. Here’s my blog post on managing SSH keys for server access.
Upgrade packages and enable firewall
SSH to your server as root.
$ ssh root@<ip-address>
Then, update all system packages:
$ apt-get update && apt-get -y upgrade
Next, enable the firewall so only SSH, HTTP, and HTTPS ports are open:
$ ufw default deny incoming
$ ufw default allow outgoing
$ ufw allow OpenSSH
$ ufw allow http
$ ufw allow https
$ ufw --force enable
Creating users and restricting permissions
I create two users on my web servers: admin and deploy. admin will have sudo access, and will only be used for the initial server setup. After a server is provisioned, it will never again be used by an automated tool. root login is completely disabled.
Automated tools will connect as deploy to deploy the apps and run related tasks. It will not have sudo access as I’m not comfortable with automated tools running sudo commands, or with web applications running under a user that can gain elevated privileges.
Password login is insecure, and maintaing passwords for multiple servers is unwieldy, so we’ll restrict it and use SSH keys instead.
Create the admin user:
# Add a new user, set the default shell,
# and create a home directory
$ useradd admin -s /bin/bash -m
# Disable password login
$ passwd -ld admin
# Sync the private keys to allow SSH access
$ rsync --archive --chown=admin:admin ~/.ssh /home/admin
# Allow passwordless sudo for `admin`
$ echo "%admin $(cat /etc/hostname)=(root) NOPASSWD:ALL" > /etc/sudoers.d/admin
# Validate the modified sudoers file
$ visudo -c -f /etc/sudoers.d/admin
Disable root login completely as the admin user can now use sudo to gain root privileges. Password authentication is also disabled at system level in the same file.
$ touch /etc/ssh/sshd_config.d/user.conf
$ cat << STRING >> /etc/ssh/sshd_config.d/user.conf
PermitRootLogin no
PasswordAuthentication no
STRING
$ systemctl restart ssh
$ passwd -ld root
Next, create the deploy user:
$ useradd deploy -s /bin/bash -m
$ passwd -ld deploy
# Enable lingering so the user's systemd services keep running
# when the user doesn't have an active session.
$ loginctl enable-linger deploy
$ rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Since the deploy user won’t have sudo access, it can’t create systemd services in the system-wide location: /etc/systemd/system. As such, we’ll create them in the user-specific location: ~/.config/systemd/user/. By default, these services start when the user starts a session and stop after the session concludes. Enabling lingering as demonstrated above ensures services start on system boot and keep running even when the user isn’t connected.
Additional security steps
The below snippet updates several kernel and system settings to harden it against network and file-system attack vectors. I’ve adapted the settings from this script by Javier Ramirez. I won’t get into the details as there’s quite a lot, but you can search for each individual setting to learn more about it if you’re curious.
$ cat <<STRING > /etc/sysctl.d/network-security.conf
# Disable ICMP redirects.
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
# ICMP noise hygiene.
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Drop spoofed/martian packets at the door.
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# SYN-flood resilience as the server handles
# port 80/443 traffic from the internet.
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5
# Hide kernel internals from unprivileged eyes (kernel pointer leaks, dmesg,
# perf side channels) and disable unprivileged eBPF.
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.perf_event_paranoid = 3
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
# Only processes with CAP_SYS_PTRACE may ptrace.
kernel.yama.ptrace_scope = 2
# Block runtime kernel replacement via kexec (one-way until reboot).
# CAVEAT: if you ever set up kdump crash dumps, remove this line first.
kernel.kexec_load_disabled = 1
# Filesystem link/dump hardening.
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
# Turn off Martian logging
net.ipv4.conf.all.log_martians = 0
net.ipv4.conf.default.log_martians = 0
STRING
$ sysctl --system >/dev/null
For additional security, install fail2ban to block IPs trying to brute force SSH access to the server:
$ apt-get install fail2ban -y
$ touch /etc/fail2ban/jail.local
$ cat << STRING >> /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1d
findtime = 10m
maxretry = 5
[sshd]
enabled = true
STRING
$ systemctl enable fail2ban
And finally, install molly-guard to prevent accidental reboots. This requires the user to enter the server’s hostname to reboot it, or run reboot.no-molly-guard to bypass the check. It adds an additional layer of scrutiny to avoid accidental reboots.
$ apt-get install molly-guard
Reboot the server for changes to take effect:
$ reboot.no-molly-guard
root connections to the server will no longer be possible after reboot. Use admin instead. You may wish to add some additional layers of security, but this provides a good baseline.
Here’s a script with all the above steps: https://gist.github.com/ayushn21/41b1ceb4fc2651d7d849c432c0a4fa05.
Next, we’ll install the prerequisite software to run Ruby apps.
Installing Ruby prerequisites
Re-connect to your server as the admin user:
$ ssh admin@<ip-address>
Install Ruby’s prerequisites:
$ sudo apt-get install curl jq xz-utils build-essential zlib1g-dev \
libyaml-dev libssl-dev libncurses-dev libffi-dev \
rustc libjemalloc-dev -y
The above list includes rustc to enable YJIT, and libjemalloc-dev to use jemalloc for enhanced memory management.
I prefer using ruby-install and chruby to install and manage multiple Ruby versions, rather than alternatives such as rbenv. I feel it has a cleaner approach as it works by configuring environment variables, rather than using shims to intercept commands and direct them to the correct Ruby binary.
Install both utilities:
# Install ruby-install
$ wget https://github.com/postmodern/ruby-install/releases/download/v0.10.2/ruby-install-0.10.2.tar.gz
$ tar -xzvf ruby-install-0.10.2.tar.gz
$ cd ruby-install-0.10.2/
$ sudo make install
$ cd ..
$ rm ruby-install-0.10.2.tar.gz
$ rm -rf ruby-install-0.10.2/
# Install chruby
$ wget https://github.com/postmodern/chruby/releases/download/v0.3.9/chruby-0.3.9.tar.gz
$ tar -xzvf chruby-0.3.9.tar.gz
$ cd chruby-0.3.9/
$ sudo make install
$ cd ..
$ rm chruby-0.3.9.tar.gz
$ rm -rf chruby-0.3.9/
Disconnect from the admin user:
$ exit
Installing Ruby
Install Ruby as deploy as that’s the user the apps will be run under.
$ ssh deploy@<ip-address>
$ ruby-install --no-install-deps --cleanup ruby -- --enable-yjit --with-jemalloc --disable-install-rdoc
Building Ruby will take a bit of time. After it completes, enable chruby by sourcing its scripts in your .profile:
$ cat <<EOF >> ~/.profile
# Use chruby to auto-switch Ruby versions
source /usr/local/share/chruby/chruby.sh
source /usr/local/share/chruby/auto.sh
EOF
Finally, source the scripts into your current session, set a default Ruby, and setup bundler.
$ source /usr/local/share/chruby/chruby.sh
$ source /usr/local/share/chruby/auto.sh
$ rm -f -- ~/.ruby-version
$ echo $(chruby) > ~/.ruby-version
$ echo 'gem: --no-document' >> ~/.gemrc
$ mkdir -p ~/.config/systemd/user
$ gem update --system
$ gem install bundler
$ bundle config set --global without 'development test'
The server is now ready to serve Ruby applications. In the next couple of posts, we’ll explore two different deployment setups.