Rails Hosting · 2025

Ruby on Rails Cloud Hosting: Deploy Rails Apps to Production

Updated April 2025 · 9 min read

Ruby 3.3, Puma, asset pipeline, and Sidekiq — managed Rails hosting with git push deployment.

HomeBlog › Ruby on Rails Cloud Hosting: Deploy Rails Apps to Production

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=production set
  • [ ] RAILS_MASTER_KEY or SECRET_KEY_BASE set as environment variable
  • [ ] DATABASE_URL points to internal database host
  • [ ] REDIS_URL points to internal Redis host
  • [ ] RAILS_LOG_TO_STDOUT=enabled so logs go to stdout
  • [ ] bundle exec rails db:migrate runs 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.key file 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 Required

Powered by WHMCompleteSolution