In the evolving landscape of software development, continuous integration and continuous deployment (CI/CD) has become fundamental to modern application development. As a Ruby on Rails developer, I’ve discovered that implementing robust CI/CD practices not only streamlines the development process but also ensures high-quality code reaches production with minimal friction. Let me share eight powerful techniques that have transformed how I deploy Rails applications.
Containerized Test Automation
Containers have revolutionized how we approach testing in Rails applications. By containerizing our test environments, we create consistent, isolated contexts that eliminate the “it works on my machine” problem.
Docker provides an excellent foundation for containerized testing. I create a dedicated Dockerfile for testing that includes all dependencies needed for my Rails application:
# Dockerfile.test
FROM ruby:3.2.2
WORKDIR /app
# Install dependencies
RUN apt-get update -qq && \
apt-get install -y nodejs postgresql-client chromium-driver
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4
# Copy application code
COPY . .
# Configure Rails for test environment
ENV RAILS_ENV=test
# Run tests
CMD ["bundle", "exec", "rspec"]
This approach ensures that tests run in an environment identical to what other developers and CI systems use. I can further enhance this with Docker Compose to coordinate multiple services:
# docker-compose.test.yml
version: '3'
services:
db:
image: postgres:14
environment:
POSTGRES_PASSWORD: password
volumes:
- pg_test_data:/var/lib/postgresql/data
redis:
image: redis:7
app:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
- db
- redis
environment:
DATABASE_URL: postgres://postgres:password@db:5432/myapp_test
REDIS_URL: redis://redis:6379/1
volumes:
pg_test_data:
Multi-Stage Deployment Pipelines
Creating deployment pipelines with distinct stages has significantly improved my confidence in deployments. Each stage serves as a gate, allowing us to catch issues before they reach production.
A typical multi-stage pipeline I implement includes:
class DeploymentOrchestrator
STAGES = [:development, :staging, :production]
def initialize(commit_sha)
@commit_sha = commit_sha
@current_stage_index = 0
@results = {}
end
def execute
STAGES.each_with_index do |stage, index|
@current_stage_index = index
# Deploy to this environment
result = deploy_to(stage)
@results[stage] = result
# Stop pipeline if deployment failed
break unless result[:success]
# Wait for approval before production
if stage == :staging && STAGES[@current_stage_index + 1] == :production
wait_for_approval
end
end
generate_deployment_report
end
private
def deploy_to(stage)
puts "Deploying #{@commit_sha} to #{stage}..."
steps = [
:provision_environment,
:deploy_code,
:run_migrations,
:restart_services,
:run_smoke_tests
]
results = steps.each_with_object({}) do |step, results|
results[step] = send("#{step}_for", stage)
unless results[step][:success]
return { success: false, failed_step: step, details: results[step][:details] }
end
end
{ success: true, details: results }
end
def wait_for_approval
# Implementation for manual approval
puts "Waiting for approval to deploy to production..."
# This could integrate with Slack, email, or a deployment dashboard
end
end
With GitHub Actions, I create this multi-stage pipeline through workflow definitions:
# .github/workflows/deploy.yml
name: Deploy Pipeline
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run tests
run: docker-compose -f docker-compose.test.yml up --exit-code-from app
deploy-staging:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to staging
run: ./deploy.sh staging
- name: Run smoke tests
run: ./smoke_tests.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- uses: actions/checkout@v3
- name: Deploy to production
run: ./deploy.sh production
- name: Run smoke tests
run: ./smoke_tests.sh production
Feature Flag Integration
I’ve found feature flags invaluable for safely deploying code that isn’t ready for all users. By integrating feature flags with my CI/CD pipeline, I can deploy features incrementally.
Here’s how I implement a feature flag service in Rails:
# app/services/feature_flag_service.rb
class FeatureFlagService
def self.enabled?(feature_name, user = nil)
flag = FeatureFlag.find_by(name: feature_name)
return false unless flag
return flag.enabled_globally? if user.nil?
# Check user-specific rules
if flag.beta_testers? && user.beta_tester?
return true
end
# Percentage rollout
if flag.percentage_rollout > 0
user_id_hash = Digest::MD5.hexdigest(user.id.to_s).to_i(16)
return (user_id_hash % 100) < flag.percentage_rollout
end
flag.enabled_globally?
end
end
I integrate this with my deployment pipeline to control feature availability:
# In deployment script
if latest_commit_contains_feature?('new_billing_system')
feature = FeatureFlag.find_or_create_by(name: 'new_billing_system')
# Start with the feature disabled
feature.update(
enabled_globally: false,
beta_testers: true,
percentage_rollout: 0
)
puts "Deployed new_billing_system behind feature flag (beta testers only)"
end
In my application code, I check the flag before exposing new features:
# app/controllers/billing_controller.rb
def show
if FeatureFlagService.enabled?('new_billing_system', current_user)
render 'billing/new_show'
else
render 'billing/show'
end
end
Database Migration Safety
Database migrations represent one of the riskiest parts of deployment. I’ve developed a systematic approach to make them safer.
First, I add validation to my deployment pipeline:
class DatabaseMigrationValidator
def initialize(migrations)
@migrations = migrations
end
def validate
@migrations.each do |migration|
content = File.read(migration.filename)
if potentially_destructive?(content)
flag_for_review(migration)
end
if requires_downtime?(content)
flag_downtime_required(migration)
end
end
end
private
def potentially_destructive?(content)
destructive_patterns = [
/drop_table/,
/remove_column/,
/remove_index/,
/rename_/
]
destructive_patterns.any? { |pattern| content.match?(pattern) }
end
def requires_downtime?(content)
downtime_patterns = [
/add_column.+default: /,
/change_column/,
/add_reference.+index: true/
]
downtime_patterns.any? { |pattern| content.match?(pattern) }
end
end
Then I ensure migrations are backward compatible whenever possible:
class AddAccountTypeToUsers < ActiveRecord::Migration[7.0]
def up
# Step 1: Add the column without a default value
add_column :users, :account_type, :string
# Step 2: Set default values in batches
User.in_batches(of: 1000) do |batch|
batch.update_all(account_type: 'standard')
end
# Step 3: Add the NOT NULL constraint
change_column_null :users, :account_type, false
end
def down
remove_column :users, :account_type
end
end
Parallel Test Execution
I’ve dramatically reduced CI time by implementing parallel test execution. Rails makes this straightforward with proper configuration.
First, I configure my test suite for parallelization:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.strategy = :transaction
end
config.before(:each, js: true) do
DatabaseCleaner.strategy = :truncation
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Then in my CI configuration, I set up parallel test runners:
# .github/workflows/tests.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
bundler-cache: true
- name: Setup test database
run: bundle exec rails db:create db:schema:load
- name: Run tests
run: |
bundle exec rspec \
--format progress \
--require parallel_tests/rspec/runtime_logger \
--format ParallelTests::RSpec::RuntimeLogger \
--partition-number ${{ matrix.test-group }} \
--partition-count 4
For applications with larger test suites, I use the parallel_tests gem:
# Gemfile
group :test do
gem 'parallel_tests'
end
This allows me to run tests in parallel even during local development:
bundle exec parallel_rspec spec/
Automated Code Quality Checks
Maintaining code quality is non-negotiable. I automate quality checks to prevent subpar code from reaching production.
I configure RuboCop to enforce style guidelines:
# .rubocop.yml
AllCops:
TargetRubyVersion: 3.2
NewCops: enable
Exclude:
- 'db/schema.rb'
- 'bin/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
Style/Documentation:
Enabled: false
Metrics/BlockLength:
Exclude:
- 'spec/**/*'
- 'config/routes.rb'
- 'config/environments/*.rb'
I integrate Brakeman for security vulnerability scanning:
# Gemfile
group :development, :test do
gem 'brakeman', require: false
end
And I include code coverage tracking with SimpleCov:
# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
add_filter '/spec/'
add_filter '/config/'
minimum_coverage 90
end
These tools integrate into my CI pipeline:
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
bundler-cache: true
- name: RuboCop
run: bundle exec rubocop
- name: Brakeman
run: bundle exec brakeman
- name: Check pending migrations
run: bundle exec rails db:migrate:status
- name: Check code smell
run: bundle exec rails_best_practices .
Rollback Strategies
Despite our best efforts, deployments can fail. I implement comprehensive rollback strategies to minimize downtime.
Here’s my approach to automated rollbacks:
class RollbackService
attr_reader :environment, :deployment_record
def initialize(environment, deployment_record)
@environment = environment
@deployment_record = deployment_record
end
def execute
log_rollback_initiation
# Get the previous successful deployment
previous_deployment = find_previous_successful_deployment
if previous_deployment.nil?
raise "No previous successful deployment found to roll back to!"
end
# Roll back code
checkout_previous_version(previous_deployment.commit_sha)
# Roll back database if necessary
if deployment_record.included_migrations?
rollback_database_migrations
end
# Restart application servers
restart_application_servers
# Run smoke tests
run_smoke_tests
log_rollback_completion(previous_deployment)
end
private
def find_previous_successful_deployment
Deployment
.where(environment: environment, status: 'successful')
.where('created_at < ?', deployment_record.created_at)
.order(created_at: :desc)
.first
end
def checkout_previous_version(commit_sha)
# Implementation depends on deployment strategy
# For example, using Capistrano:
system("cd #{deploy_path} && RAILS_ENV=#{environment} bundle exec cap #{environment} deploy REVISION=#{commit_sha}")
end
def rollback_database_migrations
executed_migrations = deployment_record.migrations
# Roll back each migration in reverse order
executed_migrations.reverse.each do |migration|
system("cd #{deploy_path} && RAILS_ENV=#{environment} bundle exec rails db:migrate:down VERSION=#{migration.version}")
end
end
end
I also implement feature toggles for critical changes that can’t be easily rolled back:
# config/initializers/feature_toggles.rb
FEATURES = {
new_billing_system: ENV['FEATURE_NEW_BILLING_SYSTEM'] == 'true',
enhanced_search: ENV['FEATURE_ENHANCED_SEARCH'] == 'true',
api_v2: ENV['FEATURE_API_V2'] == 'true'
}
def feature_enabled?(feature_name)
FEATURES[feature_name] || false
end
Canary Deployments
I’ve found canary deployments particularly effective for high-traffic applications. This involves gradually routing traffic to the new version while monitoring for issues.
class CanaryDeploymentService
attr_reader :app_name, :new_version, :rollout_percentage
def initialize(app_name, new_version, initial_percentage = 5)
@app_name = app_name
@new_version = new_version
@rollout_percentage = initial_percentage
end
def start_canary
# Deploy new version alongside current version
deploy_version(new_version)
# Configure load balancer to route traffic percentage
update_traffic_routing(rollout_percentage)
# Schedule monitoring check
schedule_health_check(10.minutes)
end
def increase_traffic(new_percentage)
@rollout_percentage = new_percentage
update_traffic_routing(rollout_percentage)
schedule_health_check(10.minutes)
end
def complete_rollout
update_traffic_routing(100)
cleanup_old_version
end
def rollback
# Route all traffic back to old version
update_traffic_routing(0)
cleanup_new_version
end
private
def deploy_version(version)
# Implementation depends on your infrastructure
# For Kubernetes:
system("kubectl apply -f k8s/#{app_name}-#{version}.yaml")
end
def update_traffic_routing(percentage)
# For nginx:
template = ERB.new(File.read('config/nginx/canary.conf.erb'))
result = template.result_with_hash(
canary_version: new_version,
canary_percentage: percentage
)
File.write('/etc/nginx/sites-enabled/canary.conf', result)
system('nginx -s reload')
end
def schedule_health_check(delay)
CanaryHealthCheckJob.set(wait: delay).perform_later(
app_name: app_name,
version: new_version,
current_percentage: rollout_percentage
)
end
end
For this to work effectively, I implement comprehensive monitoring:
class CanaryHealthCheckJob < ApplicationJob
queue_as :default
def perform(app_name:, version:, current_percentage:)
metrics = fetch_canary_metrics(app_name, version)
if metrics_indicate_failure?(metrics)
# Alert team and roll back automatically
alert_team("Canary deployment for #{app_name} v#{version} showing errors, initiating rollback")
CanaryDeploymentService.new(app_name, version).rollback
elsif current_percentage < 100
# Increase traffic if metrics look good
next_percentage = calculate_next_percentage(current_percentage)
CanaryDeploymentService.new(app_name, version, current_percentage).increase_traffic(next_percentage)
else
# Complete deployment
CanaryDeploymentService.new(app_name, version).complete_rollout
alert_team("#{app_name} v#{version} successfully deployed to 100% of traffic")
end
end
private
def fetch_canary_metrics(app_name, version)
# Connect to monitoring system (e.g., Prometheus, Datadog)
# Return relevant metrics for the canary deployment
end
def metrics_indicate_failure?(metrics)
# Logic to determine if metrics indicate a problem
# e.g., error rate > 1%, latency increased by 30%, etc.
end
def calculate_next_percentage(current)
# Implement progressive rollout strategy
# e.g., 5% -> 20% -> 50% -> 100%
case current
when 5 then 20
when 20 then 50
when 50 then 100
else 100
end
end
end
By implementing these eight CI/CD automation techniques, I’ve transformed how I deploy Rails applications. The result is more reliable, efficient, and stress-free deployments. My team spends less time on manual deployment tasks and more time building features that matter to users.
These practices have not only improved the technical aspects of deployment but have also enhanced team collaboration. With clearly defined processes and automated safeguards, everyone can contribute to deployment with confidence. The journey to CI/CD maturity is ongoing, but these techniques provide a solid foundation for any Rails application.