New to containers? Start with Container Image Fundamentals to understand the basics before diving into this guide.
Building Ruby Applications with CleanStart
CleanStart provides hardened Ruby base images (cleanstart/ruby) with all the tools you need for production deployments. The images include Ruby versions 3.3, 3.2, and 3.1 with security patches. Bundler and gem package management are pre-installed. Multi-stage build support is fully configured. Rails framework support is included out of the box. FIPS 140-3 compliance is available for regulated environments. The images are optimized for web services and background workers.
The CleanStart two-factory architecture ensures that all Ruby gems are verified through the Package Factory before image assembly in the Image Vault, providing supply chain security for your Ruby applications and their dependencies.
Quick Start: A Rails Application
Step 1: Create Your Project
mkdir my-rails-appcd my-rails-appCreate Gemfile:
source 'https://rubygems.org' gem 'rails', '7.1.0'gem 'puma', '6.4.0'gem 'pg', '1.5.0'gem 'json', '2.6.3' group :development, :test do gem 'rspec-rails', '6.1.0' gem 'factory_bot_rails', '6.4.0'endCreate app.rb (simple Rack/Rails app):
require 'rack' class App def call(env) path = env['PATH_INFO'] case path when '/' [200, { 'Content-Type' => 'application/json' }, [ { message: 'Hello from CleanStart', version: '1.0' }.to_json ]] when '/health' [200, { 'Content-Type' => 'application/json' }, [ { status: 'healthy', version: '1.0' }.to_json ]] else [404, { 'Content-Type' => 'application/json' }, [ { error: 'Not Found' }.to_json ]] end endend run App.newOr create Gemfile for a full Rails app:
gem install railsrails new my-rails-appcd my-rails-appStep 2: Create a CleanStart YAML Declaration
Create cleanstart.yaml to declare your application for the two-factory build process:
apiVersion: cleanstart/v1kind: ImageDeclarationmetadata: name: my-rails-app version: "1.0"spec: runtime: base: cleanstart/ruby:3.3 sdk: ruby:3.3 dependencies: source: "./Gemfile" lockfile: "./Gemfile.lock" resolveWith: "bundler" build: stages: - name: "builder" commands: - "bundle install --without development test" - "bundle exec rake assets:precompile" - name: "runtime" artifacts: - source: "./app" destination: "/app" security: packageFactory: verify: true signingPolicy: "strict" container: user: 1000 readOnlyRootFilesystem: trueThis declaration ensures all gems pass through the Package Factory, where each dependency is scanned and verified before assembly in the Image Vault.
Step 3: Create Production Dockerfile
# Build stageFROM cleanstart/ruby:3.3 AS builder WORKDIR /appCOPY Gemfile Gemfile.lock ./ RUN bundle install --without development test --jobs 4 --retry 3 # Asset precompilation for RailsCOPY . .RUN bundle exec rake assets:precompile # Production stageFROM cleanstart/ruby:3.3 WORKDIR /app # Create non-root userRUN useradd -m -u 1000 appuser # Copy gems from builderCOPY --from=builder /usr/local/bundle /usr/local/bundleCOPY --from=builder /app . # Set gem pathENV BUNDLE_PATH=/usr/local/bundleENV GEM_HOME=/usr/local/bundle RUN chown -R appuser:appuser /app USER appuserEXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 ENV RAILS_ENV=productionCMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:3000"]Step 4: Build and Run
# Build the Docker imagedocker build -t my-rails-app:latest . # Run locallydocker run --rm -p 3000:3000 my-rails-app:latest # Test the applicationcurl http://localhost:3000/curl http://localhost:3000/healthMulti-Stage Builds with Gem Layer Optimization
Optimize Docker layer caching by separating gem installation from application code:
# Dependencies stageFROM cleanstart/ruby:3.3 AS dependencies WORKDIR /tmpCOPY Gemfile Gemfile.lock ./ RUN bundle install --without development test --jobs 4 --retry 3 --path vendor/bundle # Builder stageFROM cleanstart/ruby:3.3 AS builder WORKDIR /appCOPY --from=dependencies /tmp/vendor ./vendorCOPY . . # Precompile assets (Rails)RUN bundle exec rake assets:precompile || true # Runtime stageFROM cleanstart/ruby:3.3 WORKDIR /appRUN useradd -m -u 1000 appuser # Copy gems from dependencies stageCOPY --from=dependencies /tmp/vendor ./vendor # Copy application from builder stageCOPY --from=builder --chown=appuser:appuser /app . ENV BUNDLE_PATH=/app/vendorUSER appuser EXPOSE 3000HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/health || exit 1 ENV RAILS_ENV=productionCMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:3000", "-w", "4"]Bundler Integration and Dependency Resolution
CleanStart verifies all gems through the Package Factory. Configure your Gemfile for production deployments:
source 'https://rubygems.org'ruby '3.3.0' # Web servergem 'puma', '6.4.0'gem 'rails', '7.1.0' # Databasegem 'pg', '1.5.0'gem 'activerecord', '~> 7.1' # Logginggem 'json', '2.6.3' # Securitygem 'bcrypt', '3.1.20' # Cachinggem 'redis', '5.0.0' group :development do gem 'pry-rails', '0.3.9'end group :test do gem 'rspec-rails', '6.1.0' gem 'factory_bot_rails', '6.4.0' gem 'faker', '3.2.1'endIn your Dockerfile, ensure Bundler creates a lockfile and verifies gem checksums:
RUN bundle install --deployment --without development test && \ bundle audit check --updateUse Gemfile.lock to pin exact versions. Audit dependencies with:
docker run --rm my-rails-app bundle auditdocker run --rm my-rails-app bundle outdatedPuma and Unicorn Runtime Configuration
Puma Configuration (Recommended)
Create config/puma.rb:
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }threads min_threads_count, max_threads_count port = ENV.fetch("PORT") { 3000 }bind "tcp://0.0.0.0:#{port}" workers = ENV.fetch("WEB_CONCURRENCY") { 4 }worker_timeout = ENV.fetch("WORKER_TIMEOUT") { 60 } environment ENV.fetch("RAILS_ENV") { "development" } plugin :tmp_restart log_requests truepreload_app! on_worker_boot do ActiveRecord::Base.establish_connection if defined?(ActiveRecord)endIn your Dockerfile:
ENV RAILS_MAX_THREADS=5ENV RAILS_MIN_THREADS=2ENV WEB_CONCURRENCY=4ENV WORKER_TIMEOUT=60ENV PORT=3000 CMD ["bundle", "exec", "puma", "-c", "config/puma.rb"]Unicorn Configuration (Legacy)
If using Unicorn, create config/unicorn.rb:
worker_processes 4timeout 60 before_fork do |server, worker| if defined?(ActiveRecord::Base) ActiveRecord::Base.connection.disconnect! endend after_fork do |server, worker| if defined?(ActiveRecord::Base) ActiveRecord::Base.establish_connection endend listen 3000, backlog: 128FIPS 140-3 Compliance for Ruby OpenSSL
For regulated environments, use CleanStart's FIPS-enabled Ruby images and ensure OpenSSL compliance:
FROM cleanstart/ruby:3.3-fips WORKDIR /appCOPY Gemfile Gemfile.lock ./RUN bundle install --without development testIn your Ruby code, verify FIPS mode is enabled:
require 'openssl' puts "FIPS enabled: #{OpenSSL::FIPS.mode == 1}" # Use FIPS-approved algorithmsdigest = OpenSSL::Digest::SHA256.new # FIPS-approvedcipher = OpenSSL::Cipher.new('aes-256-gcm') # FIPS-approved # Avoid non-FIPS algorithms# md5 = OpenSSL::Digest::MD5.new # Non-FIPSTest FIPS compliance in a container:
docker run --rm cleanstart/ruby:3.3-fips \ ruby -e "require 'openssl'; puts \"FIPS: #{OpenSSL::FIPS.mode == 1}\""Scanning and Signing Images
After building your Ruby image, scan it for vulnerabilities and sign it for supply chain security:
# Build and tag your imagedocker build -t myregistry/my-rails-app:1.0 . # Scan with CleanStart scannercleanimg-scan myregistry/my-rails-app:1.0 # Audit gems with bundlerdocker run --rm myregistry/my-rails-app:1.0 bundle audit # Sign with Cosigncosign sign --key cosign.key myregistry/my-rails-app:1.0 # Push to Image Vaultdocker push myregistry/my-rails-app:1.0 # Verify signature before pullingcosign verify --key cosign.pub myregistry/my-rails-app:1.0Deploying to Kubernetes
Create a complete Kubernetes deployment with CleanStart hardening:
apiVersion: apps/v1kind: Deploymentmetadata: name: rails-app namespace: defaultspec: replicas: 3 selector: matchLabels: app: rails-app template: metadata: labels: app: rails-app spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: myregistry/my-rails-app:1.0 imagePullPolicy: IfNotPresent ports: - containerPort: 3000 name: http protocol: TCP env: - name: RAILS_ENV value: "production" - name: PORT value: "3000" - name: WEB_CONCURRENCY value: "4" - name: RAILS_LOG_TO_STDOUT value: "true" - name: DATABASE_URL valueFrom: secretKeyRef: name: app-secrets key: database-url securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 capabilities: drop: - ALL livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 2 resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1000m memory: 512Mi volumeMounts: - name: tmp mountPath: /tmp - name: rails-tmp mountPath: /app/tmp volumes: - name: tmp emptyDir: {} - name: rails-tmp emptyDir: {}---apiVersion: v1kind: Servicemetadata: name: rails-appspec: type: ClusterIP selector: app: rails-app ports: - port: 80 targetPort: 3000 protocol: TCPTesting Your Ruby Application
Add RSpec to your project for comprehensive testing:
# Gemfilegroup :test do gem 'rspec-rails', '6.1.0' gem 'factory_bot_rails', '6.4.0' gem 'rack-test', '2.1.0'endCreate spec/app_spec.rb:
require 'rack/test' describe 'My Rails App' do include Rack::Test::Methods let(:app) { MyApp.new } it 'returns hello on root' do get '/' expect(last_response).to be_ok expect(JSON.parse(last_response.body)['message']).to eq('Hello from CleanStart') end it 'returns health status' do get '/health' expect(last_response).to be_ok expect(JSON.parse(last_response.body)['status']).to eq('healthy') endendRun tests in Docker:
FROM cleanstart/ruby:3.3 AS test WORKDIR /appCOPY Gemfile Gemfile.lock ./RUN bundle install COPY . .RUN bundle exec rspecImage Options
CleanStart provides Ruby variants for different needs:
Image | Use Case |
|---|---|
| Latest version, recommended for production |
| Stable LTS, widely tested |
| Legacy support |
| FIPS 140-3 compliance required |
Performance Tips
You should layer gems separately from application code to benefit from Docker's layer caching. Use bundle install --jobs 4 to parallelize gem installation and speed up builds. Precompile Rails assets in the build stage rather than at runtime. Tune web concurrency based on CPU cores available to the container. Configure database connection pooling appropriately for your concurrency level.
Troubleshooting
Build Issues
Problem: Bundler fails with "certificate verification failed" during gem installation Cause: SSL certificate issues; corporate proxy intercepting traffic; gem server unreachable Fix: In Dockerfile, configure bundler to use HTTP or skip verification temporarily (not for production). Add to Dockerfile: RUN bundle config set --global ssl_verify_mode 0 (only in build stage). Better fix: configure proxy in Gemfile if needed: add to ~/.bundlerc or set environment variable BUNDLE_HTTPS_PROXY=http://proxy:8080. For Ruby 3.3+, use bundler cache: RUN --mount=type=cache,target=/usr/local/bundle/cache bundle install.
Problem: "Could not find gem 'xyz' in any of the gem sources" during bundle install Cause: Gem source not configured; typo in Gemfile; gem removed from rubygems.org Fix: Verify gem name in Gemfile matches exactly (case-sensitive). Check if gem exists: gem search xyz. Add explicit gem source if using private registry: add source 'https://private-gem-server.com' before gem declaration. Ensure Gemfile.lock is current: run bundle lock --update locally and commit.
Problem: Docker layer caching fails; every source code change rebuilds all gems Cause: Gemfile copied with application code; Docker detects changes and invalidates cache Fix: Restructure Dockerfile: copy only Gemfile and Gemfile.lock first, install gems, then copy application code. This preserves gem layer cache when source changes: COPY Gemfile Gemfile.lock . && RUN bundle install && COPY . ..
Problem: Build takes 20+ minutes due to slow gem compilation Cause: Native extensions compiling from source; C dependencies missing; slow network to gem server Fix: Use pre-compiled gems where available (most modern gems have pre-built binaries). Parallel installation: bundle install --jobs 4. For gems with native extensions (e.g., pg, mysql2), ensure build tools available: RUN apt-get install -y build-essential ruby-dev. Cache gem cache between builds: use BuildKit cache mount. Or pre-download gems in separate Docker layer.
Problem: Gemfile.lock conflict; "different gems specified in Gemfile" or lock file corruption Cause: Manual edits to Gemfile.lock; bundle version mismatch; concurrent bundle lock operations Fix: Regenerate Gemfile.lock locally: rm Gemfile.lock && bundle install && bundle lock. Never manually edit lock file. Commit lock file to version control. Use same bundler version in Docker as development: pin in Dockerfile: RUN gem install bundler:2.5.0 if needed.
Runtime Issues
Problem: Rails app crashes with "Couldn't find gem 'xyz'" at runtime Cause: Gem not in Gemfile; transitive dependency missing; bundle cache not mounted Fix: Verify all gems copied from build stage: check final image contains all gems. Run bundle list in container to audit installed gems. Ensure Gemfile.lock unchanged between build and commit. For multi-stage, copy entire vendor/bundle or gem path correctly: COPY --from=builder /usr/local/bundle /usr/local/bundle.
Problem: Puma workers crash with "Worker timeout" or "Process stuck" Cause: Workers set too high; long-running requests; database connection pool exhausted Fix: Tune worker count: typically (2 * CPU_cores) + 1. In container, check actual CPUs: docker run --cpus=1 my-app cat /proc/cpuinfo | grep processor | wc -l. Increase request timeout: set ENV WORKER_TIMEOUT=120. For database connections, ensure connection pool size matches worker count: pool: 4 in config/database.yml if using 4 workers.
Problem: Port binding fails with "Address already in use" Cause: Puma binding to wrong interface; previous container still holding port; port conflict Fix: Ensure Puma binds to all interfaces: bind 'tcp://0.0.0.0:3000' in puma.rb or --bind tcp://0.0.0.0:3000 on CLI. Check for orphaned containers: docker ps -a | grep rails-app. Clean up: docker container prune. Verify port exposure in Dockerfile: EXPOSE 3000.
Problem: Rails log levels not working; debug logs not appearing Cause: Rails.logger not configured for stdout; log level ENV variable ignored Fix: Ensure RAILS_LOG_TO_STDOUT=true in environment. Configure logging in config/environments/production.rb: config.logger = ActiveSupport::Logger.new(STDOUT). For custom log level, set RAILS_LOG_LEVEL=debug (default is info). Verify with: docker run -e RAILS_LOG_LEVEL=debug my-app rails console.
Problem: HEALTHCHECK fails; endpoints return 500 or timeout Cause: Database not ready; Rails not fully initialized; ActiveRecord connection issue Fix: Implement proper health checks that verify database connectivity: add health controller that checks ActiveRecord::Base.connection.active?. Increase initialDelaySeconds in Kubernetes or start-period in Docker to allow Rails to boot. For cold starts, use startup probe in Kubernetes. Verify endpoint locally: curl -v http://localhost:3000/health.
Problem: Memory usage grows unbounded; "Out of Memory" after hours Cause: Memory leak in gems or application code; object cache not limited; connection pool holding connections Fix: Monitor memory: docker stats. For Rails, check for memory leaks with derailed_benchmarks gem. Limit gem cache size: wrap caching in background jobs with memory limits. Ensure connection pooling not creating unlimited connections. Use ruby-prof or memory_profiler to identify leaks locally.
Problem: FIPS mode enabled but OpenSSL raises errors; "cipher not available" Cause: Application uses non-FIPS ciphers or algorithms; gem doesn't support FIPS Fix: Audit gems with OpenSSL: bundle list | grep openssl. Review application crypto usage: replace MD5 with SHA256, DES with AES. Disable FIPS if not required: set OpenSSL::FIPS.mode = false in initializer (not recommended for compliance). Test in FIPS container first: docker run -it cleanstart/ruby:3.3-fips ruby -e 'require "openssl"; ...'.
Performance Issues
Problem: Application startup takes 30+ seconds; slow boot time in containers Cause: Large gem dependencies loaded on startup; Rails autoloading slow; gems doing too much in initializers Fix: Profile startup: use ruby-prof --printer graph_html -- bin/rails console < /dev/null to see slow requires. Defer non-essential initialization: move into lazy-loaded concerns. For rails c or rake tasks, skip unnecessary gems: add to Gemfile gem 'foo', require: false and require only when needed. Use bootsnap gem to cache require paths: add to Gemfile and preload in config/boot.rb.
Problem: Asset precompilation takes 10+ minutes in Docker build Cause: Large asset pipeline; Sprockets not parallelized; missing Node.js for JavaScript compilation Fix: Ensure Node.js available in build stage for JavaScript: RUN apt-get install -y nodejs npm. Precompile in separate Docker layer, before copying source: separate assets into own layer for caching. Use RAILS_ENV=production RAILS_GROUPS=assets bundle exec rake assets:precompile. For production, consider asset CDN and skip precompilation: set config.assets.compile = false.
Problem: Database migration timeout during deployment; "migration stuck" Cause: Long-running migration; database lock; migration not completing within timeout Fix: Run migrations separately from application startup: use init container in Kubernetes to run bundle exec rake db:migrate before app container starts. Set migration timeout explicitly: ActiveRecord::Migrator.migrations_paths; ActiveRecord::Migration.execution_mode = :all; ActiveRecord::Schema.migration_mode_to_switch_back = nil. For slow migrations, profile: bundle exec rake db:migrate:verbose.
CleanStart Production Hardening
Read-Only Root Filesystem
Enforce immutable infrastructure with read-only root and controlled writable paths:
apiVersion: v1kind: Podmetadata: name: rails-appspec: containers: - name: app image: cleanstart/ruby:3.3 securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: rails-tmp mountPath: /app/tmp - name: rails-logs mountPath: /app/log volumes: - name: tmp emptyDir: {} - name: rails-tmp emptyDir: {} - name: rails-logs emptyDir: {}Shell-Less ENTRYPOINT
Remove shell from the container for reduced attack surface. Update your Dockerfile:
FROM cleanstart/ruby:3.3 WORKDIR /appCOPY . .RUN bundle install --without development test # Declarative Image Builder: Use cleanimg-init as PID 1ENTRYPOINT ["/cleanimg-init", "--"]CMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:3000"]cleanimg-customize: Custom Ruby Configuration
Inject production settings and security hardening:
FROM cleanstart/ruby:3.3 WORKDIR /appCOPY Gemfile Gemfile.lock ./RUN bundle install --without development test COPY . . # Configure runtime hardeningENV RAILS_ENV=productionENV RAILS_LOG_TO_STDOUT=trueENV WEB_CONCURRENCY=4ENV RAILS_MAX_THREADS=5 ENTRYPOINT ["/cleanimg-init", "--"]CMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:3000"]Security Context
Complete Kubernetes securityContext for hardened Ruby containers:
apiVersion: apps/v1kind: Deploymentmetadata: name: rails-appspec: template: spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: cleanstart/ruby:3.3 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 capabilities: drop: - ALL resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1000m memory: 512MiNext Steps
Explore Advanced Ruby Deployment. Learn Kubernetes Deployment for Ruby. Review CleanStart Image Registry.
