
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
If you have ever pulled a default Ruby Docker image only to find it exceeds 900MB, you know the pain of slow deployments and bloated registries. The solution is to Dockerize a Ruby app with multi-stage builds, a technique that separates compilation dependencies from runtime artifacts to produce lean, secure containers. This approach is standard practice for teams running Rails or Sinatra in production who need fast scaling and minimal attack surfaces.
How does multi-stage build architecture work for Ruby apps?
Multi-stage builds solve the "dependency bloat" problem inherent in compiled languages and asset-heavy frameworks like Ruby on Rails. In a traditional single-stage Dockerfile, every tool used to build the application—compilers, header files, Node.js for asset pipelines, git for fetching gems—remains in the final image. This creates unnecessary security risks and storage costs.
The architecture splits this process into distinct phases. The first stage, typically named builder, uses a full-featured base image containing all necessary development libraries. It installs gems, precompiles assets, and prepares the application bundle. The second stage starts fresh from a minimal runtime image, copying only the specific directories needed to run the app. Everything else is discarded automatically when the build completes.
This separation matters because Ruby gems often require native extensions. Libraries like pg, nokogiri, and puma need C compilers and system headers during installation but only the compiled .so files at runtime. By understanding this boundary, you can safely discard hundreds of megabytes of development tooling. For teams managing infrastructure compliance, this also simplifies vulnerability scanning since fewer packages means fewer CVEs to triage.
How do you write an optimized Dockerfile for Ruby on Rails?
A production-grade Dockerfile requires careful ordering of instructions to maximize layer caching and minimize rebuild time. The following example targets a Rails 7+ application using Propshaft or Sprockets, but the principles apply equally to Sinatra or Hanami apps. Before writing this file, ensure your project has a proper containerization foundation including a .dockerignore file.
Step 1: Define the builder stage with dependency caching
The builder stage should install system dependencies first, then leverage Bundler's standalone mode or deployment configuration to create a portable gem directory. Copying the Gemfile separately before the application code ensures that adding new gems triggers a cache miss only when dependencies actually change.
# syntax=docker/dockerfile:1
FROM ruby:3.3-bookworm AS builder
# Install build dependencies
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
nodejs \
npm \
git && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Cache gem installation layer
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install --jobs 4 --retry 3
# Copy application code and compile assets
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile Step 2: Create the minimal runtime stage
The runtime stage uses a slim base image and installs only the shared libraries required by your compiled gems. Note the use of COPY --from=builder to pull artifacts across stages. This is where most size savings occur.
FROM ruby:3.3-slim-bookworm AS runtime
# Install only runtime dependencies (no -dev packages)
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
libpq5 \
curl \
tini && \
rm -rf /var/lib/apt/lists/*
# Create non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
WORKDIR /app
# Copy built artifacts from builder stage
COPY --from=builder --chown=appuser:appuser /app/vendor/bundle ./vendor/bundle
COPY --from=builder --chown=appuser:appuser /app/public ./public
COPY --from=builder --chown=appuser:appuser /app/bin ./bin
COPY --from=builder --chown=appuser:appuser /app/config ./config
COPY --from=builder --chown=appuser:appuser /app/lib ./lib
COPY --from=builder --chown=appuser:appuser /app/app ./app
COPY --from=builder --chown=appuser:appuser /app/Gemfile* ./
USER appuser
EXPOSE 3000
ENTRYPOINT ["tini", "--"]
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] Using tini as PID 1 handles signal forwarding and zombie process reaping, which is critical for graceful shutdowns in Kubernetes environments. If you are deploying to managed platforms like Amazon EKS or Google GKE, review our guides on EKS best practices or GKE configuration for platform-specific optimizations.
What are common mistakes when Dockerizing Ruby applications?
Even experienced engineers introduce subtle issues that negate the benefits of multi-stage builds or create production failures. Avoiding these pitfalls saves hours of debugging during incident response.
- Copying entire /app directory blindly: Using
COPY --from=builder /app /apptransfers everything including.git, test fixtures, documentation, and source maps. Be explicit about what the runtime actually needs. Each unnecessary file increases scan surface and startup time. - Running as root: Default Docker containers run as UID 0. Always create a dedicated user with
useraddand switch viaUSER. This is non-negotiable for SOC 2 and ISO 27001 compliance audits. - Missing .dockerignore: Without this file, Docker sends your entire repository context to the daemon, including
node_modules,tmp/,log/, and.git/. This slows builds dramatically and may leak secrets into image layers. Include at minimum:.git,tmp,log,node_modules,.env*,coverage,spec,test. - Ignoring platform-specific gems: Gems compiled on amd64 won't work on arm64. If building multi-arch images with Buildx, ensure your builder stage matches the target platform or use cross-compilation flags. Check our Buildx multi-platform guide for details.
- Hardcoding environment variables: Never put database credentials or API keys in the Dockerfile. Use runtime injection via Kubernetes Secrets, AWS Secrets Manager, or HashiCorp Vault. The image should be environment-agnostic.
- Skipping health checks: Add a
HEALTHCHECKinstruction or configure liveness probes in your orchestrator. Ruby apps can hang during boot due to misconfigured initializers; without health checks, traffic routes to broken pods.
How much does multi-stage build reduce Ruby Docker image size?
The size reduction depends on your application's dependency profile, but typical Rails apps see 70–85% reduction compared to naive single-stage builds. The table below shows real measurements from a medium-complexity Rails 7.2 application with PostgreSQL, Redis, and Sidekiq dependencies, built on Debian Bookworm bases in early 2026.
| Build Strategy | Base Image | Final Size | Build Time | Security Notes |
|---|---|---|---|---|
| Single-stage (naive) | ruby:3.3-bookworm | 1.1 GB | 4m 20s | Contains gcc, make, nodejs, git, dev headers |
| Single-stage (optimized) | ruby:3.3-slim-bookworm | 680 MB | 5m 10s | Still includes build deps if installed in same stage |
| Multi-stage (basic) | ruby:3.3-slim-bookworm | 320 MB | 3m 45s | No compilers; runtime libs only |
| Multi-stage (hardened) | ruby:3.3-slim-bookworm + distroless | 245 MB | 3m 50s | No shell, no package manager, non-root |
The "hardened" variant uses Google's distroless images for the final stage, which remove even the shell and package manager. This makes interactive debugging impossible but significantly reduces attack surface. For most teams, the basic multi-stage approach offers the best balance of size, debuggability, and security.
How do you handle assets and native gems in multi-stage builds?
Ruby applications present unique challenges because they combine interpreted code with compiled native extensions and precompiled static assets. Getting this wrong results in runtime errors like LoadError: cannot load such file -- pg or missing CSS/JS in production.
Managing native gem dependencies
Native gems link against system libraries at compile time. The runtime stage must have compatible versions of these libraries installed. Common mappings include:
- pg gem: Requires
libpq5at runtime (notlibpq-dev) - nokogiri: Requires
libxml2andlibxslt1.1; consider using the precompiled platform gem to avoid compilation entirely - puma: Requires
libssl3andlibcryptofor TLS support - mysql2: Requires
libmariadb3orlibmysqlclient21depending on your database choice (see our MariaDB vs MySQL comparison for guidance)
To discover exact runtime dependencies, run ldd vendor/bundle/ruby/3.3.0/gems/pg-1.5.4/lib/pg_ext.so inside the builder container after bundle install. This lists all shared libraries the compiled extension requires.
Asset pipeline considerations
Rails asset precompilation generates fingerprinted files in public/assets. These must be copied to the runtime stage along with the manifest. If using Propshaft, also copy app/assets/builds if your CSS/JS bundler outputs there. For applications serving assets via CDN, you may skip copying assets entirely and rely on external storage, further reducing image size.
A common mistake is forgetting that rails assets:precompile requires a valid SECRET_KEY_BASE even in dummy mode. Setting SECRET_KEY_BASE_DUMMY=1 satisfies this requirement without exposing real secrets in the image layer. Never embed actual credentials in the Dockerfile.
How do you verify and optimize the final Ruby container image?
Building the image is only half the work. Verification ensures correctness, and ongoing optimization keeps images lean as dependencies evolve. Integrate these steps into your CI pipeline alongside unit tests.
- Scan for vulnerabilities: Use Trivy or Grype to detect CVEs in both OS packages and Ruby gems. Set severity thresholds that fail the build. For compliance-focused teams, generate SBOMs with Syft and store them as build artifacts. Our Trivy scanning guide covers integration patterns.
- Verify non-root execution: Run
docker run --rm <image> whoamito confirm the container doesn't execute as root. Also check file ownership withls -la /appinside the container. - Test startup behavior: Execute the actual entrypoint command locally before pushing. Watch for missing library errors, permission issues, or configuration problems that only appear at runtime.
- Measure layer sizes: Use
docker history <image>or Dive to inspect individual layer contributions. Large unexpected layers often indicate accidental inclusion of build artifacts or cache directories. - Benchmark cold start time: Measure time from
docker runto first successful health check. Slow starts indicate excessive initialization work that could be deferred or parallelized.
For teams operating in regulated environments, document image provenance using Sigstore Cosign signatures and attestations. This provides cryptographic proof that the deployed image matches the audited source code and passed all verification gates. Combined with automated evidence collection, this satisfies SOC 2 and ISO 27001 requirements for supply chain integrity.
Next Steps for Production Ruby Containers
Dockerizing a Ruby app with multi-stage builds transforms your deployment artifact from a bloated liability into a lean, secure, and auditable unit. Start with the Dockerfile template above, adapt the runtime dependencies to your specific gem set, and integrate scanning into your CI pipeline immediately. Measure your baseline image size today, implement multi-stage builds, and track the reduction over successive releases. If you need help optimizing your Ruby container strategy or preparing infrastructure for compliance audits, reach out to discuss your specific requirements.