Last Updated: December 2, 2025
Purpose: Define how external services are configured per environment to ensure safety and proper isolation.
| Service | Development | Test | Staging | Production |
|---|---|---|---|---|
| Letter Opener | Test adapter | Mailgun Sandbox | Mailgun Live | |
| Storage | Local disk | Temp disk | Cloudinary (staging folder) | Cloudinary |
| CRM | Disabled | Mocked | Disabled | Export-only |
| Logging | File | STDERR | Heroku Logs | Heroku Logs + optional APM |
| Setting | Value |
|---|---|
| Delivery Method | :letter_opener |
| Behavior | Opens emails in browser (gem: letter_opener) |
| Network Calls | None |
| Configuration | config/environments/development.rb |
# development.rb
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
No environment variables required.
| Setting | Value |
|---|---|
| Delivery Method | :test |
| Behavior | Emails stored in ActionMailer::Base.deliveries |
| Network Calls | None |
| Configuration | config/environments/test.rb |
# test.rb
config.action_mailer.delivery_method = :test
Testing emails in assertions:
assert_emails 1 do
ChampionSignupMailer.admin_notification(signup).deliver_now
end
email = ActionMailer::Base.deliveries.last
assert_equal ['admin@example.com'], email.to
| Setting | Value |
|---|---|
| Delivery Method | :mailgun_api (same as production) |
| Behavior | Sandbox mode OR restricted recipients |
| Network Calls | Yes, to Mailgun API |
| Configuration | Heroku config vars |
Option A: Mailgun Sandbox Domain
Use Mailgun’s sandbox domain which only delivers to verified recipients:
# Heroku config vars for staging
MAILGUN_API_KEY=key-xxx
MAILGUN_DOMAIN=sandboxXXX.mailgun.org # Sandbox domain
MAILGUN_API_HOST=api.mailgun.net
Option B: Recipient Whitelist (Code Change)
Add a staging guard in the mailer:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
before_action :restrict_recipients_in_staging
private
def restrict_recipients_in_staging
return unless Rails.env.staging?
allowed = ENV.fetch('STAGING_EMAIL_WHITELIST', '').split(',')
mail.to = mail.to.select { |email| allowed.include?(email) }
end
end
Recommendation: Use Option A (Sandbox Domain) — simpler, no code changes.
| Setting | Value |
|---|---|
| Delivery Method | :mailgun_api |
| Behavior | Live email delivery |
| Network Calls | Yes, to Mailgun API |
| Configuration | Heroku config vars + custom delivery class |
Required Environment Variables:
| Variable | Description | Example |
|---|---|---|
MAILGUN_API_KEY |
Mailgun API key | key-xxxxxxxxxxxxxxxx |
MAILGUN_DOMAIN |
Verified sending domain | mg.bualum.co |
MAILGUN_API_HOST |
API endpoint | api.mailgun.net |
Current Implementation:
MailgunDeliveryMethod class in config/initializers/mailgun_delivery.rbmailgun-ruby gemconfig/initializers/mailgun.rb| Setting | Value |
|---|---|
| Storage Service | :local |
| Location | storage/ directory |
| Network Calls | None |
| Configuration | config/environments/development.rb |
# development.rb
config.active_storage.service = :local
| Setting | Value |
|---|---|
| Storage Service | :test |
| Location | tmp/storage/ (ephemeral) |
| Network Calls | None |
| Configuration | config/environments/test.rb |
# test.rb
config.active_storage.service = :test
| Setting | Value |
|---|---|
| Storage Service | :cloudinary |
| Behavior | Real Cloudinary, isolated folder |
| Network Calls | Yes, to Cloudinary API |
| Configuration | Heroku config vars |
Option A: Same Cloudinary Account, Different Folder
Use Cloudinary’s folder feature to namespace staging uploads:
# Could add to an initializer or Active Storage config
# Cloudinary automatically uses Rails.env in paths
Option B: Separate Cloudinary Account
Create a free Cloudinary account for staging (25GB free).
Required Environment Variable:
| Variable | Description | Example |
|---|---|---|
CLOUDINARY_URL |
Full Cloudinary URL | cloudinary://api_key:api_secret@cloud_name |
Recommendation: Use same account with Rails.env namespacing — simpler, free tier usually sufficient.
| Setting | Value |
|---|---|
| Storage Service | :cloudinary |
| Behavior | Live file storage |
| Network Calls | Yes, to Cloudinary API |
| Configuration | Heroku config vars |
# production.rb
config.active_storage.service = :cloudinary
Required Environment Variable:
| Variable | Description |
|---|---|
CLOUDINARY_URL |
Production Cloudinary credentials |
The application does NOT have live Salesforce integration. CRM integration is export-only via the contact_id field (format: C-000000000).
| Feature | Description |
|---|---|
| Contact ID Import | CSV import includes Contact ID for CRM mapping |
| CSV Export | Exports include AdvRM - Contact ID column |
| Live API Calls | None — no Salesforce API integration exists |
| Environment | CRM Behavior |
|---|---|
| Development | Contact ID field exists, no network calls |
| Test | Contact ID field exists, no network calls |
| Staging | Contact ID field exists, no network calls |
| Production | Contact ID field exists, no network calls |
If live Salesforce integration is added:
# Example pattern for future CRM integration
class SalesforceService
def sync_contact(alumni)
return if Rails.env.development? || Rails.env.test?
return if ENV['SALESFORCE_DISABLED'] == 'true'
# API call logic
end
end
| Environment | Cache Store | Configuration |
|---|---|---|
| Development | :null_store (or :memory_store if enabled) |
rails dev:cache to toggle |
| Test | :null_store |
Always disabled |
| Staging | :memory_store |
Match production |
| Production | :memory_store (64MB) |
config/environments/production.rb |
| Environment | Session Store | |
|---|---|---|
| All | Cookie store | Default Rails behavior |
See config/initializers/session_store.rb if customized.
| Environment | Job Adapter | Redis Required |
|---|---|---|
| Development | Sidekiq | Yes (local) |
| Staging | Sidekiq | Yes (Heroku Redis) |
| Production | Sidekiq | Yes (Heroku Redis) |
# Install Redis
brew install redis
brew services start redis
# Start with foreman (Sidekiq worker starts automatically)
bin/dev
# Add Redis addon (if not already added)
heroku addons:create heroku-redis:mini --app your-app-name
# Scale the worker dyno UP
heroku ps:scale worker=1 --app your-app-name
# Scale the worker dyno DOWN (to save costs when not needed)
heroku ps:scale worker=0 --app your-app-name
The REDIS_URL environment variable is automatically set by Heroku when you add the Redis addon.
# Development: Check Redis
redis-cli ping # Should return PONG
# Heroku: Check worker status
heroku ps --app your-app-name # Should show worker=1
| Service | Missing Config Behavior | Rationale |
|---|---|---|
| Mailgun (Prod) | Fail fast (raise error) | Critical — emails must work |
| Mailgun (Staging) | Fail fast | Should match production behavior |
| Cloudinary (Prod) | Fail fast | Critical — photos must work |
| Cloudinary (Staging) | Fail fast | Should match production |
| CRM/Salesforce | Silent no-op | Export-only, not critical path |
Current (in mailgun_delivery.rb):
unless api_key && domain
Rails.logger.error "[Mailgun] Missing MAILGUN_API_KEY or MAILGUN_DOMAIN"
raise "Mailgun configuration missing"
end
✅ This is correct — fail fast in production.
Additional safeguard (optional, for staging):
Add recipient filtering to prevent accidental real-user emails:
# Add to ApplicationMailer or create staging interceptor
if Rails.env.staging?
class StagingEmailInterceptor
ALLOWED_DOMAINS = %w[@bualum.co @example.com].freeze
def self.delivering_email(message)
message.to = message.to&.select do |email|
ALLOWED_DOMAINS.any? { |domain| email.end_with?(domain) }
end
message.cc = []
message.bcc = []
end
end
ActionMailer::Base.register_interceptor(StagingEmailInterceptor)
end
Cloudinary uses the environment in paths by default. No additional guardrails needed.
Current implementation is export-only (CSV), so no API guardrails needed.
If live integration is added, use:
SALESFORCE_DISABLED=true # Staging/dev
| Variable | Required | Value |
|---|---|---|
| None for email | — | Uses letter_opener |
| None for storage | — | Uses local disk |
| Variable | Required | Value |
|---|---|---|
| None | — | All services use test adapters |
| Variable | Required | Value |
|---|---|---|
RAILS_ENV |
✅ | staging (if using custom env) or production |
RAILS_MASTER_KEY |
✅ | Master key for credentials |
DATABASE_URL |
✅ | Heroku auto-sets |
MAILGUN_API_KEY |
✅ | Staging/sandbox Mailgun key |
MAILGUN_DOMAIN |
✅ | Sandbox domain or staging domain |
MAILGUN_API_HOST |
❌ | api.mailgun.net (default) |
CLOUDINARY_URL |
✅ | Cloudinary credentials |
SECRET_KEY_BASE |
✅ | Heroku auto-generates |
| Variable | Required | Value |
|---|---|---|
RAILS_ENV |
✅ | production |
RAILS_MASTER_KEY |
✅ | Master key for credentials |
DATABASE_URL |
✅ | Heroku auto-sets |
MAILGUN_API_KEY |
✅ | Production Mailgun key |
MAILGUN_DOMAIN |
✅ | mg.bualum.co or verified domain |
MAILGUN_API_HOST |
❌ | api.mailgun.net (default) |
CLOUDINARY_URL |
✅ | Production Cloudinary credentials |
SECRET_KEY_BASE |
✅ | Heroku auto-generates |
RAILS_MAX_THREADS |
❌ | 3 (recommended) |
WEB_CONCURRENCY |
❌ | 1 (recommended for basic dynos) |
RAILS_LOG_LEVEL |
❌ | info (default) |
MAILGUN_API_KEY on stagingMAILGUN_DOMAIN on stagingCLOUDINARY_URL on stagingRAILS_MASTER_KEY on stagingMAILGUN_API_KEY is setMAILGUN_DOMAIN is production domainCLOUDINARY_URL is setRAILS_MASTER_KEY is set