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 sudo privileges.
  • 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.key file, 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.

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_dump and 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.