The final piece of our Docker migration: deploying containerized Rails applications to Heroku with bulletproof CI/CD,
comprehensive monitoring, and the performance results that justified the entire effort.
The Deployment Challenge
After solving our system dependency conflicts, we had a working Docker
container that built reliably and performed well locally. But getting it deployed to production on Heroku’s Container
Registry presented new challenges:
- Configuration management for containerized applications
- CI/CD pipeline integration with GitHub Actions
- Environment variable handling in Docker contexts
- Database migrations in containerized deployments
- Performance monitoring to validate our optimization goals
The stakes were high: our entire migration effort would be judged by production performance, and we needed to
demonstrate the promised 60% memory reduction while maintaining reliability.
Heroku Container Registry Architecture
Heroku’s Container Registry allows deploying custom Docker images instead of using buildpacks, but it requires specific
configuration and deployment patterns.
Understanding the Container Registry Flow
graph LR
A[Local Development] --> B[GitHub Push]
B --> C[GitHub Actions CI]
C --> D[Build Docker Image]
D --> E[Push to Registry]
E --> F[Heroku Release]
F --> G[Production Runtime]
The key difference from buildpack deployments: you control the entire runtime environment, not just the application
code.
heroku.yml Configuration
The heroku.yml
file replaces buildpack configuration and defines how Heroku builds and runs your container:
# heroku.yml
build:
docker:
web: Dockerfile
args:
RUBY_VERSION: "3.2.7"
NODE_VERSION: "20.18.3"
config:
RAILS_ENV: production
NODE_ENV: production
run:
web: bundle exec puma -C config/puma.rb
release: bundle exec rake db:migrate
setup:
addons:
- plan: heroku-postgresql:essential-0
- plan: heroku-redis:mini
config:
RAILS_LOG_TO_STDOUT: true
RAILS_SERVE_STATIC_FILES: true
Key Configuration Elements
Build Arguments:
build:
args:
RUBY_VERSION: "3.2.7" # Passed to Dockerfile ARG instructions
NODE_VERSION: "20.18.3"
These values are available in your Dockerfile as ARG
variables, enabling version management through configuration.
Process Types:
run:
web: bundle exec puma -C config/puma.rb # Main web process
release: bundle exec rake db:migrate # Pre-release tasks
The release
process runs before each deployment, perfect for database migrations.
Environment Configuration:
setup:
config:
RAILS_LOG_TO_STDOUT: true # Container-friendly logging
RAILS_SERVE_STATIC_FILES: true # Serve assets from container
Production Dockerfile Optimization
The production Dockerfile builds on our multi-stage architecture with deployment-specific optimizations:
# Production Dockerfile
FROM whittakertech/choir-base:latest
# Configure production environment
ENV RAILS_ENV=production \
NODE_ENV=production \
BUNDLE_WITHOUT="development test" \
BUNDLE_DEPLOYMENT=true \
RAILS_LOG_TO_STDOUT=true \
RAILS_SERVE_STATIC_FILES=true
# Install production gems with deployment flag
COPY Gemfile* .ruby-version ./
RUN bundle config set --local without $BUNDLE_WITHOUT && \
bundle config set --local deployment true && \
bundle config set --local path vendor/bundle && \
bundle install --jobs 4 --retry 3
# Install production npm dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy application code
COPY . .
# Build assets in production mode
RUN yarn build && yarn build:css && \
SECRET_KEY_BASE=temporarykey bundle exec rake assets:precompile
# Remove build artifacts to reduce image size
RUN rm -rf node_modules/.cache \
tmp/cache \
log/* \
.git \
spec/ \
test/
# Configure container runtime
EXPOSE $PORT
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Production-Specific Optimizations
Bundle Configuration:
ENV BUNDLE_DEPLOYMENT=true
bundle config set --local deployment true
Deployment mode ensures gems are installed exactly as specified in Gemfile.lock
, preventing version drift.
Asset Precompilation:
RUN SECRET_KEY_BASE=temporarykey bundle exec rake assets:precompile
Assets are compiled during the build process, not at runtime, improving startup performance.
Image Size Reduction:
RUN rm -rf node_modules/.cache tmp/cache log/* .git spec/ test/
Removing development files and build artifacts reduces the final image size by 20-30%.
GitHub Actions CI/CD Pipeline
The complete CI/CD pipeline handles testing, building, and deployment:
# .github/workflows/deploy.yml
name: Rails CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
HEROKU_APP_NAME: choir-site-production
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build test image
run: |
docker build -t choir-site:test -f Dockerfile.dev .
- name: Run tests
run: |
docker run --rm \
--network host \
-e DATABASE_URL=postgresql://postgres:password@localhost:5432/test_db \
-e RAILS_ENV=test \
choir-site:test \
bash -c "bundle exec rails db:create db:migrate && bundle exec rails test"
build-and-deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build and push base image
env:
DOCKER_USERNAME: $
DOCKER_PASSWORD: $
run: |
# Build base image if Dockerfile.base changed
if git diff --name-only HEAD~1 | grep -q "Dockerfile.base"; then
docker build -t whittakertech/choir-base:latest -f Dockerfile.base .
echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin
docker push whittakertech/choir-base:latest
fi
- name: Deploy to Heroku
env:
HEROKU_API_KEY: $
run: |
# Install Heroku CLI
curl https://cli-assets.heroku.com/install.sh | sh
# Login to Heroku Container Registry
heroku container:login
# Build and push container
heroku container:push web -a $HEROKU_APP_NAME
# Release container
heroku container:release web -a $HEROKU_APP_NAME
notify:
needs: build-and-deploy
runs-on: ubuntu-latest
if: always()
steps:
- name: Deployment notification
run: |
if [ "$" == "success" ]; then
echo "✅ Deployment successful"
else
echo "❌ Deployment failed"
fi
Pipeline Features
Conditional Base Image Builds:
if git diff --name-only HEAD~1 | grep -q "Dockerfile.base"; then
docker build -t whittakertech/choir-base:latest -f Dockerfile.base .
docker push whittakertech/choir-base:latest
fi
The base image is only rebuilt when Dockerfile.base
changes, saving build time.
Database Integration Testing:
services:
postgres:
image: postgres:13
# ... health checks and configuration
Tests run against a real PostgreSQL instance, catching database-specific issues.
Heroku CLI Integration:
heroku container:login
heroku container:push web -a $HEROKU_APP_NAME
heroku container:release web -a $HEROKU_APP_NAME
Direct integration with Heroku’s container registry for seamless deployments.
Environment Variable Management
Container deployments require careful environment variable handling:
Heroku Configuration
# Essential production environment variables
heroku config:set RAILS_ENV=production \
NODE_ENV=production \
RAILS_LOG_TO_STDOUT=true \
RAILS_SERVE_STATIC_FILES=true \
-a choir-site-production
# Application secrets
heroku config:set RAILS_MASTER_KEY=$(cat config/master.key) \
SECRET_KEY_BASE=$(rails secret) \
-a choir-site-production
Runtime Environment Setup
# config/application.rb
config.force_ssl = true if Rails.env.production?
config.log_level = :info
config.logger = Logger.new(STDOUT) if ENV['RAILS_LOG_TO_STDOUT'].present?
# config/environments/production.rb
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
config.assets.compile = false
config.assets.digest = true
Container-Specific Configuration:
RAILS_LOG_TO_STDOUT
: Enables container-friendly logging
RAILS_SERVE_STATIC_FILES
: Allows serving assets from the container
RAILS_MASTER_KEY
: Encrypts credentials for container deployment
Database Migration Strategy
Container deployments require a different approach to database migrations:
Release Phase Migrations
# heroku.yml
run:
release: bundle exec rake db:migrate
The release
process runs migrations before the new container starts, ensuring database schema updates complete before
code deployment.
Migration Safety
# Strong migrations configuration
StrongMigrations.auto_analyze = true
StrongMigrations.lock_timeout = 10.seconds
StrongMigrations.statement_timeout = 1.hour
# Safe migration patterns
class AddIndexToUsersEmail < ActiveRecord::Migration[7.1]
disable_ddl_transaction! # Required for concurrent indexes
def change
add_index :users, :email, algorithm: :concurrently
end
end
Rollback Strategy
# Manual rollback if needed
heroku releases -a choir-site-production
heroku rollback v123 -a choir-site-production
# Database rollback (if necessary)
heroku run rails db:rollback -a choir-site-production
The deployment included comprehensive monitoring to validate our performance improvements:
Memory Usage Monitoring
# Heroku metrics
heroku logs --tail -a choir-site-production | grep "sample#memory_total"
# Example output showing memory reduction:
# 2025-06-02T15:30:12.345678+00:00 heroku[web.1]: sample#memory_total=201.2MB
# Previous buildpack deployment: sample#memory_total=512.8MB
# config/environments/production.rb
# Enable detailed performance metrics
config.rails_semantic_logger.add_file_appender = false
config.rails_semantic_logger.add_console_appender = true
# Custom metrics for memory tracking
Rails.application.config.after_initialize do
ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, started, finished, unique_id, data|
if data[:status] == 200
memory_usage = `ps -o pid,vsz,rss,pcpu,comm -p #{Process.pid}`.split("\n")[1]
Rails.logger.info "Memory usage: #{memory_usage}"
end
end
end
# GitHub Actions build time monitoring
- name: Build performance tracking
run: |
echo "Build started: $(date)"
start_time=$(date +%s)
heroku container:push web -a $HEROKU_APP_NAME
end_time=$(date +%s)
duration=$((end_time - start_time))
echo "Build completed in ${duration} seconds"
The Final Results
After completing the full deployment pipeline, we achieved our performance goals and more:
Memory Usage Results
Before (Heroku Buildpack):
- Baseline memory: 512MB+ (exceeding dyno limits)
- Peak memory: 650MB+ (frequent R14 errors)
- Memory efficiency: Poor (40% of usage was buildpack overhead)
After (Docker Container):
- Baseline memory: 200MB (within 512MB dyno comfortably)
- Peak memory: 320MB (significant headroom)
- Memory efficiency: Excellent (90% application code, 10% container overhead)
Result: 60% memory reduction as promised
Build Times:
- Development builds: 2-3 minutes (with layer caching)
- Production builds: 4-5 minutes (including asset compilation)
- Base image updates: 6-8 minutes (only when dependencies change)
Deployment Reliability:
- Buildpack deployment success rate: 85% (package conflicts, timeouts)
- Container deployment success rate: 100% (reproducible builds)
- Average deployment time: 3-4 minutes (vs 8-12 minutes with buildpacks)
Response Times:
- Average response time: 180ms (vs 280ms with buildpacks)
- 95th percentile: 450ms (vs 800ms with buildpacks)
- Asset serving: 45ms (vs 120ms with buildpacks)
Startup Performance:
- Container startup: 8-12 seconds (vs 25-35 seconds with buildpacks)
- Asset loading: Precompiled (vs runtime compilation)
- Database connections: Faster (optimized connection pooling)
Production Operations Guide
Deployment Commands
# Standard deployment (via CI/CD)
git push origin main # Triggers automatic deployment
# Manual deployment (emergency use)
heroku container:login
heroku container:push web -a choir-site-production
heroku container:release web -a choir-site-production
# Database operations
heroku run rails db:migrate -a choir-site-production
heroku run rails console -a choir-site-production
# Monitoring
heroku logs --tail -a choir-site-production
heroku ps -a choir-site-production
Troubleshooting Common Issues
Container Build Failures:
# Local testing
docker build -t test-build .
docker run --rm test-build bundle exec rails --version
# Check Heroku build logs
heroku logs --source build -a choir-site-production
Memory Issues:
# Monitor memory usage
heroku logs --tail -a choir-site-production | grep memory
# Scale if needed
heroku ps:scale web=1:standard-1x -a choir-site-production
Asset Problems:
# Verify asset compilation
heroku run ls -la public/assets -a choir-site-production
heroku run rails assets:clobber assets:precompile -a choir-site-production
Best Practices and Lessons Learned
1. Base Image Strategy
- Publish base images to Docker Hub for reliable CI/CD
- Version base images to enable rollbacks
- Minimize base image rebuilds to improve build times
2. Environment Parity
- Use identical container images across staging and production
- Test migrations in staging before production deployment
- Monitor performance metrics in all environments
3. Security Considerations
# Use specific versions, not latest
FROM ruby:3.2.7-slim # Not ruby:latest
# Remove unnecessary packages
RUN apt-get autoremove -y && apt-get clean
4. Monitoring and Alerting
- Set up memory usage alerts below dyno limits
- Monitor build success rates and duration
- Track application performance metrics continuously
5. Rollback Preparedness
# Always test rollback procedures
heroku releases -a choir-site-production
heroku rollback v$(( $(heroku releases -a choir-site-production | head -n2 | tail -n1 | cut -d'v' -f2) - 1 )) -a choir-site-production
What This Means for Your Rails Application
The Docker migration delivered measurable improvements across every metric we cared about:
- 60% memory reduction - enabling growth without infrastructure costs
- 100% deployment reliability - eliminating deployment-related outages
- 50% faster builds - improving developer productivity
- 35% better response times - enhancing user experience
For teams considering similar migrations:
- Start with the business case - performance problems create clear ROI
- Invest in proper architecture - multi-stage builds and base images pay dividends
- Solve dependency conflicts systematically - don’t fight the package manager
- Implement comprehensive monitoring - measure everything to validate improvements
- Plan for operations - deployment and monitoring are as important as development
The containerized Rails application now runs efficiently, deploys reliably, and provides a foundation for future
scaling. The migration effort was substantial, but the operational improvements and cost savings justify the investment.
This concludes our Rails Docker Migration series. The complete deployment configuration, monitoring setup, and
operational procedures are available in our GitHub repository. For
questions about implementing similar migrations, get in touch - we’d be happy to help other teams achieve
similar results.