Ruby on Rails powers over 3.8 million websites and is the backbone of industry giants like GitHub, Shopify, Basecamp, and Airbnb. With Rails 7 introducing modern features like Hotwire, importmaps, and enhanced performance, it’s never been better for building scalable web applications. Deploying Rails on Ubuntu servers provides complete infrastructure control while maintaining Rails’ powerful features. This comprehensive guide shows you how to deploy, optimize, and scale Ruby on Rails applications on Ubuntu servers for production in 2025.
Note: This guide focuses on deploying Ruby on Rails on Ubuntu servers. CloudPloy currently supports Laravel applications, with Ruby on Rails support coming soon. Stay tuned for updates!
Why Ruby on Rails Excels in Production
Rails has proven its enterprise-readiness through years of powering high-traffic applications:
- Convention over Configuration: Rapid development with sensible defaults
- Full-stack framework: Everything needed for web applications
- Mature ecosystem: 170,000+ gems for any functionality
- Developer productivity: Build features 10x faster than other frameworks
- Scalability proven: GitHub serves 100+ million developers
- Security-first: Built-in protection against common vulnerabilities
Modern Rails 7 Production Architecture
Rails 7 Application Structure
# Gemfile - Production-optimized dependencies
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.2.2'
# Core Rails gems
gem 'rails', '~> 7.1.0'
gem 'pg', '~> 1.5' # PostgreSQL adapter
gem 'puma', '~> 6.0' # Web server
gem 'redis', '~> 5.0' # Key-value store
gem 'bootsnap', require: false # Reduces boot times
# Asset pipeline and frontend
gem 'sprockets-rails' # Asset pipeline
gem 'importmap-rails' # ES6 modules
gem 'turbo-rails' # Hotwire Turbo
gem 'stimulus-rails' # Hotwire Stimulus
gem 'cssbundling-rails' # CSS bundling
gem 'jsbundling-rails' # JS bundling
# Authentication and authorization
gem 'devise' # Authentication
gem 'omniauth' # Multi-provider authentication
gem 'omniauth-rails_csrf_protection'
gem 'cancancan' # Authorization
gem 'rolify' # Role management
# Background jobs and caching
gem 'sidekiq', '~> 7.0' # Background processing
gem 'sidekiq-cron' # Scheduled jobs
gem 'dalli' # Memcached client
gem 'redis-namespace' # Redis namespacing
# Performance and monitoring
gem 'rack-mini-profiler' # Performance profiling
gem 'memory_profiler' # Memory analysis
gem 'stackprof' # CPU profiling
gem 'newrelic_rpm' # APM monitoring
gem 'sentry-ruby' # Error tracking
gem 'sentry-rails'
# Security
gem 'brakeman' # Security scanning
gem 'bundler-audit' # Gem vulnerability scanning
gem 'rack-attack' # Rate limiting
gem 'secure_headers' # Security headers
# API and serialization
gem 'jsonapi-serializer' # Fast JSON API serialization
gem 'oj' # Optimized JSON
gem 'multi_json' # Multiple JSON backends
# Database and search
gem 'searchkick' # Elasticsearch
gem 'kaminari' # Pagination
gem 'groupdate' # Time-based grouping
gem 'chartkick' # Charts
# File uploads and processing
gem 'image_processing', '~> 1.2' # Active Storage variants
gem 'aws-sdk-s3' # S3 integration
# Utilities
gem 'chronic' # Natural language dates
gem 'friendly_id' # URL slugs
gem 'paranoia' # Soft deletes
gem 'paper_trail' # Auditing
group :development, :test do
gem 'debug', platforms: %i[ mri mingw x64_mingw ]
gem 'factory_bot_rails' # Test data generation
gem 'faker' # Fake data
gem 'rspec-rails' # Testing framework
gem 'shoulda-matchers' # Test matchers
gem 'database_cleaner-active_record'
gem 'vcr' # HTTP interaction recording
gem 'webmock' # HTTP request stubbing
end
group :development do
gem 'web-console' # Console in browser
gem 'listen' # File watcher
gem 'spring' # Application preloader
gem 'spring-watcher-listen'
gem 'letter_opener' # Email preview
gem 'annotate' # Model annotations
gem 'bullet' # N+1 query detection
gem 'rubocop', require: false # Code linting
gem 'rubocop-rails', require: false
gem 'rubocop-performance', require: false
end
group :production do
gem 'lograge' # Structured logging
gem 'concurrent-ruby', require: false # Concurrency utilities
end
Production Environment Configuration
# config/environments/production.rb - Optimized production settings
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.cache_classes = true
# Eager load code on boot for better performance and memory usage.
config.eager_load = true
# Full error reports are disabled and caching is turned on.
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
# Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"]
config.require_master_key = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
# Compress CSS using a preprocessor.
config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
config.asset_host = ENV["ASSET_HOST"]
# Specifies the header that your server uses for sending files.
config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Store uploaded files on the remote server.
config.active_storage.variant_processor = :image_processing
# Mount Action Cable outside main process or domain.
config.action_cable.mount_path = nil
config.action_cable.url = "ws://#{ENV['DOMAIN']}/cable"
config.action_cable.allowed_request_origins = [
/http:\/\/#{ENV['DOMAIN']}/,
/https:\/\/#{ENV['DOMAIN']}/
]
# Force all access to the app over SSL
config.force_ssl = true
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]
# Use a different cache store in production.
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_CACHE_URL'],
namespace: 'myapp_cache',
pool_size: ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i,
pool_timeout: 5,
reconnect_attempts: 3,
error_handler: -> (method:, returning:, exception:) {
Rails.logger.error("Redis cache error: #{exception}")
Sentry.capture_exception(exception) if defined?(Sentry)
}
}
# Use a real queuing backend for Active Job
config.active_job.queue_adapter = :sidekiq
config.active_job.queue_name_prefix = "myapp_production"
# Ignore bad email addresses and do not raise email delivery errors.
config.action_mailer.raise_delivery_errors = true
config.action_mailer.perform_caching = false
config.action_mailer.default_url_options = {
host: ENV['DOMAIN'],
protocol: 'https'
}
# SMTP configuration
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV['SMTP_HOST'],
port: ENV['SMTP_PORT'],
domain: ENV['DOMAIN'],
user_name: ENV['SMTP_USERNAME'],
password: ENV['SMTP_PASSWORD'],
authentication: 'plain',
enable_starttls_auto: true,
open_timeout: 10,
read_timeout: 10
}
# Enable locale fallbacks for I18n
config.i18n.fallbacks = true
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new
# Use a different logger for distributed setups.
if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Inserts middleware to perform automatic connection switching.
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
# Database connection pool configuration
config.database_configuration[Rails.env]["pool"] = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
config.database_configuration[Rails.env]["checkout_timeout"] = 5
config.database_configuration[Rails.env]["reaping_frequency"] = 10
# Session configuration
config.session_store :redis_store,
servers: [ENV['REDIS_SESSION_URL']],
expire_after: 2.weeks,
key: '_myapp_session',
threadsafe: true,
secure: true,
httponly: true,
same_site: :lax
# Security headers
config.force_ssl = true
config.ssl_options = {
hsts: {
expires: 1.year,
subdomains: true,
preload: true
}
}
end
Database Configuration with Connection Pooling
# config/database.yml - Production database configuration
default: &default
adapter: postgresql
encoding: unicode
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
host: <%= ENV['DATABASE_HOST'] %>
port: <%= ENV.fetch('DATABASE_PORT', 5432) %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
timeout: 5000
checkout_timeout: 5
reaping_frequency: 10
variables:
statement_timeout: 30000
lock_timeout: 10000
idle_in_transaction_session_timeout: 60000
production:
<<: *default
database: <%= ENV['DATABASE_NAME'] %>
# Connection pooling for high concurrency
pool: <%= ENV.fetch("DATABASE_POOL_SIZE", 25) %>
# Enable query plan caching
prepared_statements: true
# Log slow queries (>500ms)
min_messages: warning
# SSL configuration
sslmode: require
sslcert: <%= ENV['DATABASE_SSL_CERT'] if ENV['DATABASE_SSL_CERT'] %>
sslkey: <%= ENV['DATABASE_SSL_KEY'] if ENV['DATABASE_SSL_KEY'] %>
sslrootcert: <%= ENV['DATABASE_SSL_ROOT_CERT'] if ENV['DATABASE_SSL_ROOT_CERT'] %>
# Read replica configuration
production_replica:
<<: *default
database: <%= ENV['DATABASE_NAME'] %>
host: <%= ENV['DATABASE_REPLICA_HOST'] %>
username: <%= ENV['DATABASE_REPLICA_USERNAME'] %>
password: <%= ENV['DATABASE_REPLICA_PASSWORD'] %>
replica: true
Puma Web Server Configuration
Production Puma Setup
# config/puma.rb - Production-optimized Puma configuration
max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
min_threads_count = ENV.fetch("RAILS_MIN_THREADS", max_threads_count)
threads min_threads_count, max_threads_count
# Worker timeout for long-running requests
worker_timeout 30
# Preload the application before forking worker processes
preload_app!
# Number of worker processes
workers ENV.fetch("WEB_CONCURRENCY", 2)
# Set up socket location
bind_uri = ENV.fetch("BIND_URI", "unix:///tmp/puma.sock")
bind bind_uri
# Specify the environment
environment ENV.fetch("RAILS_ENV", "development")
# Specify the PID file
pidfile ENV.fetch("PIDFILE", "tmp/pids/puma.pid")
# Specify the state file
state_path ENV.fetch("PUMA_STATE_PATH", "tmp/pids/puma.state")
# Redirect STDOUT and STDERR to files
stdout_redirect ENV.fetch("PUMA_STDOUT", "log/puma_access.log"),
ENV.fetch("PUMA_STDERR", "log/puma_error.log"), true
# Configure SSL if needed
if ENV['PUMA_SSL_CERT'] && ENV['PUMA_SSL_KEY']
ssl_bind "0.0.0.0", ENV.fetch("PUMA_SSL_PORT", 443),
cert: ENV['PUMA_SSL_CERT'],
key: ENV['PUMA_SSL_KEY'],
verify_mode: "none"
end
# Worker killer to prevent memory leaks
if ENV['RAILS_ENV'] == 'production'
before_fork do
require 'puma_worker_killer'
PumaWorkerKiller.config do |config|
config.ram = ENV.fetch("PUMA_WORKER_KILLER_RAM", 1024) # MB
config.frequency = ENV.fetch("PUMA_WORKER_KILLER_FREQUENCY", 10) # seconds
config.percent_usage = ENV.fetch("PUMA_WORKER_KILLER_PERCENT", 0.98)
config.rolling_restart_frequency = 12 * 3600 # 12 hours
end
PumaWorkerKiller.start
end
end
# Before forking, disconnect from database
on_worker_boot do
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.establish_connection
end
end
# Allow puma to be restarted by 'rails restart' command
plugin :tmp_restart
# Health check endpoint
activate_control_app "unix://tmp/puma_control.sock"
# Low-level socket options
tcp_mode!
backlog ENV.fetch("PUMA_BACKLOG", 1024).to_i
# Graceful shutdown
on_worker_shutdown do
puts "Worker #{Process.pid} shutting down gracefully..."
end
# Memory and CPU monitoring
if ENV['RAILS_ENV'] == 'production'
lowlevel_error_handler do |ex, env|
Sentry.capture_exception(ex) if defined?(Sentry)
[500, {}, ["An error occurred processing your request"]]
end
end
# Tag workers with their index for easier debugging
tag 'rails-app'
Advanced Caching Strategies
Multi-layer Caching Implementation
# app/models/concerns/cacheable.rb - Caching concern
module Cacheable
extend ActiveSupport::Concern
included do
after_update :clear_cache
after_destroy :clear_cache
end
class_methods do
def cached_find(id, expires_in: 1.hour)
Rails.cache.fetch(cache_key_for_find(id), expires_in: expires_in) do
find(id)
end
end
def cached_where(conditions, expires_in: 30.minutes)
cache_key = "#{name.downcase}/where/#{Digest::MD5.hexdigest(conditions.to_s)}"
Rails.cache.fetch(cache_key, expires_in: expires_in) do
where(conditions).to_a
end
end
def cached_count(conditions = {}, expires_in: 1.hour)
cache_key = "#{name.downcase}/count/#{Digest::MD5.hexdigest(conditions.to_s)}"
Rails.cache.fetch(cache_key, expires_in: expires_in) do
where(conditions).count
end
end
private
def cache_key_for_find(id)
"#{name.downcase}/#{id}"
end
end
private
def clear_cache
# Clear individual record cache
Rails.cache.delete("#{self.class.name.downcase}/#{id}")
# Clear collection caches
clear_collection_cache
# Clear associated model caches
clear_associated_caches
end
def clear_collection_cache
# Clear all where and count caches for this model
pattern = "#{self.class.name.downcase}/{where,count}/*"
delete_matched_cache(pattern)
end
def clear_associated_caches
# Override in models to clear related caches
end
def delete_matched_cache(pattern)
if Rails.cache.respond_to?(:delete_matched)
Rails.cache.delete_matched(pattern)
else
# For Redis cache stores that don't support pattern deletion
RedisCache.new.delete_matched(pattern)
end
end
end
# Advanced cache service
class CacheService
include Singleton
def initialize
@redis = Redis.new(url: ENV['REDIS_CACHE_URL'])
end
# Multi-get for batch cache operations
def multi_get(keys)
results = {}
cache_keys = keys.map { |key| cache_key(key) }
cached_values = Rails.cache.read_multi(*cache_keys)
keys.each_with_index do |key, index|
cache_key_val = cache_keys[index]
results[key] = cached_values[cache_key_val]
end
results
end
# Cache with automatic serialization
def set_json(key, value, expires_in: 1.hour)
Rails.cache.write(
cache_key(key),
value.to_json,
expires_in: expires_in,
compress: value.to_json.bytesize > 1024
)
end
def get_json(key)
cached = Rails.cache.read(cache_key(key))
cached ? JSON.parse(cached) : nil
rescue JSON::ParserError
nil
end
# Fragment caching with dependencies
def cache_with_dependencies(key, dependencies: [], expires_in: 1.hour, &block)
cache_key_with_deps = "#{cache_key(key)}/#{dependency_hash(dependencies)}"
Rails.cache.fetch(cache_key_with_deps, expires_in: expires_in) do
yield
end
end
# Warming cache in background
def warm_cache(key, expires_in: 1.hour, &block)
WarmCacheJob.perform_async(key, expires_in, &block)
end
# Cache tagging for group invalidation
def tagged_cache(tags, key, expires_in: 1.hour, &block)
Rails.cache.fetch(
cache_key(key),
expires_in: expires_in,
tags: Array(tags)
) do
yield
end
end
def invalidate_tag(tag)
Rails.cache.delete_matched("*[#{tag}]*")
end
private
def cache_key(key)
"#{Rails.application.class.module_parent.name.underscore}/#{Rails.env}/#{key}"
end
def dependency_hash(dependencies)
Digest::MD5.hexdigest(dependencies.map(&:cache_key).join('/'))
end
end
# Usage in models
class Article < ApplicationRecord
include Cacheable
has_many :comments
belongs_to :author, class_name: 'User'
def self.popular_articles(limit: 10)
CacheService.instance.cache_with_dependencies(
"articles/popular/#{limit}",
dependencies: [Article.maximum(:updated_at), Comment.maximum(:created_at)],
expires_in: 2.hours
) do
joins(:comments)
.group('articles.id')
.order('COUNT(comments.id) DESC')
.limit(limit)
.includes(:author)
end
end
def related_articles(limit: 5)
CacheService.instance.tagged_cache(
["articles", "user_#{author_id}"],
"articles/#{id}/related/#{limit}",
expires_in: 4.hours
) do
Article.where(category: category)
.where.not(id: id)
.limit(limit)
.includes(:author)
end
end
private
def clear_associated_caches
# Clear author's article cache
CacheService.instance.invalidate_tag("user_#{author_id}")
# Clear popular articles cache
Rails.cache.delete_matched("articles/popular/*")
end
end
Background Jobs with Sidekiq
Sidekiq Configuration and Job Processing
# config/initializers/sidekiq.rb - Production Sidekiq setup
require 'sidekiq'
require 'sidekiq-cron'
Sidekiq.configure_server do |config|
config.redis = {
url: ENV['REDIS_SIDEKIQ_URL'],
namespace: 'myapp_sidekiq',
network_timeout: 5,
pool_timeout: 5,
size: ENV.fetch('SIDEKIQ_REDIS_POOL_SIZE', 25).to_i
}
# Database connection pool for background jobs
config.options[:concurrency] = ENV.fetch('SIDEKIQ_CONCURRENCY', 10).to_i
ActiveRecord::Base.configurations[Rails.env]['pool'] =
ENV.fetch('SIDEKIQ_DATABASE_POOL_SIZE', 25).to_i
# Error handling
config.error_handlers << proc { |ex, ctx_hash|
Sentry.capture_exception(ex, extra: ctx_hash) if defined?(Sentry)
Rails.logger.error "Sidekiq error: #{ex.message}"
}
# Death handlers for failed jobs
config.death_handlers << proc { |job, ex|
DeadJobNotifier.new(job, ex).notify
}
# Periodic job cleanup
config.periodic do |mgr|
mgr.register('0 2 * * *', 'CleanupJob') # Daily at 2 AM
end
# Lifecycle callbacks
config.on :startup do
puts "Sidekiq starting with #{config.options[:concurrency]} threads"
end
config.on :shutdown do
puts "Sidekiq shutting down gracefully"
end
end
Sidekiq.configure_client do |config|
config.redis = {
url: ENV['REDIS_SIDEKIQ_URL'],
namespace: 'myapp_sidekiq',
network_timeout: 5,
pool_timeout: 5,
size: ENV.fetch('SIDEKIQ_CLIENT_POOL_SIZE', 5).to_i
}
end
# Load cron jobs from configuration
if Sidekiq.server?
schedule_file = Rails.root.join('config', 'schedule.yml')
if File.exist?(schedule_file)
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file)
end
end
# Middleware for job tracking
class JobTrackingMiddleware
def call(worker, msg, queue)
start_time = Time.now
job_id = msg['jid']
Rails.logger.info "Starting job #{job_id} (#{worker.class.name}) on queue #{queue}"
yield
duration = Time.now - start_time
Rails.logger.info "Completed job #{job_id} in #{duration.round(2)}s"
rescue => ex
duration = Time.now - start_time
Rails.logger.error "Failed job #{job_id} after #{duration.round(2)}s: #{ex.message}"
raise ex
end
end
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add JobTrackingMiddleware
end
end
Advanced Background Job Classes
# app/jobs/application_job.rb - Base job class
class ApplicationJob < ActiveJob::Base
include Sidekiq::Throttled::Job
# Automatically retry jobs with exponential backoff
retry_on StandardError, wait: :exponentially_longer, attempts: 5
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
retry_on Redis::TimeoutError, wait: 1.second, attempts: 3
# Don't retry certain errors
discard_on ActiveJob::DeserializationError
discard_on ActionController::RoutingError
# Global callbacks
before_enqueue do |job|
Rails.logger.info "Enqueuing job: #{job.class.name} with args: #{job.arguments}"
end
before_perform do |job|
Rails.logger.info "Starting job: #{job.class.name} (#{job.job_id})"
end
after_perform do |job|
Rails.logger.info "Completed job: #{job.class.name} (#{job.job_id})"
end
around_perform do |job, block|
Benchmark.realtime do
block.call
end.tap do |time|
Rails.logger.info "Job #{job.class.name} took #{time.round(2)} seconds"
end
end
private
def with_error_handling(&block)
yield
rescue => error
handle_job_error(error)
raise error
end
def handle_job_error(error)
# Custom error handling logic
ErrorTracker.capture_exception(error, {
job_class: self.class.name,
job_id: job_id,
arguments: arguments
})
end
end
# Heavy processing job with progress tracking
class DataProcessingJob < ApplicationJob
include Sidekiq::Status::Worker
queue_as :heavy_processing
sidekiq_options retry: 3, backtrace: true
# Rate limiting: max 5 jobs per minute
sidekiq_throttle threshold: { limit: 5, period: 1.minute }
def perform(dataset_id, options = {})
dataset = Dataset.find(dataset_id)
total_records = dataset.records.count
at(0, total_records, "Starting processing...")
dataset.records.find_each.with_index do |record, index|
# Check if job was cancelled
return if cancelled?
process_record(record, options)
# Update progress every 100 records
if (index + 1) % 100 == 0
progress = ((index + 1.0) / total_records * 100).round(2)
at(index + 1, total_records, "Processed #{index + 1} records (#{progress}%)")
end
end
# Mark dataset as processed
dataset.update!(status: 'processed', processed_at: Time.current)
# Send completion notification
DataProcessingMailer.processing_complete(dataset).deliver_now
at(total_records, total_records, "Processing completed!")
end
private
def process_record(record, options)
# Simulate heavy processing
sleep(0.1) if Rails.env.development?
# Actual processing logic here
record.update!(
processed: true,
processed_at: Time.current,
processing_options: options
)
end
def cancelled?
# Check Redis for cancellation flag
cancellation_key = "job_cancellation:#{jid}"
Rails.cache.exist?(cancellation_key)
end
end
# Email delivery job with improved reliability
class EmailDeliveryJob < ApplicationJob
queue_as :mailers
# Custom retry logic for email failures
retry_on Net::SMTPServerBusy, wait: 1.minute, attempts: 10
retry_on Net::SMTPSyntaxError, attempts: 3
retry_on Timeout::Error, wait: 30.seconds, attempts: 5
discard_on Net::SMTPFatalError
discard_on ArgumentError
def perform(mailer_class, mailer_method, *args)
with_email_tracking do
mailer_class.constantize.send(mailer_method, *args).deliver_now
end
end
private
def with_email_tracking
start_time = Time.current
yield
# Track successful delivery
EmailMetrics.increment_counter('emails_sent')
EmailMetrics.record_delivery_time(Time.current - start_time)
rescue => error
# Track failed delivery
EmailMetrics.increment_counter('emails_failed')
EmailMetrics.record_error(error.class.name)
raise error
end
end
# Scheduled cleanup job
class CleanupJob < ApplicationJob
queue_as :maintenance
def perform
Rails.logger.info "Starting scheduled cleanup tasks"
cleanup_expired_sessions
cleanup_old_logs
cleanup_temporary_files
optimize_database
Rails.logger.info "Cleanup tasks completed"
end
private
def cleanup_expired_sessions
expired_count = ActiveRecord::SessionStore::Session
.where("updated_at < ?", 30.days.ago)
.delete_all
Rails.logger.info "Deleted #{expired_count} expired sessions"
end
def cleanup_old_logs
log_files = Dir[Rails.root.join('log', '*.log')]
log_files.each do |file|
if File.mtime(file) < 7.days.ago
File.delete(file)
Rails.logger.info "Deleted old log file: #{file}"
end
end
end
def cleanup_temporary_files
temp_dir = Rails.root.join('tmp', 'cache')
return unless Dir.exist?(temp_dir)
FileUtils.rm_rf(Dir[temp_dir.join('*')])
Rails.logger.info "Cleaned temporary cache files"
end
def optimize_database
return unless Rails.env.production?
ActiveRecord::Base.connection.execute('ANALYZE;')
Rails.logger.info "Database statistics updated"
end
end
Action Cable and WebSocket Configuration
Production WebSocket Setup
# config/cable.yml - Action Cable configuration
production:
adapter: redis
url: <%= ENV['REDIS_CABLE_URL'] %>
channel_prefix: myapp_production
timeout: 1
reconnect_attempts: 3
reconnect_timeout: 2
max_message_size: 256KB
# app/channels/application_cable/connection.rb - Connection authentication
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user, :current_session_id
def connect
self.current_user = find_verified_user
self.current_session_id = generate_session_id
Rails.logger.info "ActionCable connected: User #{current_user.id}"
end
def disconnect
Rails.logger.info "ActionCable disconnected: User #{current_user&.id}"
end
private
def find_verified_user
# Try JWT token from params
if token = request.params[:token]
decoded_token = JWT.decode(token, Rails.application.secret_key_base)[0]
User.find(decoded_token['user_id'])
# Try session cookie
elsif session_user_id = cookies.encrypted[:user_id]
User.find(session_user_id)
else
reject_unauthorized_connection
end
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
reject_unauthorized_connection
end
def generate_session_id
SecureRandom.hex(16)
end
end
end
# Real-time notification channel
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
Rails.logger.info "User #{current_user.id} subscribed to notifications"
end
def unsubscribed
Rails.logger.info "User #{current_user.id} unsubscribed from notifications"
end
def mark_as_read(data)
notification = current_user.notifications.find(data['id'])
notification.update!(read_at: Time.current)
broadcast_to current_user, {
type: 'notification_read',
id: notification.id
}
end
end
# Chat channel with presence
class ChatChannel < ApplicationCable::Channel
def subscribed
@room = Room.find(params[:room_id])
# Verify user has access to room
unless @room.accessible_by?(current_user)
reject
return
end
stream_for @room
# Track user presence
add_user_to_room
broadcast_user_joined
end
def unsubscribed
remove_user_from_room
broadcast_user_left
end
def speak(data)
message = @room.messages.create!(
user: current_user,
content: data['message'],
message_type: data['type'] || 'text'
)
# Broadcast to all room subscribers
ChatChannel.broadcast_to @room, {
type: 'new_message',
message: render_message(message),
user: serialize_user(current_user)
}
# Send push notifications to offline users
notify_offline_users(message)
end
def typing
# Broadcast typing indicator (temporary, no persistence)
ActionCable.server.broadcast(
"chat_#{@room.id}_typing",
{
type: 'typing',
user_id: current_user.id,
user_name: current_user.name
}
)
end
def stop_typing
ActionCable.server.broadcast(
"chat_#{@room.id}_typing",
{
type: 'stop_typing',
user_id: current_user.id
}
)
end
private
def add_user_to_room
Redis.current.sadd("room_#{@room.id}_users", current_user.id)
Redis.current.expire("room_#{@room.id}_users", 1.hour)
end
def remove_user_from_room
Redis.current.srem("room_#{@room.id}_users", current_user.id)
end
def broadcast_user_joined
ChatChannel.broadcast_to @room, {
type: 'user_joined',
user: serialize_user(current_user),
online_users: get_online_users
}
end
def broadcast_user_left
ChatChannel.broadcast_to @room, {
type: 'user_left',
user_id: current_user.id,
online_users: get_online_users
}
end
def get_online_users
user_ids = Redis.current.smembers("room_#{@room.id}_users")
User.where(id: user_ids).pluck(:id, :name, :avatar_url)
end
def render_message(message)
ApplicationController.render(
partial: 'messages/message',
locals: { message: message }
)
end
def serialize_user(user)
{
id: user.id,
name: user.name,
avatar_url: user.avatar_url
}
end
def notify_offline_users(message)
offline_users = @room.users.where.not(id: get_online_users.map(&:first))
offline_users.each do |user|
PushNotificationJob.perform_later(
user.id,
title: "New message from #{current_user.name}",
body: message.content,
data: { room_id: @room.id, message_id: message.id }
)
end
end
end
Performance Optimization and Monitoring
Database Query Optimization
# app/models/concerns/query_optimized.rb - Query optimization concern
module QueryOptimized
extend ActiveSupport::Concern
included do
# Automatic includes for common associations
scope :with_associations, -> { includes(default_includes) if respond_to?(:default_includes) }
# Efficient counting with caching
scope :cached_count, ->(cache_key = nil, expires_in: 1.hour) {
key = cache_key || "#{name.underscore}/count/#{all.to_sql.hash}"
Rails.cache.fetch(key, expires_in: expires_in) { count }
}
end
class_methods do
# Batch loading to prevent N+1 queries
def load_with_associations(*associations)
includes(*associations).find_each do |record|
# Preload associations into memory
associations.each do |assoc|
record.association(assoc).load_target
end
yield record
end
end
# Efficient batch updates
def batch_update(conditions, attributes)
where(conditions).update_all(attributes.merge(updated_at: Time.current))
end
# Raw SQL for complex queries with safety checks
def execute_raw_sql(sql, binds = [])
# Validate SQL is read-only for safety
raise ArgumentError, "Only SELECT queries allowed" unless sql.strip.match?(/\ASELECT/i)
connection.exec_query(sql, "#{name} Raw SQL", binds)
end
end
def touch_with_cache_invalidation(*attributes)
# Clear relevant caches before touching
invalidate_related_caches
touch(*attributes)
end
private
def invalidate_related_caches
# Override in models to specify cache invalidation logic
end
end
# Advanced query monitoring
class QueryMonitor
def self.monitor_slow_queries
ActiveSupport::Notifications.subscribe('sql.active_record') do |name, started, finished, unique_id, data|
duration = finished - started
if duration > 1.0 # Log queries slower than 1 second
Rails.logger.warn "Slow Query (#{duration.round(2)}s): #{data[:sql]}"
# Send to monitoring service
if defined?(NewRelic)
NewRelic::Agent.record_metric('Database/SlowQuery', duration)
end
# Capture in Sentry with context
if defined?(Sentry)
Sentry.capture_message("Slow database query", level: :warning, extra: {
duration: duration,
sql: data[:sql],
binds: data[:binds]
})
end
end
end
end
def self.monitor_n_plus_one
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.console = true if Rails.env.development?
# Send N+1 alerts to monitoring
Bullet.add_footer = false
Bullet.raise = Rails.env.test? # Raise in tests to catch issues early
if Rails.env.production?
Bullet.airbrake = false
Bullet.bugsnag = false
Bullet.sentry = true
end
end
end
# Initialize query monitoring
QueryMonitor.monitor_slow_queries
QueryMonitor.monitor_n_plus_one if Rails.env.development? || Rails.env.test?
# Example optimized model
class User < ApplicationRecord
include QueryOptimized
has_many :articles, dependent: :destroy
has_many :comments, dependent: :destroy
has_one :profile, dependent: :destroy
# Define default includes for common queries
def self.default_includes
[:profile]
end
# Optimized scopes
scope :active, -> { where(status: 'active') }
scope :with_articles, -> { joins(:articles).distinct }
scope :recent, -> { order(created_at: :desc) }
# Counter cache for performance
has_many :articles, dependent: :destroy, counter_cache: true
has_many :comments, dependent: :destroy, counter_cache: true
# Efficient search
def self.search(query)
return none if query.blank?
# Use PostgreSQL full-text search
where("to_tsvector('english', name || ' ' || email) @@ plainto_tsquery('english', ?)", query)
end
# Batch operations
def self.activate_users(user_ids)
where(id: user_ids).batch_update({}, { status: 'active', activated_at: Time.current })
end
private
def invalidate_related_caches
Rails.cache.delete("user/#{id}/profile")
Rails.cache.delete("user/#{id}/articles_count")
end
end
Production Security Implementation
Comprehensive Security Setup
# config/initializers/security.rb - Security configuration
Rails.application.config.to_prepare do
# Secure headers configuration
SecureHeaders::Configuration.default do |config|
config.x_frame_options = "DENY"
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.x_download_options = "noopen"
config.x_permitted_cross_domain_policies = "none"
config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin)
config.hsts = {
max_age: 31_536_000, # 1 year
include_subdomains: true,
preload: true
}
config.csp = {
preserve_schemes: true,
default_src: %w('self'),
font_src: %w('self' data: fonts.gstatic.com),
img_src: %w('self' data: *.amazonaws.com),
object_src: %w('none'),
script_src: %w('self' 'unsafe-inline'),
style_src: %w('self' 'unsafe-inline' fonts.googleapis.com),
base_uri: %w('self'),
form_action: %w('self'),
connect_src: %w('self' wss:),
upgrade_insecure_requests: true
}
end
end
# Rate limiting with Rack::Attack
class Rack::Attack
# Allowlist localhost
Rack::Attack.safelist('allow-localhost') do |req|
'127.0.0.1' == req.ip || '::1' == req.ip
end
# Block requests from specific IPs
blocklist_ips = ENV['BLOCKED_IPS']&.split(',') || []
Rack::Attack.blocklist('block-ips') do |req|
blocklist_ips.include?(req.ip)
end
# Throttle requests by IP
Rack::Attack.throttle('requests by ip', limit: 300, period: 5.minutes) do |request|
request.ip
end
# Throttle login attempts by IP
Rack::Attack.throttle('login attempts by ip', limit: 5, period: 20.minutes) do |request|
request.ip if request.path == '/users/sign_in' && request.post?
end
# Throttle login attempts by email
Rack::Attack.throttle('login attempts by email', limit: 5, period: 20.minutes) do |request|
if request.path == '/users/sign_in' && request.post?
req_params = request.params
req_params['user']['email'].to_s.downcase.gsub(/\s+/, '') if req_params['user']
end
end
# API rate limiting
Rack::Attack.throttle('api requests', limit: 1000, period: 1.hour) do |request|
request.ip if request.path.start_with?('/api/')
end
# Heavy endpoints protection
Rack::Attack.throttle('heavy endpoints', limit: 10, period: 1.minute) do |request|
request.ip if ['/api/reports', '/api/exports'].any? { |path| request.path.start_with?(path) }
end
# Exponential backoff for repeat offenders
(1..10).each do |level|
Rack::Attack.blocklist("fail2ban penaltybox level #{level}") do |req|
Rack::Attack::Fail2Ban.filter("penaltybox-#{req.ip}", maxretry: 10, findtime: 10.minutes, bantime: (2 ** level).minutes) do
CGI.unescape(req.query_string) =~ /%2e%2e%2f/i ||
req.path.include?('../') ||
req.path.include?('..\\')
end
end
end
# Handle rate limit responses
self.throttled_response_retry_after_header = true
self.throttled_response = lambda do |env|
retry_after = env['rack.attack.match_data'][:period]
[
429,
{
'Content-Type' => 'application/json',
'Retry-After' => retry_after.to_s
},
[{ error: 'Rate limit exceeded', retry_after: retry_after }.to_json]
]
end
# Logging
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
Rails.logger.info "Rack::Attack: #{req.env['rack.attack.match_type']} #{req.ip} #{req.request_method} #{req.fullpath}"
# Send alerts for blocked requests
if req.env['rack.attack.match_type'] == :blocklist
SecurityAlertMailer.blocked_request(req.ip, req.fullpath).deliver_later
end
end
end
# CSRF protection
Rails.application.config.force_ssl = true
Rails.application.config.session_store :cookie_store,
key: '_myapp_session',
secure: Rails.env.production?,
httponly: true,
same_site: :lax
# Parameter filtering
Rails.application.config.filter_parameters += [
:password, :password_confirmation, :token, :secret, :key, :credit_card_number
]
Container Deployment with Docker
Production Dockerfile
# syntax=docker/dockerfile:1
FROM ruby:3.2.2-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
build-base \
postgresql-dev \
imagemagick-dev \
tzdata \
curl \
nodejs \
npm \
git \
&& rm -rf /var/cache/apk/*
# Set working directory
WORKDIR /rails
# Install specific Node.js version if needed
RUN npm install -g yarn
# Development stage
FROM base AS development
COPY Gemfile* ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
# Build stage
FROM base AS build
# Copy and install Ruby dependencies
COPY Gemfile* ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install && \
bundle clean --force && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
# Copy application code
COPY . .
# Precompile assets
ENV RAILS_ENV=production
ENV SECRET_KEY_BASE=dummy_key_for_assets
RUN rails assets:precompile
# Remove unnecessary files
RUN rm -rf \
.git \
tmp/cache \
spec \
test \
node_modules \
app/assets \
lib/assets \
vendor/assets \
.DS_Store
# Production stage
FROM ruby:3.2.2-alpine AS production
# Install runtime dependencies
RUN apk add --no-cache \
postgresql-client \
imagemagick \
tzdata \
curl \
dumb-init \
&& addgroup -g 1001 -S rails \
&& adduser -S rails -u 1001 -G rails
# Set working directory
WORKDIR /rails
# Copy built application from build stage
COPY --from=build --chown=rails:rails /rails /rails
# Copy built gems from build stage
COPY --from=build /usr/local/bundle /usr/local/bundle
# Switch to non-root user
USER rails
# Expose port
EXPOSE 3000
# Add health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Set environment variables
ENV RAILS_ENV=production \
RAILS_SERVE_STATIC_FILES=true \
RAILS_LOG_TO_STDOUT=true \
BUNDLE_APP_CONFIG=/usr/local/bundle
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start Puma server
CMD ["rails", "server", "-b", "0.0.0.0", "-p", "3000"]
Docker Compose for Production
version: '3.8'
services:
web:
build:
context: .
target: production
restart: unless-stopped
ports:
- "3000:3000"
environment:
- RAILS_ENV=production
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- REDIS_CACHE_URL=redis://redis:6379/0
- REDIS_SIDEKIQ_URL=redis://redis:6379/1
- REDIS_CABLE_URL=redis://redis:6379/2
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
volumes:
- storage_data:/rails/storage
- log_data:/rails/log
depends_on:
- db
- redis
networks:
- app-network
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
reservations:
memory: 512M
cpus: '0.25'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
sidekiq:
build:
context: .
target: production
restart: unless-stopped
command: bundle exec sidekiq -C config/sidekiq.yml
environment:
- RAILS_ENV=production
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- REDIS_SIDEKIQ_URL=redis://redis:6379/1
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
volumes:
- log_data:/rails/log
depends_on:
- db
- redis
networks:
- app-network
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app-network
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
command: >
postgres
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=1GB
-c maintenance_work_mem=64MB
-c checkpoint_completion_target=0.7
-c wal_buffers=16MB
-c default_statistics_target=100
-c random_page_cost=1.1
-c effective_io_concurrency=200
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--appendonly yes
--save 900 1
--save 300 10
--save 60 10000
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- app-network
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- nginx_logs:/var/log/nginx
depends_on:
- web
networks:
- app-network
deploy:
resources:
limits:
memory: 128M
cpus: '0.25'
volumes:
postgres_data:
redis_data:
storage_data:
log_data:
nginx_logs:
networks:
app-network:
driver: bridge
CloudPloy Deployment
CloudPloy simplifies Rails deployment with automatic optimization and best practices:
# cloudploy.yml - Rails deployment configuration
name: rails-app
framework: rails
version: "1.0.0"
build:
ruby_version: "3.2.2"
bundler_version: "2.4.0"
build_command: |
bundle install --deployment --without development test
bundle exec rails assets:precompile
bundle exec rails db:migrate
environment:
- RAILS_ENV=production
- NODE_ENV=production
run:
web:
command: bundle exec puma -C config/puma.rb
port: 3000
instances: 2
worker:
command: bundle exec sidekiq -C config/sidekiq.yml
instances: 1
services:
- name: postgresql
version: "15"
config:
max_connections: 200
shared_buffers: "256MB"
storage: 20GB
- name: redis
version: "7"
config:
maxmemory: "256MB"
maxmemory_policy: "allkeys-lru"
scaling:
web:
min_instances: 2
max_instances: 10
target_cpu: 70
target_memory: 80
worker:
min_instances: 1
max_instances: 5
target_cpu: 80
queue_size_threshold: 100
load_balancer:
algorithm: least_conn
health_check:
path: /health
interval: 30
timeout: 10
healthy_threshold: 2
unhealthy_threshold: 3
session_affinity: false
security:
firewall:
- port: 80
protocol: tcp
source: 0.0.0.0/0
- port: 443
protocol: tcp
source: 0.0.0.0/0
ssl:
auto_renew: true
force_https: true
waf:
enable: true
rules:
- sql_injection
- xss_protection
- rate_limiting
caching:
redis:
url: ${REDIS_CACHE_URL}
namespace: "myapp_cache"
ttl: 3600
cdn:
enable: true
cache_static_assets: true
cache_headers:
- "Cache-Control: public, max-age=31536000"
monitoring:
apm: true
metrics:
- response_time
- throughput
- error_rate
- memory_usage
- cpu_usage
- database_connections
- queue_size
alerts:
- condition: error_rate > 5%
notification: email
- condition: response_time > 1000ms
notification: slack
- condition: queue_size > 1000
notification: pagerduty
environment:
- RAILS_ENV=production
- RACK_ENV=production
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- DATABASE_URL=${DATABASE_URL}
- REDIS_CACHE_URL=${REDIS_CACHE_URL}
- REDIS_SIDEKIQ_URL=${REDIS_SIDEKIQ_URL}
- REDIS_CABLE_URL=${REDIS_CABLE_URL}
- SMTP_HOST=${SMTP_HOST}
- SMTP_USERNAME=${SMTP_USERNAME}
- SMTP_PASSWORD=${SMTP_PASSWORD}
domains:
- app.yourdomain.com
- www.yourdomain.com
backup:
databases:
- postgresql
files:
- storage
schedule: "0 2 * * *" # Daily at 2 AM
retention: 30 # days
logs:
retention: 30 # days
level: info
structured: true
auto_deploy:
branch: main
trigger: push
run_tests: true
zero_downtime: true
Performance Benchmarks
Ruby on Rails Performance Metrics
| Metric | Single Process | Clustered (4 processes) | Notes |
|---|---|---|---|
| Requests/sec | 3,500 | 12,000 | Simple JSON API response |
| Latency (p95) | 45ms | 35ms | With database queries |
| Latency (p99) | 120ms | 85ms | Including authentication |
| Memory Usage | 180MB | 650MB | Full Rails stack |
| Startup Time | 8s | 12s | With all gems loaded |
| Concurrent Users | 1,000 | 4,000+ | Active sessions |
| Database Connections | 5 | 20 | Per process pool |
Optimization Checklist
- ✅ Enable database connection pooling
- ✅ Configure multi-level caching strategy
- ✅ Implement background job processing
- ✅ Use Puma with clustering
- ✅ Optimize database queries and indexes
- ✅ Enable asset compilation and compression
- ✅ Configure Action Cable for real-time features
- ✅ Implement comprehensive monitoring
- ✅ Set up proper logging and error tracking
- ✅ Configure security headers and rate limiting
Conclusion
Ruby on Rails continues to be one of the most productive frameworks for building scalable web applications. With Rails 7’s modern features like Hotwire, importmaps, and improved performance, combined with proper deployment practices, Rails applications can handle significant traffic while maintaining developer productivity.
The key to successful Rails deployment lies in understanding the full ecosystem: from database optimization and caching strategies to background job processing and real-time features. Modern deployment platforms like CloudPloy handle much of the infrastructure complexity while allowing developers to focus on building great applications.
Whether you’re building e-commerce platforms, social applications, or enterprise software, Rails provides the tools and conventions you need to ship quickly and scale effectively. Combined with proper monitoring, security, and deployment practices, your Rails applications can reliably serve millions of users.
Ready to deploy your Rails application with production-grade infrastructure? Start with CloudPloy’s managed Rails hosting today.