Introduction
Ship small nonroot images, scan for CVEs, and let Kamal gate traffic. Rails 8.1 gives us that path by default.
- Pin Ruby and keep it synced with
.ruby-version - Use multistage builds
- Precompile gems, app code, and assets during build
- Run production containers as a nonroot user
- Fail CI on high and critical CVEs
While our previous guide covered Rails 8 adds Kamal by default, this post focuses on containerization best practices, security hardening, and production optimization techniques.
The examples below use Rails 8.1, Ruby 3.4.9, and Kamal 2.11.0.
Understanding Rails Docker Setup
When we create a new Rails 8.1 application, it generates several Docker related files:
Dockerfile- Production ready container image.dockerignore- Files to exclude from buildsbin/docker-entrypoint- Container startup script
These files follow industry best practices and provide a solid foundation for production deployments.
The Rails Dockerfile Explained
Rails generates an optimized multistage Dockerfile.
Let’s understand its key components:
# syntax=docker/dockerfile:1
# check=error=true
ARG RUBY_VERSION=3.4.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
curl libjemalloc2 libvips libpq-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Build stage
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential curl git pkg-config libyaml-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache \
"${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
COPY . .
RUN bundle exec bootsnap precompile app/ lib/
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage
FROM base
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]Rules - enforce these or accept risk:
- Lock Ruby:
ARG RUBY_VERSIONmust match.ruby-version. No:latest. - Keep build dependencies out of runtime. If a compiler lands in the final image, fix it.
- Precompile gems, code, and assets during build. Runtime compilation means debugging later.
- Install runtime packages only when required. Smaller image, smaller attack surface.
Why Multistage Builds Matter
Multistage builds separate the build environment from runtime:
- Build stage: Includes compilers and build tools
- Final stage: Contains only runtime dependencies
This approach reduces the final image size by 50-70% and improves security by excluding unnecessary tools.
Deployment Best Practices
Quick Deployment with Kamal
Deploy your containerized app with:
kamal deployThis command:
- Builds the Docker image
- Pushes to your registry
- Deploys with zero downtime
- Runs health checks automatically
For Kamal configuration details, see our Kamal introduction guide.
Security Best Practices
Run as Nonroot User
Rails’ Dockerfile runs as a nonroot user by default:
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000Rule: never change the production Dockerfile to run as root. If we need root for debugging, use a one-off command with an explicit user instead.
Use Specific Image Tags
Rule: use specific versions.
ARG RUBY_VERSION=3.4.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim # Good
FROM ruby:latest # BadKeep RUBY_VERSION aligned with the application’s .ruby-version.
Scan for Vulnerabilities
Regularly scan images:
docker scout quickview myapp:latest
docker scout cves myapp:latestIn CI, fail on high and critical vulnerabilities:
docker scout cves --exit-code --only-severity critical,high myapp:latestBlock the merge until the image is fixed or a risk exception is documented.
Environment Variables
Secrets live in Kamal env or a vault. No exceptions in the repo.
env:
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
clear:
RAILS_LOG_TO_STDOUT: trueDatabase Management
Running Migrations Safely
Always run migrations before deploying new code:
kamal app exec 'bin/rails db:migrate'For automated migrations, use deployment hooks to run them before traffic switches to new containers.
Database Connection Pooling
Configure Puma’s database connections:
# config/puma.rb
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
endThis ensures each Puma worker has its own database connection.
Health Checks
Configure health checks for zero downtime deployments:
# config/deploy.yml
healthcheck:
path: /up
interval: 10s
timeout: 5s
max_attempts: 7Rails 7.1+ includes a /up endpoint by default.
For custom health checks:
# app/controllers/health_controller.rb
class HealthController < ApplicationController
def show
# Check database connectivity
ActiveRecord::Base.connection.execute("SELECT 1")
# Check Redis connectivity
Redis.new.ping
render json: { status: 'ok' }, status: :ok
rescue => e
render json: { status: 'error', message: e.message }, status: :service_unavailable
end
endProduction Optimization
Puma Configuration
Optimize Puma for containerized environments:
# 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
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
endSet via environment variables:
env:
clear:
WEB_CONCURRENCY: 2
RAILS_MAX_THREADS: 5Memory and CPU Limits
Set resource limits to prevent container overconsumption:
servers:
web:
hosts:
- 192.168.1.10
options:
memory: 512m
cpus: 1.0Monitor with:
kamal app exec 'ps aux --sort=-%mem | head -10'Asset Optimization
Ensure assets are precompiled during build:
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompileFor CDN delivery, configure Active Storage:
# config/environments/production.rb
config.active_storage.service = :amazon
config.asset_host = 'https://cdn.example.com'Scaling Strategies
Horizontal Scaling
Add more servers to handle increased traffic:
servers:
web:
- 192.168.1.10
- 192.168.1.11
- 192.168.1.12Traffic is automatically load balanced across all instances.
Vertical Scaling
Increase container resources:
servers:
web:
options:
memory: 1g
cpus: 2.0Background Jobs
Separate web and job processing:
servers:
web:
hosts:
- 192.168.1.10
job:
hosts:
- 192.168.1.20
cmd: bundle exec sidekiq -C config/sidekiq.ymlThis isolates resource intensive background jobs from web request handling.
Monitoring and Debugging
Kamal Commands
# View logs
kamal app logs --follow
# Check status
kamal app details
# Run Rails console
kamal app exec --interactive 'bin/rails console'
# Check resource usage
kamal app exec 'top -bn1'Application Monitoring
Integrate APM tools via environment variables:
env:
secret:
- NEW_RELIC_LICENSE_KEY
- SENTRY_DSNCI/CD Integration
GitHub Actions
name: Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
bundler-cache: true
- name: Deploy
run: |
gem install kamal -v 2.11.0
echo "$" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
kamal deployCommon Pitfalls
Large Image Sizes
Use the generated .dockerignore:
.git
log/*
tmp/*
node_modules
coverageHardcoded Secrets
Always use environment variables, never commit secrets to the repository.
Skipping Health Checks
Always configure health checks for zero downtime deployments.
Not Testing Locally
Test before deploying:
docker build -t myapp:test .
docker run -p 3000:80 myapp:testPre-merge Checklist
These should pass before merging:
RUBY_VERSIONmatches.ruby-version- Final image does not include build tools
- Container runs as uid/gid
1000 - Assets are precompiled during build
- Health check passes locally
- CI blocks high and critical CVEs, except documented risk exceptions
- Migrations run through a deploy hook or release task, never at container boot
Conclusion
Rails’ built in Docker support provides a production ready foundation for containerized applications.
By following these best practices, we can build secure, performant, and scalable Rails applications that deploy confidently to any infrastructure.
Key takeaways:
- Use Rails’ optimized Dockerfile
- Implement proper security measures (nonroot user, specific tags)
- Scan images with Docker Scout
- Configure health checks for zero downtime
- Optimize Puma and resource limits for production
- Scale horizontally by adding servers
- Separate concerns with role based deployments
For deployment workflows, see our Kamal introduction guide.
For more information, visit the Kamal documentation.
