Ruby on Rails ships with Puma, a multi-threaded application server that is production ready on its own. What it does not provide is HTTPS termination, efficient static file serving or process supervision, so on a Linux server you pair Puma with Nginx and systemd. In this tutorial you will install Ruby with rbenv, deploy an existing Rails application backed by PostgreSQL, run Puma as a systemd service and put Nginx with a Let's Encrypt certificate in front of it, all on Ubuntu 24.04.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM (compiling Ruby and precompiling assets are memory hungry).
- A non-root user with
sudoprivileges. - A domain name (
your_domain) with an A record pointing to the server's public IP. - UFW enabled with SSH allowed (
sudo ufw allow OpenSSH && sudo ufw enable). - A Rails application in a Git repository that uses PostgreSQL, plus its
config/master.keyfile, which is not committed to Git.
In this guide the application is called myapp, runs as a dedicated deploy user and lives in /var/www/myapp. Replace these names with your own.
Step 1 - Installing system dependencies
Ruby is compiled from source by ruby-build, which needs a compiler and several development libraries. Install them together with PostgreSQL, its client headers (for the pg gem) and Nginx:
sudo apt update
sudo apt install git curl autoconf patch build-essential rustc libssl-dev libyaml-dev libreadline-dev zlib1g-dev libgmp-dev libffi-dev libpq-dev postgresql nginx
rustc is optional; it lets Ruby build with the YJIT just-in-time compiler.
Create the deploy user that will own and run the application:
sudo adduser --disabled-password --gecos "" deploy
Create the application directory and give it to that user. Nginx must be able to read the public directory, which is why the app lives under /var/www rather than in a home directory (home directories are not world readable on Ubuntu 24.04):
sudo mkdir -p /var/www/myapp
sudo chown deploy:deploy /var/www/myapp
Step 2 - Installing Ruby with rbenv
rbenv lets each application use the exact Ruby version it declares in .ruby-version. Switch to the deploy user for this step and the next ones until Step 6:
sudo -iu deploy
Clone rbenv and the ruby-build plugin, then load rbenv in the shell:
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
source ~/.bashrc
Verify that rbenv is on the PATH:
rbenv --version
rbenv 1.3.2
Clone your application:
git clone https://github.com/your_user/myapp.git /var/www/myapp
cd /var/www/myapp
Install the Ruby version the application asks for. Without an argument, rbenv install reads it from .ruby-version. Compilation takes several minutes:
cat .ruby-version
rbenv install
Once it finishes, confirm that the application directory uses that version:
ruby -v
ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [x86_64-linux]
The exact version depends on your .ruby-version file. Bundler ships with Ruby, so no extra install is needed.
Step 3 - Creating the PostgreSQL role
Type exit to return to your sudo user, then create a database role for the app. Replace your_strong_password with a long random password made of letters and digits:
exit
sudo -u postgres createuser --createdb --pwprompt myapp
--createdb lets Rails create its own databases. This matters for Rails 8 apps, which use extra databases for Solid Cache, Solid Queue and Solid Cable in production.
Check the role:
psql -h localhost -U myapp -d postgres -c 'SELECT current_user;'
current_user
--------------
myapp
(1 row)
Open config/database.yml in your application and make sure the production section uses this role, reads the password from an environment variable and connects over TCP. A typical block looks like this:
production:
primary: &primary_production
<<: *default
host: localhost
database: myapp_production
username: myapp
password: <%= ENV["MYAPP_DATABASE_PASSWORD"] %>
The host: localhost line matters: without it, Rails connects over the Unix socket, where PostgreSQL uses peer authentication and rejects the deploy system user. Commit this change to your repository.
Step 4 - Configuring secrets and installing gems
Rails decrypts config/credentials.yml.enc with the master key. Instead of copying master.key into the repository, store it together with the other settings in an environment file that both systemd and your shell can load:
sudo -iu deploy
nano ~/myapp.env
RAILS_ENV=production
RAILS_MASTER_KEY=contents_of_your_config_master_key
MYAPP_DATABASE_PASSWORD=your_strong_password
RAILS_LOG_TO_STDOUT=1
WEB_CONCURRENCY=2
RAILS_MAX_THREADS=3
WEB_CONCURRENCY sets the number of Puma worker processes (one per CPU core is a good start) and RAILS_MAX_THREADS the threads per worker; Puma and the config/puma.rb generated by Rails read these variables. Protect the file:
chmod 600 ~/myapp.env
Load it into the current shell:
set -a
source ~/myapp.env
set +a
Configure Bundler to install only production gems into the application's vendor/bundle directory, then install them:
cd /var/www/myapp
bundle config set --local deployment true
bundle config set --local without 'development test'
bundle install
Bundle complete! 24 Gemfile dependencies, 97 gems now installed.
Gems in the groups 'development' and 'test' were not installed.
Bundled gems are installed into `./vendor/bundle`
Step 5 - Preparing the database and assets
Create the databases and run migrations. db:prepare creates missing databases and loads the schema, or runs pending migrations when they already exist:
bin/rails db:prepare
Compile CSS, JavaScript and images into public/assets, with a digest in each filename so browsers can cache them forever:
bin/rails assets:precompile
ls public/assets | head -n 3
Start Puma in the foreground to check that the app boots, bound to localhost:
bundle exec puma -C config/puma.rb -b tcp://127.0.0.1:3000
From a second SSH session, send a request. Recent Rails versions enable config.assume_ssl and config.force_ssl in production, so pass the header Nginx will send later:
curl -I -H "Host: your_domain" -H "X-Forwarded-Proto: https" http://127.0.0.1:3000/up
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
The /up health check route is generated by Rails 7.1 and later. On older apps request / instead. Stop Puma with CTRL+C and type exit to return to your sudo user.
Step 6 - Running Puma as a systemd service
Create a unit file so systemd starts Puma at boot and restarts it if it crashes:
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=Puma for myapp
After=network.target postgresql.service
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp
EnvironmentFile=/home/deploy/myapp.env
ExecStart=/home/deploy/.rbenv/bin/rbenv exec bundle exec puma -C config/puma.rb -b tcp://127.0.0.1:3000
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
rbenv exec selects the Ruby version from .ruby-version in the working directory, and the -b option overrides the port in config/puma.rb so Puma only listens on localhost.
Start the service and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
Confirm it is running and listening only on the loopback interface:
sudo systemctl status myapp --no-pager
sudo ss -ltnp | grep 3000
LISTEN 0 1024 127.0.0.1:3000 0.0.0.0:* users:(("ruby",pid=4121,fd=7))
Application logs go to the journal: sudo journalctl -u myapp -f.
NoteIf your app uses Solid Queue for background jobs, the simplest option on a single server is to run it inside Puma. Add
SOLID_QUEUE_IN_PUMA=truetomyapp.env(the default Rails 8puma.rbchecks it) and restart the service.
Step 7 - Configuring Nginx
Nginx serves files from public/ directly and forwards everything else to Puma:
sudo nano /etc/nginx/sites-available/myapp
upstream puma_myapp {
server 127.0.0.1:3000;
}
server {
listen 80;
listen [::]:80;
server_name your_domain;
root /var/www/myapp/public;
client_max_body_size 20M;
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
location / {
try_files $uri @puma;
}
location @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;
proxy_pass http://puma_myapp;
}
}
Enable the site, remove the default one, test and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
Check that Nginx serves a compiled asset itself. Replace the file name with one from ls /var/www/myapp/public/assets:
curl -I http://your_domain/assets/application-0123abcd.css
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Cache-Control: public, max-age=31536000, immutable
Step 8 - Enabling HTTPS
Install Certbot and request a certificate. The Nginx plugin adds the certificate to the server block and redirects HTTP to HTTPS:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain
Renewal is handled by a systemd timer; test it with:
sudo certbot renew --dry-run
Open https://your_domain in a browser. The app should load with a valid certificate. If your app has config.hosts set in config/environments/production.rb, make sure your_domain is in the list.
Step 9 - Deploying updates
To release a new version, run these commands as the deploy user, then restart the service from your sudo user:
sudo -iu deploy
cd /var/www/myapp
set -a; source ~/myapp.env; set +a
git pull
bundle install
bin/rails db:migrate
bin/rails assets:precompile
exit
sudo systemctl restart myapp
If .ruby-version changed, run rbenv install in the app directory before bundle install. Once the setup is stable, tools like Capistrano or Kamal can automate these steps.
Troubleshooting
502 Bad Gateway: Puma is not running. Read sudo journalctl -u myapp -n 50 --no-pager. A missing or wrong RAILS_MASTER_KEY shows up as ActiveSupport::MessageEncryptor::InvalidMessage, and a missing environment variable as KeyError.
PG::ConnectionBad: Peer authentication failed: Rails is connecting over the Unix socket. Add host: localhost to the production database configuration.
Redirect loop after enabling HTTPS: Rails does not see that the request was HTTPS. Check that the @puma location sets X-Forwarded-Proto $scheme.
rbenv install is killed during compilation: the server ran out of memory. Add a swap file or use a server with more RAM.
Conclusion
Your Rails application now runs under Puma as a systemd service, uses PostgreSQL, and is served through Nginx over HTTPS with long-lived caching for precompiled assets. The rbenv setup lets you upgrade Ruby per application by changing .ruby-version.
As next steps you can:
- Schedule PostgreSQL backups with
pg_dumpand a systemd timer. - Automate the update steps with Capistrano or move to container deployments with Kamal.
- Send exceptions to an error tracker so production failures do not go unnoticed.
