This guide deploys an existing Rails application to an Ubuntu VPS using the classic, dependable stack: Puma as the application server, systemd to supervise it, Nginx in front for HTTPS and static files, and PostgreSQL as the database.
1. Prepare the server
sudo apt update
sudo apt install -y nginx postgresql libpq-dev git build-essential
sudo adduser --disabled-password --gecos "" deploy
As the deploy user, install Ruby with rbenv or mise (matching your app’s .ruby-version) — see installing Ruby and Rails.
Create the database role:
sudo -u postgres createuser --pwprompt myapp
sudo -u postgres createdb -O myapp myapp_production
2. Get the code and install gems
sudo -u deploy -i
git clone [email protected]:you/myapp.git ~/myapp
cd ~/myapp
bundle config set --local deployment true
bundle config set --local without 'development test'
bundle install
3. Configuration and secrets
Rails reads secrets from encrypted credentials; the server needs the key. Create /home/deploy/myapp/.env.production (mode 600) or use systemd’s EnvironmentFile:
RAILS_ENV=production
RAILS_MASTER_KEY=…contents of config/master.key…
DATABASE_URL=postgres://myapp:[email protected]/myapp_production
RAILS_LOG_TO_STDOUT=1
RAILS_SERVE_STATIC_FILES=0
Then:
set -a; source .env.production; set +a
bin/rails assets:precompile
bin/rails db:migrate
In config/environments/production.rb, make sure config.force_ssl = true (or assume_ssl) works with the proxy, and config.hosts includes your domain.
4. Puma configuration
config/puma.rb (Rails generates one; key settings):
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 ENV.fetch("RAILS_ENV", "production")
- Workers are processes (use roughly one per CPU core, limited by RAM — each loads the app).
- Threads per worker handle concurrent requests; match
RAILS_MAX_THREADSwith the database pool size inconfig/database.yml. preload_app!saves memory (copy-on-write) but rules out phased restarts; this guide leaves it off so deploys can restart workers one at a time.
5. systemd service
/etc/systemd/system/myapp.service:
[Unit]
Description=myapp Puma
After=network.target postgresql.service
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/myapp
EnvironmentFile=/home/deploy/myapp/.env.production
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
(With mise, use its shim path instead.) USR1 triggers a phased restart in cluster mode: workers restart one at a time with the new code, so deploys cause no downtime.
sudo mkdir -p /home/deploy/myapp/tmp/sockets && sudo chown -R deploy: /home/deploy/myapp/tmp
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f
6. Nginx
upstream myapp {
server unix:/home/deploy/myapp/tmp/sockets/puma.sock;
}
server {
listen 80;
server_name example.com;
root /home/deploy/myapp/public;
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
try_files $uri @rails;
}
location @rails {
proxy_pass http://myapp;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
client_max_body_size 20M;
}
Nginx needs to traverse /home/deploy to reach public/ and the socket: sudo chmod 755 /home/deploy (or deploy under /srv/myapp instead). The upgrade headers support Action Cable WebSockets.
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com
7. Background jobs
Run Sidekiq, Solid Queue or GoodJob as another systemd service with the same EnvironmentFile and working directory, e.g. ExecStart=… bundle exec sidekiq or bin/jobs. Sidekiq needs Redis — see installing Redis.
8. Deploying updates
cd ~/myapp
git pull --ff-only
bundle install
set -a; source .env.production; set +a
bin/rails assets:precompile
bin/rails db:migrate
sudo systemctl reload myapp # phased restart (USR1)
Automate this with Capistrano, a CI pipeline (GitHub Actions) or switch to Kamal for container-based deploys.
Troubleshooting
- 502 — Puma not running or socket path mismatch;
journalctl -u myapp. Blocked host— add the domain toconfig.hosts.- Missing assets — precompile not run, or Nginx root not pointing to
public/. - Redirect loop with
force_ssl— missingX-Forwarded-Proto.
Related: Nginx reverse proxy and PostgreSQL backups.