Your AI-powered Rails app is ready. Kamal is your deploy tool. Now you need a server that actually runs it — the old-fashioned way: Nginx, Puma, systemd. No Heroku. No Render. No serverless. Just you, your VPS, and the command line.
This is how Rails ran before PaaS existed. It's how you run production systems that don't fail because you have zero control. In this post, we'll configure a bare Ubuntu 22.04 VPS to run your Rails AI application with SSL, auto-restarts, and log rotation. If you can't do this, you don't understand your stack.
Assumptions
- Ubuntu 22.04 LTS (works on Debian too)
- Root or sudo access
- Domain pointed at your VPS IP
- Your app is configured for production (Kamal deployed it)
Create Deploy User
Never run your app as root. Create a dedicated deploy user:
sudo adduser deploy
sudo usermod -aG sudo deploy
su - deploy
Set up SSH key authentication. On your local machine:
ssh-copy-id deploy@your-vps-ip
Disable root login and password auth in /etc/ssh/sshd_config:
sudo nano /etc/ssh/sshd_config
Set these lines:
PermitRootLogin no
PasswordAuthentication no
sudo systemctl restart ssh
Install Ruby, Node, and Yarn
Use rbenv or rvm for Ruby. I use rbenv because it's simpler:
# Install dependencies
sudo apt update
sudo apt install -y curl git build-essential libssl-dev zlib1g-dev libsqlite3-dev
# Install rbenv
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash
# Add to PATH
echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
source ~/.bashrc
# Install Ruby
rbenv install 3.3.0
rbenv global 3.3.0
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Install Yarn
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update
sudo apt install yarn
Install and Configure Nginx
Nginx is your reverse proxy. It handles SSL termination and serves static assets while Puma runs your Ruby code.
sudo apt install nginx
sudo systemctl start nginx
sudo systemctl enable nginx
Create your site configuration:
sudo nano /etc/nginx/sites-available/my-app.conf
Add this configuration:
upstream puma {
server unix:///var/www/my-app/current/tmp/sockets/puma.sock fail_timeout=0;
}
server {
listen 80;
server_name your-domain.com;
root /var/www/my-app/current/public;
access_log /var/log/nginx/my-app.access.log;
error_log /var/log/nginx/my-app.error.log;
location / {
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;
}
location ^~ /assets/ {
gzip_static on;
expires max;
add_header Cache-Control "public, must-revalidate";
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/my-app.conf /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx
Configure systemd for Puma
systemd keeps your Puma process running. If it crashes, systemd restarts it. This is production 101.
Create the service file:
sudo nano /etc/systemd/system/puma.service
Add this configuration:
[Unit]
Description=Puma HTTP Server
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/my-app/current
Environment=RAILS_ENV=production
Environment=RAILS_MAX_THREADS=5
Environment=RAILS_LOG_TO_STDOUT=true
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Reload systemd and start Puma:
sudo systemctl daemon-reload
sudo systemctl enable puma
sudo systemctl start puma
Check status:
sudo systemctl status puma
sudo journalctl -u puma -f
SSL with Let's Encrypt
Free SSL certificates. Certbot automates everything.
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d your-domain.com
Follow the prompts. Certbot automatically updates your Nginx config for SSL.
Log Rotation
Your app generates logs. Without rotation, your disk fills up and your server dies.
Create a logrotate config:
sudo nano /etc/logrotate.d/my-app
Add this configuration:
/var/www/my-app/current/log/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 0644 deploy deploy
sharedscripts
postrotate
/bin/kill -USR1 $(cat /var/www/my-app/current/tmp/pids/puma.pid 2>/dev/null) 2>/dev/null || true
endscript
}
Test logrotate:
sudo logrotate -d /etc/logrotate.d/my-app
sudo logrotate -f /etc/logrotate.d/my-app
Puma Configuration for Production
Create a production-ready config/puma.rb in your Rails app:
workers Integer(ENV.fetch('WEB_CONCURRENCY', 2))
threads_count = Integer(ENV.fetch('RAILS_MAX_THREADS', 5))
threads threads_count, threads_count
rackup DefaultRackup if respond_to?(:rackup)
environment ENV.fetch('RAILS_ENV', 'development')
if ENV['RAILS_ENV'] == 'production'
bind "unix:///var/www/my-app/current/tmp/sockets/puma.sock"
pidfile "/var/www/my-app/current/tmp/pids/puma.pid"
stdout_redirect '/var/www/my-app/current/log/puma.stdout.log', '/var/www/my-app/current/log/puma.stderr.log', true
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord::Base)
end
end
Deployment with Kamal
With Kamal, you deploy your app to /var/www/my-app/current/. Kamal handles the rest:
# Deploy
kamal deploy
# Check status
kamal status
# Logs
kamal logs -f
# Rebuild if needed
kamal deploy --no-push
Key Takeaways
- Always use a non-root user for deploying and running your app.
- systemd is your friend — it handles restarts and process management.
- Nginx reverse proxy — handles SSL, serves static assets, and forwards to Puma.
- Let's Encrypt — free SSL certificates that auto-renew.
- Log rotation — prevents disk space issues.
This stack has been running production Rails apps for over a decade. It's not fancy, but it works. And when it breaks, you know exactly where to look.
Next post: CI/CD for Rails — GitHub Actions, test pipeline, and auto-deploy to your VPS.
Top comments (0)