Containerizing a Rails application is no longer optional in modern development—it's the foundation that bridges your laptop and production. Rails in Docker: Ruby on Rails best practices explained covers what changed when Rails started generating Dockerfiles by default, and how to build lean, fast images that actually work at scale.
Rails generates a Dockerfile
Since Rails 7.1, the framework generates a production-ready Dockerfile automatically when you create a new application. This is a significant shift: instead of teams reverse-engineering container patterns, we now have opinionated defaults that reflect years of collective experience.
When you run rails new myapp, you'll find a Dockerfile at the root. Let's walk through what it does:
FROM ruby:3.3-slim
WORKDIR /rails
# Install dependencies
RUN apt-get update -qq && \
apt-get install -y build-essential git && \
rm -rf /var/lib/apt/lists/*
# Copy Gemfile and lock
COPY Gemfile Gemfile.lock ./
# Install gems
RUN bundle install
# Copy application
COPY . .
# Expose port
EXPOSE 3000
# Start server
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]The generated Dockerfile uses ruby:3.3-slim as its base image—the slim variant strips out unnecessary build tools and documentation, keeping image size manageable. It installs only what's needed, copies your application code, and runs the Rails server bound to all interfaces.
However, this simple approach has trade-offs. The entire layer chain remains in the final image, dependencies needed only at build time stay around, and every gem compilation bloats the output. That's where multi-stage builds come in.
Multi-stage builds
Multi-stage Docker builds separate the compilation phase from the runtime phase. You build your application in one container—where you need build tools, compilers, and development headers—then copy only the final artifacts into a lean runtime image. This technique cuts image size dramatically and reduces the attack surface.
Here's a production-grade multi-stage Dockerfile:
# Build stage
FROM ruby:3.3-slim AS builder
WORKDIR /app
RUN apt-get update -qq && \
apt-get install -y build-essential git postgresql-client && \
rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install --deployment --without development test
COPY . .
RUN bundle exec rake assets:precompile
# Runtime stage
FROM ruby:3.3-slim
WORKDIR /app
RUN apt-get update -qq && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Copy only what we need from builder
COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY --from=builder /app/public/assets /app/public/assets
COPY --from=builder /app/public/packs /app/public/packs
COPY . .
ENV BUNDLE_PATH=/app/vendor/bundle
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]The builder stage compiles all gems, precompiles assets, and performs any expensive operations. The runtime stage is minimal—it only pulls in precompiled gems and static assets. Build tools like git, compilers, and development headers never make it into the final image.
Real-world impact: a single-stage build might produce a 2GB image; multi-stage often cuts that to 400–600MB. Faster deployments, smaller pull times, and fewer vulnerabilities because unnecessary tools aren't present in production.
Thruster for HTTP caching and compression
Rails 8 introduced Thruster, a small HTTP proxy written in Rust that runs as the first process in your container. Thruster handles HTTP/2 support, automatic gzip compression, and HTTP cache headers—without adding Ruby overhead.
Rather than running your Rails app on port 3000 and proxying through Nginx outside the container, Thruster runs inside the same process group and talks directly to your Rails app on a Unix socket:
# Gemfile
gem "thruster"
# Procfile.docker
web: bundle exec thrust startWhen you run bundle exec thrust start, Thruster binds to port 3000 (or your configured port), listens for HTTP/2 requests, compresses responses on the fly, and forwards to your Rails app. Responses are typically 60–70% smaller with gzip, and clients see faster page loads.
In your Dockerfile, update the CMD:
CMD ["bundle", "exec", "thrust", "start"]Thruster also respects your Rails cache headers. If you set Cache-Control: max-age=3600 in a controller, Thruster caches that response in memory and serves it without hitting Rails. This is especially valuable for asset routes or public pages that don't change frequently.
Benefits include reduced Rails process load, faster response times for cached content, and one less external service to manage. You no longer need Nginx as a separate container for HTTP concerns; Thruster handles them efficiently within your existing process.
Run migrations on deploy
Database schema changes must run before your new code does, or you'll face mysterious errors. The standard pattern is to run migrations as part of your deployment process, before the new container version handles traffic.
Many teams use an init container or a separate job step. A cleaner approach is a release phase: a one-off process that runs migrations before the main application starts serving requests.
In your Procfile (used by many container orchestrators), define a release process:
release: bundle exec rails db:migrate
web: bundle exec thrust startOn platforms like Heroku, the release phase runs automatically before scaling up new dynos. On Kubernetes, you can use a Job or init container. On ECS or similar services, you might run the migration as a task before updating your service.
Here's a Kubernetes Job example:
apiVersion: batch/v1
kind: Job
metadata:
name: rails-migrate
spec:
template:
spec:
containers:
- name: rails
image: myapp:latest
command: ["bundle", "exec", "rails", "db:migrate"]
restartPolicy: Never
backoffLimit: 3Run this Job, wait for it to complete (exit code 0), then deploy the new ReplicaSet or Deployment. This ensures the schema is up to date before any new instance processes a request.
Common gotcha: if a migration locks a large table, other services hitting the database during the migration might time out. Always test migrations against production-sized data, add timeouts to your migration commands, and plan maintenances during low-traffic windows if needed.
Common mistakes
- Forgetting to copy
.dockerignore: By default, Docker copies your entire repo into the image, including.git,node_modules, and test files. Add a.dockerignorefile similar to.gitignoreto exclude unnecessary files. - Running as root: The generated Dockerfile often runs the Rails process as the root user. Best practice is to create a non-root user and run as that user to limit privilege escalation risk.
- Not setting RAILS_ENV: If you don't explicitly set
ENV RAILS_ENV=productionin your Dockerfile, Rails might run in development mode inside the container, which is slower and uses different error handling. - Layering large files without cleanup: Each
RUNcommand creates a layer. If you download a large tarball and extract it in separate commands, both the tarball and extracted files get stored as separate layers. Chain commands with&&and clean up in the same layer. - Skipping asset precompilation: Rails needs to precompile assets before boot. If you skip this step, the first request will be slow as Rails compiles assets on-the-fly, or worse, assets won't exist at all.
Key takeaways
- Rails 7.1+ generates a Dockerfile by default; use it as a starting point and refine for your production constraints.
- Multi-stage builds separate compilation from runtime, cutting image size and reducing attack surface significantly.
- Thruster handles HTTP caching and compression inside your container, eliminating the need for a separate reverse proxy layer.
- Always run database migrations as a separate phase before your application starts serving traffic.
- Test your Dockerfile locally and measure image size, startup time, and memory usage before declaring it production-ready.






Comments
Loading comments…