From Development to Production: 7 Proven Methods for Automated Rails Application Deployment
Learn 7 proven methods for automating Rails deployments: from infrastructure as code to zero-downtime releases. Turn scary deployments into reliable, one-click processes. Start building better delivery today.
Getting an application from your development machine to a server where real people can use it is often the hardest part of building software. For years, I found this process stressful. It was manual, error-prone, and usually involved late-night commands that could break the live site. I knew there had to be a better way.
The goal is simple: make the act of releasing new code as boring and reliable as turning on a light switch. In the Ruby on Rails world, we can achieve this through specific, repeatable methods. Here are seven key methods for setting up automated, reliable delivery of your Rails application.
The Blueprint Method
Before you automate anything, you need a single source of truth for how your application is set up. This means writing configuration files that define your servers, networks, and software. Instead of clicking around in a server admin panel, you write code to describe your infrastructure.
This approach lets you rebuild your entire setup from scratch using these files. If a server fails, you can launch a new, identical one quickly. It also means every environment, from a developer’s laptop to the live site, can be nearly identical, removing the classic problem of “it works on my machine.”
For a Rails app, this often starts with the database.yml and credentials.yml.enc files, but extends to server specifications. You might use a tool like Terraform to write this setup code.
# A simple Ruby class to manage environment-specific settings.
# This acts as our 'blueprint' for what changes in each stage.
class EnvironmentBlueprint
def self.settings_for(stage)
case stage.to_sym
when :development
{
database_host: 'localhost',
asset_compilation: false,
email_delivery: :test,
cache_store: :memory_store
}
when :staging
{
database_host: 'db-staging.internal',
asset_compilation: true,
email_delivery: :smtp,
cache_store: :redis_store
}
when :production
{
database_host: 'db-production-cluster.internal',
asset_compilation: true,
email_delivery: :sendgrid,
cache_store: :redis_store,
force_ssl: true
}
end
end
def self.apply(stage)
config = Rails.application.config
settings = settings_for(stage)
config.force_ssl = settings[:force_ssl] if settings[:force_ssl]
config.action_mailer.delivery_method = settings[:email_delivery]
end
end
# During application startup, apply the blueprint.
# In config/environments/production.rb, you might have:
EnvironmentBlueprint.apply(:production)
The main idea is that the differences between your development, testing, and live environments are controlled by code, not by memory or manual server tweaks. This blueprint becomes the first step in any automated process, ensuring consistency.
The Assembly Line Method
Imagine a car moving down an assembly line. At each station, a specific task is performed: installing the engine, adding doors, painting. Software deployment can work the same way. An automated pipeline is your assembly line. It takes your code, moves it through a series of stations (steps), and produces a deployed application.
Each step is small and has a single job: run tests, check code style, build assets, deploy to a test server. If any step fails, the line stops. This prevents broken code from moving forward. For Rails, a basic pipeline might look like this: Test -> Build -> Deploy to Staging -> Final Checks -> Deploy to Production.
You can build this with tools like GitHub Actions, GitLab CI, or Jenkins. The pipeline is defined in a file in your code repository, making it version-controlled and repeatable.
# .github/workflows/deploy.yml - A GitHub Actions pipeline definition
name: Deploy Rails Application
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1.2'
- name: Install Dependencies
run: bundle install
- name: Run Tests
run: bundle exec rspec
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build Docker Image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to Registry
run: docker push myapp:${{ github.sha }}
deploy-staging:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to Staging
run: |
ssh deploy@staging-server "docker pull myapp:${{ github.sha }}"
ssh deploy@staging-server "docker stop myapp-staging || true"
ssh deploy@staging-server "docker run --rm -d --name myapp-staging -p 3000:3000 myapp:${{ github.sha }}"
This file defines a clear sequence. The test job must pass before build-and-push starts. That job must finish before deploy-staging runs. It’s a visual and enforced flow. In practice, your Rails-specific steps, like assets:precompile, would be inside the Docker build step or a separate job.
The Seamless Switch Method
The most nerve-wracking part of a deployment is the cut-over—the moment you switch users from the old version of your app to the new one. The goal is to make this switch with no noticeable downtime. Users shouldn’t see error messages or get logged out.
The classic technique is to use a symlink. Your application lives in a directory like /var/www/app/current. This isn’t a real folder; it’s a symbolic link pointing to a timestamped release directory like /var/www/app/releases/20231027120000. To deploy, you build the new version in /var/www/app/releases/20231027121500, and then atomically change the current symlink to point to the new directory. The web server (like Puma or Passenger) picks up the change instantly.
This method often pairs with “rolling restarts” of your application server processes, where old processes finish their current requests while new ones start up with the new code.
# A simplified version of a seamless deployment script (Capistrano uses similar logic).
class SeamlessDeployer
RELEASES_DIR = '/var/www/myapp/releases'
CURRENT_LINK = '/var/www/myapp/current'
def deploy(new_code_path)
# 1. Create a unique timestamped directory for this release.
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
release_path = File.join(RELEASES_DIR, timestamp)
FileUtils.mkdir_p(release_path)
# 2. Copy the new application code into the release directory.
# In reality, you'd likely clone a git repo here.
FileUtils.cp_r("#{new_code_path}/.", release_path)
# 3. Link shared directories that persist between releases (like logs, uploads).
link_shared_assets(release_path)
# 4. Run any build steps inside the release directory (e.g., asset compilation).
run_build_steps(release_path)
# 5. THE CRITICAL SWITCH: Atomically update the 'current' symlink.
# This is a single, fast filesystem operation.
tmp_link = "#{CURRENT_LINK}.tmp"
File.symlink(release_path, tmp_link)
File.rename(tmp_link, CURRENT_LINK) # This rename is atomic.
# 6. Gracefully restart the application server.
restart_application_server
# 7. (Optional) Clean up old releases, keeping only the last 5.
cleanup_old_releases
end
private
def link_shared_assets(release_path)
['log', 'tmp', 'storage', 'public/uploads'].each do |dir|
shared_path = "/var/www/myapp/shared/#{dir}"
target_path = File.join(release_path, dir)
FileUtils.rm_rf(target_path) if File.exist?(target_path)
FileUtils.ln_s(shared_path, target_path)
end
end
def restart_application_server
# Send a signal to Puma/Passenger to restart workers gracefully.
pid_file = '/var/www/myapp/shared/tmp/pids/puma.pid'
if File.exist?(pid_file)
pid = File.read(pid_file).strip.to_i
Process.kill('USR1', pid) # Puma's phased-restart signal
end
end
end
The magic is in step 5. File.rename is an atomic operation on most filesystems. At one precise instant, all new requests start being served from the new release directory. This method, combined with a reverse proxy like Nginx, makes deployments invisible.
The Safe Database Change Method
Changing your Rails application code is one thing. Changing the database that holds all your users’ data is another. A flawed database migration can cause severe, lasting problems. The key is to design migrations that are safe to run while the application is live.
The golden rule: write migrations so they can be run in multiple steps, and each step is compatible with both the old and new versions of your application code. This is sometimes called a “expand/contract” or “parallel change” pattern.
For example, to rename a column, you don’t just run rename_column. That would break the old code still running during deployment. Instead, you:
- Expand: Add a new column alongside the old one.
- Migrate: Update your application code to write to both columns. Deploy this version.
- Backfill: Copy all historical data from the old column to the new one (in small batches).
- Migrate: Update your application code to read only from the new column. Deploy this version.
- Contract: Once everything works, remove the old column in a final migration.
# Bad migration: Causes immediate downtime during deployment.
class BadRenameColumn < ActiveRecord::Migration[7.0]
def change
rename_column :users, :preferences, :settings # Old code breaks instantly!
end
end
# Safe, multi-step migration plan:
# Step 1 Migration: Add the new column.
class AddSettingsToUsers < ActiveRecord::Migration[7.0]
def up
# Use `if_not_exists` to make the migration safe to run multiple times.
add_column :users, :settings, :jsonb, if_not_exists: true
# Create an index concurrently to avoid locking the table.
# This can't be done in the same transaction.
commit_db_transaction
begin
execute <<-SQL.squish
CREATE INDEX CONCURRENTLY IF NOT EXISTS
index_users_on_settings ON users USING gin(settings)
SQL
ensure
begin_db_transaction
end
end
def down
remove_column :users, :settings, if_exists: true
end
end
# Step 2: Update your application model with dual write logic.
class User < ApplicationRecord
# Temporary accessors during the transition.
alias_attribute :legacy_preferences, :preferences
before_save :sync_preferences_to_settings, if: :preferences_changed?
private
def sync_preferences_to_settings
# Write to both columns. The new code reads from `settings`.
self.settings = preferences
end
end
# Step 3: A background job to backfill data safely.
class BackfillSettingsJob < ApplicationJob
queue_as :low_priority
def perform
# Process in small batches to avoid overloading the database.
User.where(settings: nil).find_in_batches(batch_size: 500) do |batch|
User.transaction do
batch.each do |user|
user.update_column(:settings, user.preferences)
end
end
sleep(0.1) # Small pause between batches.
end
end
end
# Step 4: After deploying code that reads solely from `settings`,
# run the final migration to remove the old column.
class RemovePreferencesFromUsers < ActiveRecord::Migration[7.0]
def up
# First, ensure no application code is using the column.
# You might add a check here or run it weeks later.
remove_column :users, :preferences, if_exists: true
end
def down
add_column :users, :preferences, :jsonb
end
end
This process takes longer but is vastly safer. It treats the database schema as a public API that must be changed with care. Tools like Strong Migrations for Rails can help enforce these safe patterns.
The Isolated Package Method
A common deployment headache is environment differences. “It uses Ruby 3.1.1 on the server, but I have 3.1.2 locally.” “This native gem won’t compile on the production OS.” Containerization, specifically with Docker, solves this by packaging your application and all its dependencies into a single, portable unit called a container image.
Think of it like a shipping container for software. It doesn’t matter if the ship (your laptop) or the port (the production server) is different; everything inside the container—Ruby, Rails, system libraries, your code—is identical and isolated.
For Rails, a Dockerfile defines how to build this image. Your deployment pipeline builds the image, tests it, and then ships the exact same image to staging and production. This guarantees consistency.
# Dockerfile
# Start from an official Ruby image for a consistent base.
FROM ruby:3.1.2-slim-bullseye AS builder
# Install system dependencies needed to build gems and assets.
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
build-essential \
nodejs \
yarnpkg \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Set an environment variable for the install location.
ENV APP_HOME /app
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
# Install gems into a separate layer for better caching.
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install --jobs=4 --retry=3
# Copy the application code.
COPY . .
# Precompile assets. This happens during build, not on the server.
RUN bundle exec rails assets:precompile
# ============================
# Second stage: Create the final, lean runtime image.
FROM ruby:3.1.2-slim-bullseye
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
ENV APP_HOME /app
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
# Copy only the installed gems and application from the builder stage.
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder $APP_HOME $APP_HOME
# The user your app runs as (security best practice).
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser $APP_HOME
USER appuser
# The command to run the application.
# This could be Puma, or a script that runs migrations then starts the server.
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
The pipeline from the Assembly Line Method would run docker build using this file. The resulting image is a self-contained artifact. Deploying then becomes a matter of transferring this image and running it. Tools like Docker Compose or Kubernetes manage running these containers, but the principle remains: deploy a known, tested unit, not a list of instructions.
The Feature Switch Method
What if you could deploy new code on a Tuesday afternoon but only show it to a small group of users? Or turn it off instantly if something goes wrong, without rolling back the entire deployment? Feature flags (or feature toggles) let you do this.
A feature flag is a simple conditional in your code that checks whether a feature is “on.” The switch is controlled by external configuration, often a database or a specialized service. This separates deployment from release. You can deploy the code that contains a new feature, but keep it hidden until you’re ready to “flip the switch.”
This is incredibly powerful. You can enable a feature for just your team, then for 1% of users to monitor performance, then for 50%, and finally for everyone—all without a single new deployment. If bugs appear at the 1% stage, you just turn it off.
# A simple in-database feature flag implementation.
# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
validates :name, presence: true, uniqueness: true
# Check if a feature is active for a given user or context.
def self.active?(name, user = nil)
flag = find_by(name: name)
return false unless flag&.enabled
# Implement rollout strategies.
case flag.strategy
when 'boolean'
true
when 'percentage'
# Deterministically enable for a percentage of users based on their ID.
# This gives a consistent experience per user.
user_id_hash = Digest::SHA1.hexdigest(user.id.to_s).to_i(16) if user
(user_id_hash % 100) < flag.percentage
when 'cohort'
user && flag.cohort_list.include?(user.cohort)
when 'admin'
user&.admin?
else
false
end
end
end
# In your application code, wrap the new feature.
def show_new_dashboard
if FeatureFlag.active?('new_user_dashboard', current_user)
render 'dashboard/v2'
else
render 'dashboard/v1'
end
end
# In a background job, you can gradually ramp up the percentage.
class FeatureRolloutJob < ApplicationJob
def perform(feature_name)
flag = FeatureFlag.find_by(name: feature_name)
return unless flag
# Check error rates and performance metrics.
if system_healthy_with_feature?(feature_name)
# Increase rollout by 5% up to 100%.
new_percentage = [flag.percentage + 5, 100].min
flag.update!(percentage: new_percentage)
# Schedule the next check if not yet at 100%.
FeatureRolloutJob.set(wait: 15.minutes).perform_later(feature_name) if new_percentage < 100
else
# Something's wrong! Roll back and alert the team.
flag.update!(percentage: 0, enabled: false)
AlertService.notify("Feature #{feature_name} rolled back due to errors.")
end
end
end
This method changes deployment from a high-stakes event to a routine one. You deploy code with flags “off.” The risk comes later when you enable it, but you have fine-grained control and an instant “off” button. Services like LaunchDarkly, Flipper, or built-in Rails Credentials for simple cases can manage these flags.
The Watchtower Method
After you deploy, how do you know it worked? You need immediate feedback. This method involves setting up automatic checks that run right after deployment to verify the application is healthy. If these checks fail, the system can even automatically revert to the previous version.
These checks, often called health checks or synthetic monitoring, go beyond “is the server responding?” They might check that the database is connected, that core background jobs are processing, that an API endpoint returns valid data, or that response times are within normal limits.
# app/controllers/health_controller.rb
class HealthController < ApplicationController
# Skip authentication and other filters for this endpoint.
skip_before_action :authenticate_user!
def show
checks = {
database: database_connected?,
redis: redis_connected?,
sidekiq: sidekiq_processing?,
storage: storage_writable?,
version: current_app_version
}
status = checks.values.all? ? :ok : :service_unavailable
render json: checks, status: status
end
private
def database_connected?
ActiveRecord::Base.connection.active?
rescue PG::ConnectionBad
false
end
def redis_connected?
Sidekiq.redis_info
true
rescue Redis::CannotConnectError
false
end
def sidekiq_processing?
# Check that a queue is being processed by looking for recent heartbeats.
workers = Sidekiq::Workers.new
!workers.empty? || Sidekiq::ProcessSet.new.any?
end
def storage_writable?
# For Active Storage, check if you can write to the configured service.
case Rails.configuration.active_storage.service
when :local
File.writable?(ActiveStorage::Blob.service.root)
when :amazon, :google, :microsoft
# Could attempt a simple head/put operation to a test bucket.
true # Placeholder - implement actual check.
else
true
end
end
def current_app_version
# Read from a file created during deployment, or from git.
File.read(Rails.root.join('REVISION')).strip if File.exist?(Rails.root.join('REVISION'))
end
end
# A post-deployment validation script that uses the health endpoint.
class PostDeployValidator
def initialize(app_url)
@app_url = app_url
end
def run_checks
response = HTTParty.get("#{@app_url}/health", timeout: 10)
health_data = JSON.parse(response.body)
if response.code == 200 && health_data['database'] && health_data['redis']
puts "✅ Health checks passed for #{@app_url}"
true
else
puts "❌ Health checks failed: #{health_data}"
false
end
rescue Net::ReadTimeout, Errno::ECONNREFUSED => e
puts "❌ Cannot connect to #{@app_url}: #{e.message}"
false
end
end
# In your deployment pipeline, after deploying, call the validator.
# If it fails, trigger a rollback.
validator = PostDeployValidator.new('https://staging.myapp.com')
if validator.run_checks
puts "Proceeding with production deployment."
else
puts "Staging health check failed. Initiating automatic rollback."
# Trigger a script to revert to the last known good version.
system('bin/rollback_deployment')
exit(1)
end
The Watchtower method gives you confidence. It moves you from wondering if the deployment worked to getting a clear pass/fail signal. Integrating this into your pipeline creates a safety net that can catch problems before they impact all your users.
Bringing It All Together
Individually, each of these methods solves a specific problem. Together, they form a resilient system for delivering software.
You start with a Blueprint (Infrastructure as Code) so you know exactly what you’re deploying to. You build an Assembly Line (CI/CD Pipeline) to automate the journey of your code. You package your app into an Isolated Package (Container) for consistency. You use a Seamless Switch (Zero-Downtime Deployment) to update without disruption. You manage risky changes with Safe Database migrations. You control exposure with Feature Switches. And you verify everything with the Watchtower (Post-Deploy Validation).
Implementing all seven at once can be overwhelming. My advice is to start with the one that causes you the most pain. For many teams, that’s the Assembly Line—just automating the basic test and deploy steps. Then add the Watchtower for validation. Then tackle Safe Database changes.
The journey is incremental. Each step you take makes deployments less scary, more frequent, and more reliable. You move from a place of fear and manual effort to a place where pushing a button to deliver value is a normal, uneventful part of your day. And that’s when you can really start to focus on building great software.