From Rails Chaos to Code: How Infrastructure as Code Transformed My Deployment Nightmares
Learn how to manage Rails infrastructure as code using Ruby. Discover patterns for environment configuration, container orchestration, and secure deployment automation.
When I first started building Rails applications, I’d get my code working perfectly on my laptop. Then came the hard part: making it run somewhere else. On a real server. It felt like trying to rebuild a house from memory in a different country. Something always went wrong. A missing package, a wrong file permission, a secret key that wasn’t set.
That frustration led me to a better way: treating my servers and their setup like I treat my application code. This is often called Infrastructure as Code. Instead of clicking buttons in a cloud console or running random shell commands, I write definitions and configurations in files. These files go into version control, right alongside my models and controllers. They can be reviewed, tested, and, most importantly, repeated exactly.
Let me share some patterns that changed how I handle this. I write them in Ruby because it’s what I know, and it lets me build the tools that fit my needs.
I begin with the environment itself. A Rails app behaves differently in development than in production. The old way was to have big, messy if statements in config/environments.rb. I wanted something clearer, something I could see at a glance.
So I started writing classes that return a structured configuration hash. It’s just data. I can take that data and use it to set up my database connection, my cache store, or my job queues.
# This class holds the truth about what each environment needs.
class EnvironmentBlueprint
def self.assemble(environment_name)
case environment_name.to_sym
when :development
{
database: {
adapter: 'postgresql',
pool: 5,
timeout: 5000
},
cache: {
store: :memory_store,
size: 64.megabytes
}
}
when :production
{
database: {
adapter: 'postgresql',
pool: 25,
prepared_statements: true,
timeout: 5000
},
cache: {
store: :redis_cache_store,
url: ENV['REDIS_URL'],
expires_in: 24.hours
}
}
end
end
end
# When my app starts, it uses this blueprint.
blueprint = EnvironmentBlueprint.assemble(Rails.env)
ActiveRecord::Base.establish_connection(blueprint[:database])
Rails.cache = ActiveSupport::Cache.lookup_store(blueprint[:cache][:store], blueprint[:cache])
The beauty is in the simplicity. I can look at EnvironmentBlueprint and instantly know what production requires. I can even write a small test to make sure the configuration is valid before I try to boot a server with it. It separates the what (the requirements) from the how (the Rails setup code).
Next, I think about the physical pieces I need. A database server. A cache. A load balancer. In the past, I might have manually created these in a web portal. Now, I define them as objects in my code. Each object knows its type and its properties.
I can also define how these pieces depend on each other. The app server depends on the database being ready. The load balancer depends on the app servers. This creates a graph, a map of my infrastructure.
# This object represents one piece of my infrastructure.
class InfraComponent
attr_reader :id, :type, :config
def initialize(id, type, config = {})
@id = id
@type = type
@config = config
@prerequisites = [] # Things that must exist before this can.
end
def requires(component)
@prerequisites << component
self
end
def descriptor
{
id: @id,
type: @type,
config: @config,
needs: @prerequisites.map(&:id)
}
end
end
# Now I can map out my stack.
database = InfraComponent.new(
'app-database',
'postgresql',
{ version: '13', storage_gb: 100 }
)
cache = InfraComponent.new(
'app-cache',
'redis',
{ node_type: 'cache.t3.micro' }
).requires(database) # Cache needs the database to be set up first.
app_cluster = InfraComponent.new(
'web-servers',
'autoscaling_group',
{ min_size: 2, max_size: 10 }
).requires(cache)
# I can serialize this to JSON and give it to a tool that understands it.
stack_definition = [database, cache, app_cluster].map(&:descriptor)
File.write('stack.json', JSON.pretty_generate(stack_definition))
This pattern forces me to think about relationships and order. When I translate these definitions into actual cloud resources—using a tool like Terraform or a cloud provider’s SDK—I know the creation order is correct. It prevents errors where the app tries to connect to a database that doesn’t exist yet.
Containers are a natural fit for this mindset. A Dockerfile is code that builds an image. A docker-compose.yml file is code that describes how multiple containers work together. But I often want to generate these files dynamically, based on the app version or environment.
I create a Ruby class that builds the compose configuration. It feels more powerful than editing YAML directly.
class ContainerOrchestrator
def initialize(app_name, version_tag)
@app_name = app_name
@version_tag = version_tag
@container_definitions = {}
end
def define_container(name, specification)
@container_definitions[name] = specification
self
end
def compose_config
{
'version' => '3.8',
'services' => @container_definitions.transform_values do |spec|
config = {
'image' => "#{spec[:image]}:#{spec[:tag] || @version_tag}",
'environment' => spec[:env] || {}
}
config['ports'] = spec[:ports] if spec[:ports]
config['volumes'] = spec[:volumes] if spec[:volumes]
config['depends_on'] = spec[:depends_on] if spec[:depends_on]
config
end,
'volumes' => { 'postgres_data' => nil, 'redis_data' => nil }
}
end
end
# Defining my multi-service app becomes very clear.
orchestrator = ContainerOrchestrator.new('myapp', 'v1.2.3')
orchestrator
.define_container('app', {
image: 'myregistry/myapp',
env: { 'RAILS_ENV' => 'production', 'DATABASE_URL' => 'postgres://db:5432/app_prod' },
ports: ['3000:3000'],
depends_on: ['db', 'redis']
})
.define_container('db', {
image: 'postgres:13',
env: { 'POSTGRES_PASSWORD_FILE' => '/run/secrets/db_password' },
volumes: ['postgres_data:/var/lib/postgresql/data']
})
.define_container('redis', {
image: 'redis:6-alpine',
command: 'redis-server --appendonly yes',
volumes: ['redis_data:/data']
})
# Generate the final YAML file.
File.write('docker-compose.prod.yml', YAML.dump(orchestrator.compose_config))
I use this to create different compose files for development, test, and production from the same source definitions. The environment variables, ports, and volumes change, but the core relationships stay the same.
When I need to provision resources directly in a cloud like AWS, I use their CloudFormation service. It uses JSON or YAML templates, which can be hard to read and write. Instead, I build the template with Ruby.
This lets me use loops, conditionals, and variables—normal programming constructs—to create complex templates.
class CloudStackTemplate
def initialize(description)
@description = description
@resources = {}
end
def add_resource(logical_id, type, properties)
@resources[logical_id] = {
'Type' => type,
'Properties' => properties
}
self
end
def to_h
template = {
'AWSTemplateFormatVersion' => '2010-09-09',
'Description' => @description,
'Resources' => @resources
}
template
end
end
# Building a load-balanced web stack becomes more logical.
template = CloudStackTemplate.new('My Rails App Stack')
# I can use loops to create multiple, similar resources.
2.times do |i|
template.add_resource("AppServerSecurityGroup#{i}", 'AWS::EC2::SecurityGroup', {
'GroupDescription' => "Security group for app server #{i}",
'SecurityGroupIngress' => [
{
'IpProtocol' => 'tcp',
'FromPort' => 3000,
'ToPort' => 3000,
'SourceSecurityGroupId' => { 'Ref' => 'LoadBalancerSecurityGroup' }
}
]
})
end
template.add_resource('ApplicationLoadBalancer', 'AWS::ElasticLoadBalancingV2::LoadBalancer', {
'Scheme' => 'internet-facing',
'Type' => 'application'
})
File.write('cloud-stack.json', JSON.pretty_generate(template.to_h))
Writing templates this way reduces copy-paste errors. If I need to change a security group rule, I change it in one loop, not across five separate JSON blocks. The generated JSON is what CloudFormation consumes, but my source code is much easier to maintain.
A core idea is idempotency. It means I can run my setup script again and again, and the result will be the same. If a package is already installed, the script won’t try to install it again. It just makes sure the system is in the state I described.
I write small Ruby classes that check the current state before making any change.
class SystemSteward
def initialize(host_connection)
@host = host_connection
@changes_made = []
end
def report
@changes_made
end
def package_ensure(name, version = :latest)
if @host.package_installed?(name, version)
# It's already there. Do nothing.
puts "Package #{name} is already present."
else
puts "Installing #{name}..."
@host.install_package(name, version)
@changes_made << { action: :install_package, name: name }
end
end
def service_ensure(name, desired_state)
current_state = @host.service_status(name)
if current_state == desired_state
puts "Service #{name} is already #{desired_state}."
else
puts "Setting service #{name} to #{desired_state}..."
@host.configure_service(name, desired_state)
@changes_made << { action: :configure_service, name: name, state: desired_state }
end
end
end
# Using it might look like this:
# steward = SystemSteward.new(ssh_connection_to_server)
# steward.package_ensure('nginx')
# steward.package_ensure('postgresql-client-13', '13.5')
# steward.service_ensure('nginx', :running)
# steward.service_ensure('postgresql', :enabled)
# puts "Changes: #{steward.report}"
I can run this steward script daily. It will keep my servers in compliance. If a teammate logs in and manually stops a service, the next run of the script will start it again. The code defines the desired truth, and the script enforces it.
Secrets are the hardest part. API keys, database passwords, signing secrets. They can’t live in plain text in my Git repository. I need a way to store them safely but still access them in my code.
I built a simple secret keeper that uses encryption. Each secret is encrypted with a master key that is never stored with the code. It’s injected at runtime, from an environment variable or a secure vault.
require 'openssl'
require 'base64'
require 'json'
class SecretVault
def initialize(master_key)
@master_key = master_key
@algorithm = 'aes-256-gcm'
end
def seal(secret_name, plaintext_value, metadata = {})
cipher = OpenSSL::Cipher.new(@algorithm)
cipher.encrypt
cipher.key = @master_key
iv = cipher.random_iv
# Add some context so a secret for staging can't be used in production.
cipher.auth_data = metadata.to_json if metadata.any?
encrypted_text = cipher.update(plaintext_value) + cipher.final
auth_tag = cipher.auth_tag
{
name: secret_name,
ciphertext: Base64.strict_encode64(encrypted_text),
iv: Base64.strict_encode64(iv),
tag: Base64.strict_encode64(auth_tag),
meta: metadata,
sealed_at: Time.now.utc.iso8601
}
end
def reveal(encrypted_package)
cipher = OpenSSL::Cipher.new(@algorithm)
cipher.decrypt
cipher.key = @master_key
cipher.iv = Base64.strict_decode64(encrypted_package[:iv])
cipher.auth_tag = Base64.strict_decode64(encrypted_package[:tag])
# Verify the metadata matches.
if encrypted_package[:meta].any?
cipher.auth_data = encrypted_package[:meta].to_json
end
encrypted_bytes = Base64.strict_decode64(encrypted_package[:ciphertext])
cipher.update(encrypted_bytes) + cipher.final
end
end
# How I use it:
# MASTER_KEY = ENV['INFRA_MASTER_KEY']
# vault = SecretVault.new(MASTER_KEY)
#
# # To store a new secret (run once, output goes to a file).
# encrypted = vault.seal('database_password', 'my_super_secure_pw', { env: 'prod', service: 'db' })
# File.write('secrets/prod.db.password.json', JSON.pretty_generate(encrypted))
#
# # In the application, during startup:
# secret_package = JSON.parse(File.read('secrets/prod.db.password.json'), symbolize_names: true)
# db_password = vault.reveal(secret_package)
# ActiveRecord::Base.establish_connection(password: db_password, ...)
The JSON files with the encrypted secrets can be committed to Git. They’re safe without the master key. The metadata binding means I can’t accidentally load a staging secret into a production app; the decryption will fail. This gives me a way to manage secrets as code, without exposing the secrets themselves.
Finally, I test my infrastructure code. Just like I write unit tests for a User model, I write checks for my infrastructure definitions. Does my security group accidentally allow SSH from the entire internet? Is there a circular dependency where Resource A needs B, and B needs A?
I create a validation suite that runs against my definitions before I try to deploy them.
class InfrastructureAuditor
def audit(stack_definition)
findings = []
# Check 1: No SSH open to the world (0.0.0.0/0)
stack_definition['Resources'].each do |resource_name, config|
next unless config['Type'] == 'AWS::EC2::SecurityGroup'
ingress_rules = config.dig('Properties', 'SecurityGroupIngress') || []
ingress_rules.each do |rule|
if rule['CidrIp'] == '0.0.0.0/0' && rule['FromPort'] == 22
findings << "CRITICAL: #{resource_name} allows SSH from anywhere (0.0.0.0/0)."
end
end
end
# Check 2: Estimated cost warning for large instance types.
stack_definition['Resources'].each do |resource_name, config|
next unless config['Type'] == 'AWS::EC2::Instance'
instance_type = config.dig('Properties', 'InstanceType')
if instance_type && instance_type.start_with?('m5.24xlarge', 'c5.18xlarge')
findings << "COST: #{resource_name} uses large instance type #{instance_type}. Review if necessary."
end
end
findings
end
end
# In my build pipeline:
# template = JSON.parse(File.read('cloud-stack.json'))
# auditor = InfrastructureAuditor.new
# issues = auditor.audit(template)
#
# if issues.any?
# puts "Infrastructure checks failed:"
# issues.each { |issue| puts " - #{issue}" }
# exit 1 # Fail the build.
# end
These tests catch problems early. They’re fast, automated gates that prevent a bad configuration from ever being deployed. I can add more checks over time as I learn from mistakes.
These patterns didn’t appear overnight. I adopted them one at a time, usually to solve a specific pain point. Maybe I spent a weekend recovering from a failed manual deployment, so I started with the Environment Blueprint. Or I got a security alert about an open port, which led me to build the Infrastructure Auditor.
The common thread is treating everything as code. Code can be reviewed in a pull request. Code can have its history tracked. Code can be tested. When I apply these principles to servers, databases, and networks, my whole system becomes more stable, understandable, and repeatable.
It turns the scary, mysterious process of deployment into just another part of the development workflow, one I can control and improve.