Deploy Ruby on Rails on a VPS — Step by Step

Deploying Rails on a VPS is where most of the Rails deployment topic collapses from theory into reality. You are not just running rails server anymore — you are wiring together an operating system, a Ruby runtime, a database, a reverse proxy, an application server, a process supervisor, background workers and a TLS certificate, all on a machine you are responsible for keeping alive at 3 AM. This guide walks through every layer of that stack with concrete commands and the trade-offs behind each choice, covering server preparation, Ruby installation, PostgreSQL setup, Puma tuning, Nginx configuration, SSL termination, Sidekiq integration and basic monitoring. After ten-plus years of shipping Rails to production, what I can tell you is: the hard part is rarely any single step. It is the interactions between steps that produce the subtle, infuriating failures nobody warns you about.
Prepare the server
Start with a fresh Ubuntu LTS image. At the time of writing, Ubuntu 24.04 is the safest choice — wide package support, long security window, and the most Rails-specific documentation of any distribution.
Create a deploy user immediately. Do not run your application as root, ever, no matter how tempting it is for a "quick test."
adduser deploy
usermod -aG sudo deploy
Copy your SSH public key to the deploy user. Then disable password authentication and root login in /etc/ssh/sshd_config. Restart sshd. If you lock yourself out at this step, you will need console access from your VPS provider, so double-check before restarting the service.
Set up the firewall:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable
Three ports. That is all the outside world should be able to reach. Puma listens on a Unix socket, not a public port, so there is nothing else to expose.
Set the timezone and locale to something sane. UTC for the server clock, always. Your application can present local times to users; the server itself should never be confused about what "now" means.
Install Ruby
Use rbenv and ruby-build. Install the dependencies first:
sudo apt install -y build-essential libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev
Then install rbenv into the deploy user's home directory and add it to the shell path. Install ruby-build as an rbenv plugin. Then:
rbenv install 3.3.6
rbenv global 3.3.6
Verify it: ruby -v should return the version you just installed. Now install Bundler:
gem install bundler --no-document
The critical thing to verify here is that the Ruby path is identical whether you run it interactively, via ssh deploy@server 'ruby -v', or from a systemd service. Mismatched paths are one of the top three causes of "it works when I SSH in but the app won't start" failures.
Set up PostgreSQL
sudo apt install -y postgresql postgresql-contrib libpq-dev
Create a database user for your app:
sudo -u postgres createuser --createdb deploy
Some guides tell you to set a password here. On a single-server deployment where the app connects over a Unix socket, peer authentication works and keeps one less secret to manage. If your database will run on a separate server, yes, you need password authentication — but for a one-box deployment, peer auth is simpler and no less secure.
Create the production database:
createdb myapp_production
Tune postgresql.conf for your available memory. The two settings that matter most on a small VPS: shared_buffers (set to about 25% of total RAM) and work_mem (start at 4–8 MB and raise it if you see disk sorts in EXPLAIN ANALYZE output). Do not copy a tuning guide written for a 64 GB database server and paste it into a 2 GB VPS config — you will OOM the machine.
Configure Puma for production
Create config/puma/production.rb or set environment variables. The essentials:
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
bind "unix:///home/deploy/myapp/tmp/sockets/puma.sock"
environment "production"
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection
end
Why a Unix socket instead of a TCP port? Lower latency, no TCP overhead, and you do not accidentally expose Puma to the internet if your firewall rules drift.
Worker count: match it to your CPU cores. On a 2-core VPS, two workers is correct. Thread count: 5 is a sensible default for a database-heavy app. The preload_app! directive gives you copy-on-write memory savings, but forces you to re-establish database connections after fork — that is what the on_worker_boot block does.
Trade-off: more workers use more memory. On a 2 GB VPS running PostgreSQL and Sidekiq on the same machine, two Puma workers and five threads each might already push you close to the ceiling. Monitor RSS and swap usage after your first real traffic.
Set up Nginx as a reverse proxy
sudo apt install -y nginx
Create a site configuration in /etc/nginx/sites-available/myapp:
upstream puma {
server unix:///home/deploy/myapp/tmp/sockets/puma.sock fail_timeout=0;
}
server {
listen 80;
server_name example.com;
root /home/deploy/myapp/public;
location / {
try_files $uri @puma;
}
location @puma {
proxy_pass http://puma;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Symlink it to sites-enabled, remove the default site, test with nginx -t, reload. Nginx handles static assets directly from public/ without touching Puma, which is one of the main reasons you want it in front of your app server.
SSL with Let's Encrypt
Do not skip this. There is no legitimate reason to serve a production Rails app over plain HTTP in 2026.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
Certbot modifies your Nginx config to add the listen 443 ssl block and a redirect from port 80. It also installs a cron job or systemd timer for automatic renewal. Verify renewal works: sudo certbot renew --dry-run.
Force SSL in your Rails config:
# config/environments/production.rb
config.force_ssl = true
This sets the Strict-Transport-Security header and redirects HTTP to HTTPS at the application level. Belt and suspenders — Nginx handles the redirect first, but if a request somehow reaches Rails over HTTP, the app catches it too.
Background jobs with Sidekiq
Most Rails applications need background processing. Sidekiq is the standard choice.
Install Redis:
sudo apt install -y redis-server
Ensure Redis is configured to listen only on 127.0.0.1 and has maxmemory set. On a shared VPS, a runaway Redis instance that consumes all available memory will take down your entire stack.
Add Sidekiq to your Gemfile, configure it in config/sidekiq.yml, and create a systemd service unit:
[Unit]
Description=Sidekiq for myapp
After=network.target redis-server.service
[Service]
User=deploy
WorkingDirectory=/home/deploy/myapp
ExecStart=/home/deploy/.rbenv/shims/bundle exec sidekiq -e production
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Notice the full path to bundle. This is where Ruby path mismatches bite hardest — systemd does not load your shell profile, so rbenv shims are not on the path unless you specify them explicitly.
Create a matching systemd unit for Puma as well, following the same pattern. Enable both services with systemctl enable.
Monitoring: the minimum viable setup
A server you are not monitoring is a server that will surprise you. At minimum:
- Log rotation. Rails log files will eat your disk if left unchecked. Configure
logrotateforproduction.log, Nginx logs and PostgreSQL logs. - Disk and memory alerts. A cron job that checks
df -handfree -mand sends an email or webhook when thresholds are crossed. Crude, but effective. - Process supervision. Systemd handles restarts, but you should verify Puma and Sidekiq are running after deploys and after server reboots.
- Application error tracking. Sentry, Honeybadger, or Bugsnag — pick one, install the gem, configure the DSN. You want errors delivered to you, not hiding in log files.
- Uptime checking. A simple external HTTP check against your health endpoint. If you do not have one, add a
/uproute that returns 200 and confirms the database is reachable.
Is this overkill for a small side project? Maybe. But the first time your database fills up a 25 GB disk at 2 AM and you find out from a user tweet instead of an alert, you will wish you had spent the thirty minutes.
What usually goes wrong
After watching dozens of first-time VPS deployments, these are the failures that actually eat time:
- Ruby path mismatches. Puma or Sidekiq silently uses system Ruby instead of your
rbenv-managed version. The fix is always explicit paths in systemd unit files. - Database connection pool exhaustion. Puma threads exceed
database.ymlpool size. Under light load, you never notice. Under real traffic, requests queue and then timeout. - Forgetting to precompile assets. The deploy goes fine, the app starts, and every page is unstyled. Run
RAILS_ENV=production bundle exec rails assets:precompileas part of every deploy. - Secret key base not set. Rails refuses to boot in production without
SECRET_KEY_BASE. Usecredentials:editor environment variables — but do not commit secrets to your repo. - Firewall lockout. You edit SSH config, restart the service, and cannot get back in. Always test SSH in a second terminal before closing your existing session.
- Let's Encrypt renewal failure. Certbot renewal fails silently because Nginx config changed. That dry-run test is not optional.
- Redis and Sidekiq memory creep. Jobs that enqueue faster than they process, slowly filling Redis. Set
maxmemoryand amaxmemory-policyinredis.conf.
Deployment checklist
Use this after every deploy, not just the first one:
- SSH in as the deploy user (not root)
- Pull code and install dependencies (
bundle install) - Run database migrations
- Precompile assets
- Restart Puma (
systemctl restart puma) - Restart Sidekiq (
systemctl restart sidekiq) - Verify the site loads and returns 200 on the health endpoint
- Check
journalctl -u pumaandjournalctl -u sidekiqfor startup errors - Verify SSL certificate is valid and not expiring within 30 days
- Confirm background jobs are processing (check Sidekiq web UI or logs)
FAQ
Do I need Docker for a VPS deployment?
No. Docker adds value for reproducibility and multi-service orchestration, but for a single Rails app on a single VPS, it adds complexity without proportional benefit. Learn the bare-metal deployment first. You will understand what Docker is abstracting when you eventually adopt it.
How much RAM do I need?
For a small-to-medium Rails app with PostgreSQL, Redis and Sidekiq on the same machine: 2 GB is tight but workable. 4 GB is comfortable. Below 2 GB, you will fight swap constantly.
Should I use Capistrano?
Capistrano is a well-understood deploy tool for Rails. It handles the release directory structure, symlinks, asset precompilation, and process restarts. For a single server, it is a solid choice. For multi-server deployments, it still works but you may outgrow it. The alternative is a simple shell script that does the same steps — less magic, more transparency.
What about Kamal or Dokku?
Kamal (formerly MRSK) is a newer deploy tool from the Rails core team that uses Docker under the hood but presents a simpler interface. Dokku gives you a Heroku-like push-to-deploy experience on your own VPS. Both are valid. This guide covers the manual approach so you understand what those tools automate.
Can I run multiple Rails apps on one VPS?
Yes, with separate Puma instances, separate Nginx server blocks and separate systemd units. Memory is the constraint — each additional app adds at least 300-500 MB of resident memory. Plan accordingly.
Related reading
- Rails Deployment — the parent topic covering VPS, container and PaaS deployment strategies
- Deploy Rails on Your Own Server — structured learning path for first-time deployers
- Nginx for Rails Apps — deeper coverage of reverse proxy configuration
- Web Performance for Rails Developers — performance tuning once your deploy is stable