Tools: Latest: VPS Setup for Rails — Nginx, Puma, systemd, SSL with Let's Encrypt
Assumptions
Create Deploy User
Install Ruby, Node, and Yarn
Install and Configure Nginx
Configure systemd for Puma
SSL with Let's Encrypt
Log Rotation
Puma Configuration for Production
Deployment with Kamal
Key Takeaways 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. Never run your app as root. Create a dedicated deploy user: Set up SSH key authentication. On your local machine: Disable root login and password auth in /etc/ssh/sshd_config: Use rbenv or rvm for Ruby. I use rbenv because it's simpler: Nginx is your reverse proxy. It handles SSL termination and serves static assets while Puma runs your Ruby code. Create your site configuration: Add this configuration: systemd keeps your Puma process running. If it crashes, systemd restarts it. This is production 101. Create the service file: Add this configuration: Reload systemd and start Puma: Free SSL certificates. Certbot automates everything. Follow the prompts. Certbot automatically updates your Nginx config for SSL. Your app generates logs. Without rotation, your disk fills up and your server dies. Create a logrotate config: Add this configuration: Create a production-ready config/puma.rb in your Rails app: With Kamal, you deploy your app to /var/www/my-app/current/. Kamal handles the rest: 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
$ -weight: 600;">sudo adduser deploy
-weight: 600;">sudo usermod -aG -weight: 600;">sudo deploy
su - deploy
-weight: 600;">sudo adduser deploy
-weight: 600;">sudo usermod -aG -weight: 600;">sudo deploy
su - deploy
-weight: 600;">sudo adduser deploy
-weight: 600;">sudo usermod -aG -weight: 600;">sudo deploy
su - deploy
ssh-copy-id deploy@your-vps-ip
ssh-copy-id deploy@your-vps-ip
ssh-copy-id deploy@your-vps-ip
-weight: 600;">sudo nano /etc/ssh/sshd_config
-weight: 600;">sudo nano /etc/ssh/sshd_config
-weight: 600;">sudo nano /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PermitRootLogin no
PasswordAuthentication no
PermitRootLogin no
PasswordAuthentication no
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart ssh
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart ssh
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart ssh
# Install dependencies
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git build-essential libssl-dev zlib1g-dev libsqlite3-dev # Install rbenv
-weight: 500;">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 -weight: 500;">install 3.3.0
rbenv global 3.3.0 # Install Node.js
-weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash -
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs # Install Yarn
-weight: 500;">curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | -weight: 600;">sudo -weight: 500;">apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | -weight: 600;">sudo tee /etc/-weight: 500;">apt/sources.list.d/yarn.list
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install yarn
# Install dependencies
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git build-essential libssl-dev zlib1g-dev libsqlite3-dev # Install rbenv
-weight: 500;">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 -weight: 500;">install 3.3.0
rbenv global 3.3.0 # Install Node.js
-weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash -
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs # Install Yarn
-weight: 500;">curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | -weight: 600;">sudo -weight: 500;">apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | -weight: 600;">sudo tee /etc/-weight: 500;">apt/sources.list.d/yarn.list
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install yarn
# Install dependencies
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git build-essential libssl-dev zlib1g-dev libsqlite3-dev # Install rbenv
-weight: 500;">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 -weight: 500;">install 3.3.0
rbenv global 3.3.0 # Install Node.js
-weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash -
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs # Install Yarn
-weight: 500;">curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | -weight: 600;">sudo -weight: 500;">apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | -weight: 600;">sudo tee /etc/-weight: 500;">apt/sources.list.d/yarn.list
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install yarn
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start nginx
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable nginx
-weight: 600;">sudo nano /etc/nginx/sites-available/my-app.conf
-weight: 600;">sudo nano /etc/nginx/sites-available/my-app.conf
-weight: 600;">sudo nano /etc/nginx/sites-available/my-app.conf
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"; }
}
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"; }
}
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"; }
}
-weight: 600;">sudo ln -s /etc/nginx/sites-available/my-app.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo rm /etc/nginx/sites-enabled/default
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx
-weight: 600;">sudo ln -s /etc/nginx/sites-available/my-app.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo rm /etc/nginx/sites-enabled/default
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx
-weight: 600;">sudo ln -s /etc/nginx/sites-available/my-app.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo rm /etc/nginx/sites-enabled/default
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx
-weight: 600;">sudo nano /etc/systemd/system/puma.-weight: 500;">service
-weight: 600;">sudo nano /etc/systemd/system/puma.-weight: 500;">service
-weight: 600;">sudo nano /etc/systemd/system/puma.-weight: 500;">service
[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
[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
[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
-weight: 600;">sudo -weight: 500;">systemctl daemon-reload
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable puma
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start puma
-weight: 600;">sudo -weight: 500;">systemctl daemon-reload
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable puma
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start puma
-weight: 600;">sudo -weight: 500;">systemctl daemon-reload
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable puma
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start puma
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status puma
-weight: 600;">sudo journalctl -u puma -f
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status puma
-weight: 600;">sudo journalctl -u puma -f
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status puma
-weight: 600;">sudo journalctl -u puma -f
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx # Obtain certificate
-weight: 600;">sudo certbot --nginx -d your-domain.com
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx # Obtain certificate
-weight: 600;">sudo certbot --nginx -d your-domain.com
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx # Obtain certificate
-weight: 600;">sudo certbot --nginx -d your-domain.com
-weight: 600;">sudo nano /etc/logrotate.d/my-app
-weight: 600;">sudo nano /etc/logrotate.d/my-app
-weight: 600;">sudo nano /etc/logrotate.d/my-app
/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
}
/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
}
/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
}
-weight: 600;">sudo logrotate -d /etc/logrotate.d/my-app
-weight: 600;">sudo logrotate -f /etc/logrotate.d/my-app
-weight: 600;">sudo logrotate -d /etc/logrotate.d/my-app
-weight: 600;">sudo logrotate -f /etc/logrotate.d/my-app
-weight: 600;">sudo logrotate -d /etc/logrotate.d/my-app
-weight: 600;">sudo logrotate -f /etc/logrotate.d/my-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
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
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
# Deploy
kamal deploy # Check -weight: 500;">status
kamal -weight: 500;">status # Logs
kamal logs -f # Rebuild if needed
kamal deploy --no-push
# Deploy
kamal deploy # Check -weight: 500;">status
kamal -weight: 500;">status # Logs
kamal logs -f # Rebuild if needed
kamal deploy --no-push
# Deploy
kamal deploy # Check -weight: 500;">status
kamal -weight: 500;">status # Logs
kamal logs -f # Rebuild if needed
kamal deploy --no-push - Ubuntu 22.04 LTS (works on Debian too)
- Root or -weight: 600;">sudo access
- Domain pointed at your VPS IP
- Your app is configured for production (Kamal deployed it) - 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.