How to Install Ruby on Rails on Ubuntu 24.04 VPS: Production Deploy Guide
Deploying a Ruby on Rails application on your own VPS gives you complete control over the stack, predictable costs, and the freedom to tune every layer — from the Ruby version manager to the reverse proxy. This guide walks you through a production-grade Rails 7 install on Ubuntu 24.04 LTS, from a fresh SSH session to a working Puma + Sidekiq deployment behind Nginx with TLS.
Want a head start? Deploy a CloudCore Starter VPS pre-tuned for Ruby workloads and follow this guide end to end. Launch a Rails-ready VPS now and you will be serving your first request in under 30 minutes.
Table of Contents
What You Will Build
By the end of this guide your VPS will be running a full Rails 7 production stack:
- Ruby 3.3.x managed via rbenv (multiple Ruby versions coexist cleanly)
- Rails 7.1+ with Propshaft or Sprockets assets and Import Maps / jsbundling
- PostgreSQL 16 as the primary data store, with a dedicated app role
- Redis 7 backing Sidekiq background jobs and Action Cable WebSockets
- Puma as the app server with multiple workers and
preload_app! - systemd units supervising Puma and Sidekiq (auto-restart, log journal, boot)
- Nginx reverse proxy fronting Puma over a Unix socket with
try_files $uri @puma - Let's Encrypt TLS with auto-renewal via Certbot
Why Self-Host Rails on a VPS?
Managed platforms like Heroku, Render, and Fly.io abstract away the operating system, which is convenient until you need to tune it. Running Rails on your own VPS gives you:
- Flat, predictable pricing — A VPS costs the same whether you serve 1,000 or 1,000,000 requests this month. No dyno-hours, no per-worker premiums, no surprise bandwidth bills.
- Full control of the stack — Choose any Ruby version, any Postgres extension, any kernel tuning. Add
pg_stat_statements,pgvector,timescaledb, or custom compiled gems with zero platform lock-in. - Colocated services — PostgreSQL, Redis, Sidekiq, and Puma share a localhost loopback. Intra-service latency drops to microseconds and egress bandwidth is free.
- Resource headroom for background work — Sidekiq, Action Cable, Active Job, nightly ETL, and cron tasks all run on the same box without provisioning separate worker instances.
- Compliance and data residency — You choose the data center. Keep EU customer data in Germany. Keep healthcare data on a HIPAA-eligible host. You control the data lifecycle end to end.
- No cold starts — Your Puma workers stay warm forever. First-request latency stays under 100 ms even after a quiet hour.
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server (PuTTY on Windows, or the built-in terminal on macOS/Linux)
- A domain name pointed at your server's IP (for TLS in Step 14)
- At least 2 GB of RAM for a single Puma worker + PostgreSQL + Redis (4-8 GB recommended for real workloads)
- At least 20 GB of disk space for the OS, Ruby toolchain, Postgres data, and your app
Recommended Plan: CloudCore Starter>
For most Rails apps in production we recommend the CloudCore Starter plan:>
- 3 vCPU cores
- 8 GB RAM
- 75 GB NVMe SSD
- Unmetered bandwidth>
This gives you room for 2-4 Puma workers, a healthy PostgreSQL buffer pool, Redis, and a Sidekiq process — plus headroom for traffic spikes. For heavier workloads, scale up to CloudCore Professional or beyond.
Connect to your server via SSH to get started:
ssh root@your-server-ipStep 1: Update System Packages and Install Build Dependencies
Start by updating the package index and installing the libraries Ruby and common gems need to compile native extensions. Missing one of these is the single most common source of bundle install failures, so we install the full set up front.
sudo apt update && sudo apt upgrade -yInstall the build toolchain and library headers:
sudo apt install -y \
git curl wget ca-certificates gnupg lsb-release \
build-essential autoconf bison \
libssl-dev libyaml-dev libreadline-dev zlib1g-dev \
libncurses-dev libffi-dev libgdbm-dev libdb-dev \
libpq-dev libxml2-dev libxslt1-dev libvips imagemagick \
pkg-configWhat each group of packages is for:
build-essential,autoconf,bison— gcc/make toolchain plus parser generators required to compile Ruby from source.libssl-dev,libyaml-dev,libreadline-dev,zlib1g-dev,libffi-dev,libgdbm-dev— core Ruby dependencies for TLS, YAML parsing, interactive IRB, compression, FFI, and GDBM.libpq-dev— PostgreSQL client headers needed by thepggem. Skipping this is the number-one cause ofbundle installfailing on a Rails app.libxml2-dev,libxslt1-dev— needed bynokogiriwhen it compiles against system libraries.libvips,imagemagick— Active Storage image processing backends.
sudo rebootStep 2: Create a Deploy User
Never run a Rails app as root. Create a dedicated unprivileged user that owns your code, gems, and Puma sockets.
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG sudo deployCopy your SSH key so you can log in as deploy:
sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploySwitch to the new user for the rest of the guide:
sudo su - deployAll subsequent commands assume you are the deploy user unless a step explicitly uses sudo.
Step 3: Install rbenv and ruby-build
rbenv is the de-facto Ruby version manager for production servers. It is lightweight, has no shell-function magic, and lets multiple Ruby versions coexist cleanly — which matters when you upgrade Rails across projects.
Clone rbenv and the ruby-build plugin into the deploy user's home directory:
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-buildWire it into the shell:
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init - bash)"' >> ~/.bashrc
source ~/.bashrcVerify rbenv is on PATH:
rbenv --versionExpected output:
rbenv 1.2.0-78-g89909faStep 4: Install Ruby 3.3.x
Rails 7.1+ officially supports Ruby 3.1, 3.2, and 3.3. We recommend Ruby 3.3 for any new install — it delivers YJIT improvements, GC compaction by default, and the fastest performance Ruby has ever shipped.
List the Ruby versions ruby-build knows about:
rbenv install --list | grep '^3\.3'Install the latest 3.3.x:
rbenv install 3.3.6
rbenv global 3.3.6The compile step takes 5-10 minutes on a 3 vCPU server. Expected final output:
Installed ruby-3.3.6 to /home/deploy/.rbenv/versions/3.3.6Verify:
ruby -vExpected output:
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [x86_64-linux]Turn off documentation generation for gems
Generating RDoc every time you install a gem wastes minutes and disk. Disable it globally:
echo "gem: --no-document" >> ~/.gemrcStep 5: Install Bundler and Rails
With Ruby in place, install Bundler (the gem dependency manager) and Rails:
gem install bundler
gem install rails -v '~> 7.1'
rbenv rehashVerify:
bundler -v
rails -vExpected output:
Bundler version 2.5.23
Rails 7.1.5rbenv rehashregenerates the shim files in~/.rbenv/shimsso new gem executables (rails,bundle,sidekiq, etc.) become available on PATH. Run it any time you install a gem that ships binaries.
Step 6: Install and Configure PostgreSQL 16
PostgreSQL is Rails' default production database and the right choice for almost every new app. Install version 16 from the official PostgreSQL APT repository (Ubuntu ships an older version by default).
Switch back to a user with sudo:
exit # back to your sudo userAdd the PGDG repository:
sudo install -d /usr/share/postgresql-common/pgdg sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ --fail https://www.postgresql.org/media/keys/ACCC4CF8.ascecho "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \ https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt update sudo apt install -y postgresql-16 postgresql-contrib-16
Check it is running:
sudo systemctl status postgresqlCreate a database role for your app
Create a PostgreSQL role that matches the deploy OS user. This lets Rails connect via the Unix socket with no password.
sudo -u postgres createuser --createdb --pwprompt deployEnter a password when prompted (store it somewhere secure — you will put it in Rails credentials). Create the production database:
sudo -u postgres createdb --owner=deploy myapp_productionVerify the connection
sudo su - deploy
psql -d myapp_production -c 'SELECT version();'Expected output:
PostgreSQL 16.4 (Ubuntu 16.4-1.pgdg24.04+1) on x86_64-pc-linux-gnu ...For a deeper walkthrough of Postgres tuning, replication, and backups, see our PostgreSQL 16 on Ubuntu guide.
Step 7: Install Redis for Sidekiq and Action Cable
Redis is how modern Rails apps run background jobs (Sidekiq), broadcast WebSocket messages (Action Cable), and cache fragments and low-level data (Rails.cache). One Redis instance serves all three roles on a single VPS.
Install from the Ubuntu repository (24.04 ships Redis 7):
sudo apt install -y redis-serverConfigure it to start on boot and bind to localhost only (the default):
sudo systemctl enable --now redis-serverVerify:
redis-cli pingExpected output:
PONGHarden Redis for production
Even on a firewalled box, enable memory limits and persistence. Edit /etc/redis/redis.conf:
sudo sed -i 's/^# maxmemory .*/maxmemory 512mb/' /etc/redis/redis.conf
sudo sed -i 's/^# maxmemory-policy .*/maxmemory-policy allkeys-lru/' /etc/redis/redis.conf
sudo systemctl restart redis-serverFor a deeper Redis tuning guide (persistence modes, replication, SSL), see Redis on Ubuntu.
Step 8: Create or Clone Your Rails App
Switch back to the deploy user and put your app in /home/deploy/apps/myapp:
sudo su - deploy
mkdir -p ~/apps
cd ~/appsOption A: Start a fresh Rails 7 app
rails new myapp --database=postgresql --css=tailwind
cd myappThe --database=postgresql flag wires up the pg gem and a production database config pointing at PostgreSQL. --css=tailwind adds the tailwindcss-rails gem (swap for --css=bootstrap or omit entirely).
Option B: Clone an existing Rails app from Git
git clone [email protected]:your-org/myapp.git
cd myappMake sure Gemfile specifies a Ruby version compatible with what you installed:
# Gemfile
ruby "3.3.6"Configure config/database.yml for production
The generator sets sensible defaults, but confirm the production block uses environment variables for credentials:
production:
<<: *default
database: myapp_production
username: deploy
password: <%= ENV["MYAPP_DATABASE_PASSWORD"] %>
host: localhostStep 9: Configure Credentials and SECRET_KEY_BASE
Rails 7 ships with encrypted credentials (config/credentials.yml.enc) that replace the old secrets.yml. The file is committed to Git; the master.key that decrypts it is not.
Generate a production master key
If you started a fresh app, Rails already created config/master.key and added it to .gitignore. For a cloned app, you need to place the production master key on the server. Never commit master.key to Git.
Copy the key from your local machine over SSH:
# from your laptop
scp config/credentials/production.key deploy@your-server-ip:~/apps/myapp/config/credentials/Or set the RAILS_MASTER_KEY environment variable in the systemd unit (we do this in Step 12).
Edit production credentials
From the server:
cd ~/apps/myapp
EDITOR="nano" bin/rails credentials:edit --environment productionRails decrypts the file, opens it in nano, and re-encrypts on save. Add the database password, any API keys, and SECRET_KEY_BASE:
secret_key_base: <long-random-string-from-rails-secret>
database_password: <the-password-you-set-in-step-6>
aws:
access_key_id: ...
secret_access_key: ...Generate a strong SECRET_KEY_BASE:
bin/rails secretPaste the output as the secret_key_base value. Rails uses this to sign cookies, CSRF tokens, and encrypted session data — treat it like a root password.
Step 10: Install Gems, Migrate, and Precompile Assets
Install gems without development/test groups:
bundle config set --local deployment 'true'
bundle config set --local without 'development test'
bundle installThe --deployment flag locks Bundler to the exact versions in Gemfile.lock and installs gems into vendor/bundle. This matches what Capistrano and Kamal expect.
Create and migrate the database:
RAILS_ENV=production bin/rails db:create db:migrateIf you already created the database in Step 6, db:create will be a no-op.
Precompile assets. Rails 7 defaults to Propshaft for asset serving and Import Maps for JavaScript — neither requires Node.js on the server. If your app uses jsbundling-rails with esbuild/Webpack or cssbundling-rails, install Node first:
# only if your app uses jsbundling or cssbundling
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsThen precompile:
RAILS_ENV=production SECRET_KEY_BASE=dummy bin/rails assets:precompileWe pass a dummy SECRET_KEY_BASE because assets:precompile boots Rails, which requires it. The real one is read from credentials at runtime.
Smoke-test the app in production mode:
RAILS_ENV=production bin/rails server -p 3000Hit http://your-server-ip:3000 from a browser or curl. You should see your app (or the default Rails welcome page). Press Ctrl+C and move on — we will replace rails server with Puma under systemd.
Step 11: Configure Puma for Production
Puma is Rails 7's default app server. For production you want multiple workers (forked processes) with preload_app! enabled so each worker shares memory via copy-on-write, dramatically reducing RAM usage.
Edit config/puma.rb:
# config/puma.rbmax_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
min_threads_count = ENV.fetch("RAILS_MIN_THREADS", max_threads_count)
threads min_threads_count, max_threads_count
workers ENV.fetch("WEB_CONCURRENCY", 3)
preload_app!
rails_env = ENV.fetch("RAILS_ENV", "production")
environment rails_env
app_dir = File.expand_path("..", __dir__)
shared_dir = "#{app_dir}/tmp"
Listen on a Unix socket so Nginx can proxy to it
bind "unix://#{shared_dir}/sockets/puma.sock"Logging
stdout_redirect "#{shared_dir}/logs/puma.stdout.log",
"#{shared_dir}/logs/puma.stderr.log",
truePID and state files
pidfile "#{shared_dir}/pids/puma.pid"
state_path "#{shared_dir}/pids/puma.state"Allow Puma to be restarted by rails restart command
plugin :tmp_restartReconnect DB and Redis in each forked worker
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
endCreate the directories Puma writes into:
mkdir -p ~/apps/myapp/tmp/{sockets,pids,logs}Tuning the worker and thread counts
A good starting formula for a 3 vCPU / 8 GB VPS:
WEB_CONCURRENCY=3— one worker per vCPU. Each worker is a separate OS process.RAILS_MAX_THREADS=5— 5 threads per worker. With MRI's GIL, threads are useful for I/O-bound work (DB queries, HTTP calls).- Total concurrency = workers × threads = 15 in-flight requests.
- RAM — each worker uses ~250-400 MB after preload. 3 workers ≈ 1 GB.
RAILS_MAX_THREADS and DB_POOL together — your database pool must be at least as large as your thread count:# config/database.yml
default: &default
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>Step 12: Create systemd Units for Puma and Sidekiq
systemd is the cleanest way to supervise Rails processes on Ubuntu — it handles restarts, log aggregation via journalctl, and boot ordering.
Puma unit
Create /etc/systemd/system/puma.service (as sudo):
sudo tee /etc/systemd/system/puma.service > /dev/null <<'EOF' [Unit] Description=Puma HTTP Server for myapp After=network.target postgresql.service redis-server.service Requires=postgresql.service redis-server.service[Service] Type=simple User=deploy Group=deploy WorkingDirectory=/home/deploy/apps/myapp
Environment=RAILS_ENV=production Environment=RACK_ENV=production Environment=RAILS_LOG_TO_STDOUT=1 Environment=RAILS_SERVE_STATIC_FILES=1 Environment=WEB_CONCURRENCY=3 Environment=RAILS_MAX_THREADS=5 EnvironmentFile=-/home/deploy/apps/myapp/.env.production
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb ExecReload=/bin/kill -USR1 $MAINPID
Restart=always RestartSec=3 KillMode=mixed TimeoutStopSec=30
StandardOutput=journal StandardError=journal SyslogIdentifier=puma
[Install] WantedBy=multi-user.target EOF
Create .env.production in the app directory for any non-credential env vars (like RAILS_MASTER_KEY if you prefer env over file):
# /home/deploy/apps/myapp/.env.production
RAILS_MASTER_KEY=your-master-key-here
chmod 600 /home/deploy/apps/myapp/.env.productionSidekiq unit
Sidekiq processes background jobs pulled from Redis. Create /etc/systemd/system/sidekiq.service:
sudo tee /etc/systemd/system/sidekiq.service > /dev/null <<'EOF' [Unit] Description=Sidekiq Background Worker for myapp After=network.target postgresql.service redis-server.service puma.service Requires=redis-server.service[Service] Type=simple User=deploy Group=deploy WorkingDirectory=/home/deploy/apps/myapp
Environment=RAILS_ENV=production Environment=RAILS_LOG_TO_STDOUT=1 EnvironmentFile=-/home/deploy/apps/myapp/.env.production
ExecStart=/home/deploy/.rbenv/shims/bundle exec sidekiq -e production -C config/sidekiq.yml
Restart=always RestartSec=3 KillMode=mixed TimeoutStopSec=60
StandardOutput=journal StandardError=journal SyslogIdentifier=sidekiq
[Install] WantedBy=multi-user.target EOF
Enable and start both services
sudo systemctl daemon-reload
sudo systemctl enable --now puma.service sidekiq.service
sudo systemctl status puma.service
sudo systemctl status sidekiq.serviceTail logs with:
sudo journalctl -u puma -f
sudo journalctl -u sidekiq -fStep 13: Configure Nginx as a Reverse Proxy
Nginx fronts Puma, terminates TLS, serves precompiled assets directly (much faster than proxying them to Rails), and handles connection pooling.
Install Nginx:
sudo apt install -y nginxCreate /etc/nginx/sites-available/myapp:
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'EOF' upstream puma { server unix:///home/deploy/apps/myapp/tmp/sockets/puma.sock fail_timeout=0; }server { listen 80; server_name myapp.example.com;
root /home/deploy/apps/myapp/public;
client_max_body_size 50m; keepalive_timeout 10;
# Serve precompiled assets directly with far-future expires location ~ ^/(assets|packs|images|fonts|favicon\.ico|robots\.txt|sitemap\.xml)/ { gzip_static on; expires max; add_header Cache-Control public; access_log off; }
# Fall through to Puma for everything else try_files $uri/index.html $uri @puma;
location @puma { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; proxy_pass http://puma;
# WebSockets (Action Cable) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
# Long-lived requests (streaming, uploads) proxy_read_timeout 300s; proxy_send_timeout 300s; }
error_page 500 502 503 504 /500.html; keepalive_timeout 10; } EOF
Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxThe deploy user's home directory needs to be traversable by www-data:
sudo chmod o+x /home/deploy
sudo chmod o+x /home/deploy/apps
sudo chmod o+x /home/deploy/apps/myappVisit http://myapp.example.com in a browser — you should see your Rails app served through Nginx. For a deeper dive on Nginx tuning (HTTP/2, gzip, brotli, rate limiting), see Nginx on Ubuntu.
Step 14: Enable TLS with Let's Encrypt
Certbot automates Let's Encrypt certificate issuance and renewal.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.comCertbot will:
/etc/letsencrypt/live/myapp.example.com/.Verify renewal works without actually renewing:
sudo certbot renew --dry-runYour site is now available at https://myapp.example.com with an A+ TLS grade.
Step 15: The Deploy Flow
With systemd supervising Puma and Sidekiq, every deploy is a small, predictable sequence. For a Git-based workflow:
# on the server as deploy user
cd ~/apps/myappgit pull origin main
bundle install --deployment --without development test
RAILS_ENV=production bin/rails db:migrate
RAILS_ENV=production bin/rails assets:precompile
restart app server and background workers
sudo systemctl restart puma.service
sudo systemctl restart sidekiq.serviceZero-downtime restarts
Puma supports phased restart — workers restart one at a time so the site never goes fully offline:
sudo systemctl reload puma.serviceThe ExecReload=/bin/kill -USR1 $MAINPID line in the systemd unit triggers Puma's USR1 signal, which performs a phased restart. Use restart (SIGTERM) after schema migrations that change Active Record classes; use reload for code-only changes.
Wrap the flow in a script
Put the sequence into bin/deploy.sh:
#!/usr/bin/env bash set -euo pipefailcd ~/apps/myapp
git pull origin main bundle install --deployment --without development test RAILS_ENV=production bin/rails db:migrate RAILS_ENV=production bin/rails assets:precompile
sudo systemctl reload puma.service sudo systemctl restart sidekiq.service
echo "Deploy complete at $(date)"
chmod +x ~/apps/myapp/bin/deploy.shGrant deploy the right to reload/restart only these specific services without a password, via /etc/sudoers.d/deploy-services:
deploy ALL=NOPASSWD: /bin/systemctl reload puma.service, /bin/systemctl restart puma.service, /bin/systemctl restart sidekiq.serviceAlternative: Deploying with Kamal 2
If you prefer a modern container-based workflow, Kamal 2 (the 37signals-built successor to Capistrano) deploys Rails apps as Docker containers over SSH. Kamal handles zero-downtime rollouts, health checks, and a built-in proxy (kamal-proxy) that replaces Nginx entirely.
The trade-off: Kamal requires Docker on the server and an image registry. The upside: deploys are atomic, rollbacks are one command, and the same config/deploy.yml works for one server or a fleet.
A typical Kamal workflow, once configured:
bundle add kamal
bin/kamal setup # first-time install
bin/kamal deploy # subsequent deploys
bin/kamal app logs -f # tail logs
bin/kamal rollback # instant rollbackKamal is an excellent choice for teams deploying to multiple servers, or for anyone who wants to keep the host OS pristine and ship everything as containers. For a single-server Rails 7 app, the rbenv + systemd + Nginx stack in this guide is simpler, uses less RAM, and keeps you closer to the metal.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
bundle install fails with An error occurred while installing pg | Missing PostgreSQL client headers | sudo apt install -y libpq-dev and rerun bundle install |
bundle install fails compiling nokogiri native extension | Missing libxml2/libxslt or build tools | sudo apt install -y build-essential libxml2-dev libxslt1-dev pkg-config |
LoadError: cannot load such file -- bundler/setup | Bundler not installed in current rbenv Ruby | gem install bundler && rbenv rehash |
Puma service fails with address already in use on the socket | Stale socket from a prior run | rm ~/apps/myapp/tmp/sockets/puma.sock && sudo systemctl restart puma |
| Nginx returns 502 Bad Gateway | Puma socket missing or permission denied | Check ls -la ~/apps/myapp/tmp/sockets/, verify chmod o+x /home/deploy, check journalctl -u puma -n 100 |
ActiveRecord::ConnectionNotEstablished: FATAL: Peer authentication failed | Postgres pg_hba.conf using peer auth for TCP | Connect via Unix socket (remove host from database.yml) or edit pg_hba.conf to use md5 |
| Sidekiq service keeps restarting | Redis not reachable or wrong URL | redis-cli ping to verify Redis, set REDIS_URL=redis://localhost:6379/0 in .env.production |
ActionView::Template::Error: The asset "application.css" is not present in the asset pipeline | Assets not precompiled or wrong manifest | Run RAILS_ENV=production bin/rails assets:precompile, check public/assets/.manifest.json exists |
Rails logs show ArgumentError: Missing secret_key_base | Credentials not decrypting | Verify RAILS_MASTER_KEY env var or config/credentials/production.key exists and matches |
PG::ConnectionBad: could not connect to server at boot | Puma started before Postgres | systemd unit already has After=postgresql.service — also add Requires=postgresql.service |
| Action Cable WebSocket fails with 404 | Nginx missing Upgrade / Connection headers | Verify the @puma block in /etc/nginx/sites-available/myapp includes the WebSocket headers from Step 13 |
Errno::ENOENT: No such file or directory @ rb_sysopen - tmp/pids/puma.pid | Puma tmp directories missing | mkdir -p ~/apps/myapp/tmp/{sockets,pids,logs} |
Viewing logs
The single most useful debugging tool is journalctl:
sudo journalctl -u puma -f # tail Puma
sudo journalctl -u sidekiq -f # tail Sidekiq
sudo journalctl -u nginx -n 100 # last 100 Nginx lines
tail -f ~/apps/myapp/log/production.logFAQ
Which Ruby version should I use for a new Rails 7 app?
Use the latest Ruby 3.3.x (3.3.6 at the time of writing). Rails 7.1 and 7.2 officially support Ruby 3.1, 3.2, and 3.3, but 3.3 ships the fastest YJIT to date — in production workloads you will see 10-20% lower CPU time compared to 3.2 on the same code. Avoid 3.0 and earlier on any new install; they are out of security support. Upgrade existing apps to 3.3 during your next scheduled maintenance window. rbenv makes coexisting versions trivial, so you can test locally before flipping production.
Do I need Node.js on the server?
Rails 7 defaults eliminate Node from the server for most apps. Propshaft (the default asset pipeline) serves precompiled CSS and JavaScript with no compilation step. Import Maps (default for JavaScript) lets browsers load ES modules directly from the asset host with no bundler. If you stick with these defaults, you do not need Node on the server — compile assets in CI or on your laptop and ship them with your deploy.
You do need Node on the server if your app uses jsbundling-rails (esbuild/Rollup/Webpack) or cssbundling-rails (Tailwind via PostCSS, Bootstrap, Sass), and you precompile on the server rather than in CI. In that case install Node 20 LTS as shown in Step 10.
How many Puma workers and threads should I configure?
The starting formula is workers = number of vCPUs and threads per worker = 5. On a 3 vCPU / 8 GB VPS, that means WEB_CONCURRENCY=3 and RAILS_MAX_THREADS=5, yielding 15 concurrent in-flight requests with roughly 1 GB of RAM consumed by Puma.
Threads help with I/O-bound work (DB queries, API calls, file uploads) because MRI's GIL releases during I/O. Workers help with CPU-bound work because each is a separate process. If your app is mostly database queries and external API calls, lean on threads; if it does heavy in-Ruby computation (image transforms, PDF generation), lean on workers.
Your database.yml pool size must be at least as large as your thread count, or Active Record will raise ActiveRecord::ConnectionTimeoutError under load.
Should I use Capistrano, Kamal, or just a shell script?
For a single server, a shell script like the one in Step 15 is the simplest and most reliable option. It is explicit, easy to debug, and has zero external dependencies.
Use Capistrano when you need the traditional current/, releases/, and shared folder pattern with instant rollback — Capistrano's mature ecosystem still has more plugins than anything else.
Use Kamal 2 when you want containerized deploys, atomic image-based rollouts, and multi-server support with a single YAML config. Kamal is increasingly the default for new Rails apps at 37signals and similar shops.
All three are fine. Pick the one that matches your team's operational skill set.
How do I add SSL for Action Cable WebSockets?
Once Certbot has added the TLS block to your Nginx config (Step 14), Action Cable works over wss://myapp.example.com/cable with no additional config — the Upgrade and Connection headers in the @puma location block handle the WebSocket handshake. In config/cable.yml, make sure production uses Redis:
production:
adapter: redis
url: redis://localhost:6379/1
channel_prefix: myapp_productionAnd config/environments/production.rb allows connections from your host:
config.action_cable.allowed_request_origins = [
"https://myapp.example.com"
]How do I back up the Postgres database?
Add a nightly pg_dump cron entry for the deploy user:
crontab -e0 3 * pg_dump -Fc myapp_production > /home/deploy/backups/myapp_$(date +\%Y\%m\%d).dump && find /home/deploy/backups -mtime +14 -deleteFor production-grade backups, push the dump to S3-compatible storage (Backblaze B2, Cloudflare R2, Wasabi) with rclone or aws s3 cp. Test restores quarterly — a backup you have never restored is not a backup.
Next Steps
Now that Rails is running on your VPS, here are recommended next steps to harden and extend your setup:
- Enable application monitoring — Install AppSignal, Scout APM, or self-host Sentry to track request latency, exceptions, and Sidekiq queue health. Errors in production are invisible without it.
- Set up database backups to object storage — Use
rcloneoraws s3to ship nightlypg_dumparchives to Backblaze B2 or Cloudflare R2. Backups on the same VPS are not backups.
- Add a CDN in front of Nginx — Put Cloudflare or Bunny.net in front of your domain to cache assets globally, absorb traffic spikes, and block malicious bots. Rails'
public/assetshash-fingerprinted filenames are perfectly cacheable.
- Harden SSH — Disable password authentication, change the default port, and install Fail2Ban. See our Linux hardening guide for a complete checklist.
- Add staging — Clone this setup to a second VPS and deploy a
stagingbranch. Never test migrations on production — staging costs one small VPS and saves one very bad evening.
- Explore the Rails ecosystem — Browse rubyonrails.org for the official guides, rbenv.org for Ruby version management patterns, and rubygems.org for the gem index.
Skip the Manual Install — Launch a Rails-Ready VPS>
Our CloudCore Starter plan is pre-tuned for Ruby workloads — the right kernel parameters, NVMe storage, and unmetered bandwidth to run Rails in production from day one.>
- 3 vCPU cores, 8 GB RAM, 75 GB NVMe SSD
- Ubuntu 24.04 LTS ready in under 60 seconds
- Unmetered bandwidth, no per-request fees
- Deploy Rails, PostgreSQL, Redis, Sidekiq, and Nginx on one box
- Scale up in-place when your app grows>
Deploy Your Rails VPS Now — start shipping today.