Ruby on Rails Cloud Hosting: Deploy Rails Apps to Production
Ruby 3.3, Puma, asset pipeline, and Sidekiq — managed Rails hosting with git push deployment.
In This Guide
Ruby on Rails Cloud Hosting: A Production Deployment Guide
Rails has a reputation for being opinionated about development but surprisingly flexible about deployment. That flexibility means there are several ways to deploy Rails correctly — and several ways to deploy it incorrectly that only fail under production load. This guide covers the architecture decisions that matter for production Rails applications.
The Rails Production Stack
A production Rails application typically involves more than a single process:
Puma: The default Rails web server since Rails 5. Handles HTTP requests, runs multiple threads per worker, and manages the connection between the reverse proxy and your Rails app.
Sidekiq (optional but common): Background job processor using Redis. Runs as a separate process. Your application queues jobs from the web process; Sidekiq workers process them asynchronously.
Action Cable: WebSocket handler for real-time features. Can run in the same Puma process or as a separate deployment.
PostgreSQL or MySQL: Primary database. Rails' Active Record makes it relatively easy to switch, but PostgreSQL is the default choice for new applications.
Redis: Used for Action Cable, caching, and Sidekiq. A fast in-memory store that should run on the same internal network as your Rails app.
Understanding this stack helps you provision infrastructure correctly — most deployment failures come from missing services or services that can't reach each other.
Preparing Rails for Production
The Gemfile
# Gemfile
gem 'puma', '>= 6.0'
gem 'pg', '>= 1.1' # PostgreSQL adapter
gem 'redis', '>= 5.0' # Redis client
# Background jobs
gem 'sidekiq', '>= 7.0'
# Performance
gem 'bootsnap', require: false # Faster boot times
group :production do
gem 'lograge' # Structured single-line request logs
end
Always pin gem versions. bundle update in a fresh production environment pulling newer gem versions that break compatibility is a genuine risk.
config/puma.rb
# config/puma.rb
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 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
port ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
# Workers: number of separate Puma processes
# Each worker has its own thread pool
# Set based on: CPU cores × 2 for I/O-heavy apps
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
# Preload the application before forking workers (saves memory via COW)
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
The preload_app! directive combined with on_worker_boot database reconnection is important — forked Puma workers inherit their parent's database connections, which causes connection errors. on_worker_boot re-establishes connections in each worker after forking.
Environment Variables
# Rails core
RAILS_ENV=production
RAILS_MASTER_KEY=your-master-key-here # From config/master.key
SECRET_KEY_BASE=64-char-random-string # Or use credentials
# Database
DATABASE_URL=postgresql://user:password@db-host:5432/myapp_production
# Rails picks this up automatically
# Redis
REDIS_URL=redis://redis-host:6379/0
# Application
APP_DOMAIN=myapp.com
RAILS_LOG_TO_STDOUT=enabled
RAILS_SERVE_STATIC_FILES=enabled # If not using a separate static file server
# Puma tuning
WEB_CONCURRENCY=2
RAILS_MAX_THREADS=5
RAILS_LOG_TO_STDOUT=enabled redirects Rails logs from log/production.log to stdout, where cloud platforms can capture them. Without this, logs go to a file that disappears when the container restarts.
The Master Key
Rails 5.2+ uses encrypted credentials stored in config/credentials.yml.enc. The decryption key lives in config/master.key — never commit this file to git.
In production, set RAILS_MASTER_KEY as an environment variable rather than mounting the key file. Rails checks this environment variable before looking for the file.
# Generate if you don't have one
rails credentials:edit
# View the master key (to set as env var)
cat config/master.key
Losing the master key is permanent — you cannot decrypt your credentials without it. Store it somewhere safe outside the repository.
Dockerfile for Rails
FROM ruby:3.3-slim AS base
# Install runtime dependencies
RUN apt-get update -qq && \
apt-get install -y \
postgresql-client \
nodejs \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /rails
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test && \
bundle exec bootsnap precompile --gemfile
# Install Node.js dependencies and build assets
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy application
COPY . .
# Precompile bootsnap
RUN bundle exec bootsnap precompile app/ lib/
# Precompile assets
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
# Set non-root user
RUN useradd -ms /bin/bash rails
RUN chown -R rails:rails /rails
USER rails
EXPOSE 3000
# Startup: run migrations then start server
CMD ["bash", "-c", "bundle exec rails db:migrate && bundle exec puma -C config/puma.rb"]
The SECRET_KEY_BASE_DUMMY=1 trick allows assets:precompile to run without a real secret key base during the Docker build. The actual key is injected at runtime via environment variable.
Database Configuration
Rails' DATABASE_URL environment variable is the cleanest production configuration:
# config/database.yml
production:
url: <%= ENV['DATABASE_URL'] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
connect_timeout: 10
variables:
statement_timeout: 30000 # Kill queries running over 30 seconds
The pool value should match or exceed RAILS_MAX_THREADS. Each Puma thread needs its own database connection. If your thread pool is 5 and connection pool is 3, you'll see ActiveRecord::ConnectionTimeoutError under load.
Database Connection on Internal Network
When your PostgreSQL or MariaDB runs on the same cloud platform as your Rails app:
DATABASE_URL=postgresql://myuser:mypassword@db:5432/myapp_production
The hostname db resolves to the database container's internal IP address over the private network. Queries never leave the host. This is significantly faster than connecting to an external database URL — typically 0.1-0.5ms round trip vs. 5-20ms for an external connection.
Sidekiq Configuration
Sidekiq requires Redis and runs as a separate process from your web server:
# config/sidekiq.yml
:concurrency: 10
:queues:
- [critical, 3]
- [default, 2]
- [low, 1]
:max_retries: 5
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: ENV['REDIS_URL'] }
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV['REDIS_URL'] }
end
Deploy Sidekiq as a separate service pointing at the same codebase:
# Start command for Sidekiq service
bundle exec sidekiq -C config/sidekiq.yml
The Sidekiq service connects to the same Redis and database as your web service. Both services need the same environment variables — DATABASE_URL, REDIS_URL, RAILS_MASTER_KEY.
Sidekiq and Database Connections
Sidekiq workers use database connections from the same ActiveRecord connection pool. Each Sidekiq thread can hold a connection. Configure accordingly:
# config/sidekiq.yml
:concurrency: 10 # 10 threads = 10 potential DB connections
# config/database.yml production:
pool: <%= ENV.fetch("DB_POOL") { 10 } %>
Set DB_POOL environment variable for the Sidekiq service separately from the web service if they have different concurrency requirements.
Asset Pipeline
In production, Rails serves precompiled assets. The Dockerfile above runs rails assets:precompile during the build. The compiled assets land in public/assets/.
If your platform provides static file serving through the reverse proxy (recommended — it's much more efficient than serving static files through Rails), disable Rails' static file serving:
RAILS_SERVE_STATIC_FILES=false # Reverse proxy handles /assets
If Rails is behind a plain reverse proxy that just passes all requests through:
RAILS_SERVE_STATIC_FILES=enabled # Rails serves its own assets
Sprockets (Rails 6 and earlier) and Propshaft (Rails 7+) both work correctly in containerized environments as long as public/assets is populated during the build and the correct RAILS_SERVE_STATIC_FILES value is set.
Health Check Endpoint
# config/routes.rb
get '/health', to: 'health#show'
# app/controllers/health_controller.rb
class HealthController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :verify_authenticity_token
def show
ActiveRecord::Base.connection.execute('SELECT 1')
render json: {
status: 'ok',
database: 'connected',
sidekiq: sidekiq_alive?
}, status: :ok
rescue => e
render json: {
status: 'error',
message: e.message
}, status: :service_unavailable
end
private
def sidekiq_alive?
Sidekiq.redis { |r| r.ping } == 'PONG'
rescue
false
end
end
The health endpoint checks both database connectivity and Redis (via Sidekiq). A deployment that brings up a web container but can't reach the database should return 503, which tells the platform not to route traffic to this container.
Logging
Rails' default log format is verbose and multi-line — hard to parse in log aggregation tools. Lograge condenses each request into a single JSON line:
# config/environments/production.rb
config.log_level = :info
config.log_formatter = ::Logger::Formatter.new
# With lograge gem:
config.lograge.enabled = true
config.lograge.formatter = Lograge::Formatters::Json.new
config.lograge.custom_options = lambda do |event|
{
time: event.time,
host: event.payload[:host],
user_id: event.payload[:user_id],
request_id: event.payload[:request_id],
}
end
Structured JSON logs are parseable by log aggregation systems and make debugging production issues significantly easier.
Production Checklist
- [ ]
RAILS_ENV=productionset - [ ]
RAILS_MASTER_KEYorSECRET_KEY_BASEset as environment variable - [ ]
DATABASE_URLpoints to internal database host - [ ]
REDIS_URLpoints to internal Redis host - [ ]
RAILS_LOG_TO_STDOUT=enabledso logs go to stdout - [ ]
bundle exec rails db:migrateruns before web server starts - [ ] Puma worker count set via
WEB_CONCURRENCY - [ ] Sidekiq deployed as separate service with same env vars
- [ ] Health check endpoint returns 200 with database connected
- [ ] Assets precompiled in Docker build
- [ ] No
config/master.keyfile in repository
Rails in production is reliable when you treat it as the infrastructure-aware framework it's designed to be. The defaults — Puma, Active Record connection pooling, encrypted credentials — are production-grade decisions. The work is configuring the surrounding infrastructure (database, Redis, background jobs, logs) to match.
Deploy Your App with Git Push
Automatic builds, environment variables, live logs, rollback, and custom domains. No server management required.
Deploy Free — No Card RequiredPowered by WHMCompleteSolution