Dockerize a Ruby App with Multi-Stage Builds

Khimananda Oli 10 min read Programming and Languages
Dockerize a Ruby App with Multi-Stage Builds

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.

STAGE 1: Builderruby:3.3-fullBuild Toolsgcc, make, nodejsbundle install + assets:precompileCompiles native extensionsArtifacts: /app/vendor, /app/publicCompiled gems & static assetsSTAGE 2: Runtimeruby:3.3-slimRuntime Libs Onlylibpq5, libssl3COPY --from=builder /appOnly compiled artifacts transferredFinal Image: ~250MBNo compilers, no source maps
Multi-stage build flow: heavy build tools stay in Stage 1, only compiled artifacts transfer to the slim runtime image

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 /app transfers 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 useradd and switch via USER. 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 HEALTHCHECK instruction 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 StrategyBase ImageFinal SizeBuild TimeSecurity Notes
Single-stage (naive)ruby:3.3-bookworm1.1 GB4m 20sContains gcc, make, nodejs, git, dev headers
Single-stage (optimized)ruby:3.3-slim-bookworm680 MB5m 10sStill includes build deps if installed in same stage
Multi-stage (basic)ruby:3.3-slim-bookworm320 MB3m 45sNo compilers; runtime libs only
Multi-stage (hardened)ruby:3.3-slim-bookworm + distroless245 MB3m 50sNo 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.

Image Size Comparison (MB)025050075010001100 MBSingle (Naive)680 MBSingle (Slim)320 MBMulti-stage245 MBHardened78% Reduction
Size comparison demonstrates 78% reduction from naive single-stage to hardened multi-stage Ruby Docker builds

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 libpq5 at runtime (not libpq-dev)
  • nokogiri: Requires libxml2 and libxslt1.1; consider using the precompiled platform gem to avoid compilation entirely
  • puma: Requires libssl3 and libcrypto for TLS support
  • mysql2: Requires libmariadb3 or libmysqlclient21 depending 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.

  1. 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.
  2. Verify non-root execution: Run docker run --rm <image> whoami to confirm the container doesn't execute as root. Also check file ownership with ls -la /app inside the container.
  3. 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.
  4. 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.
  5. Benchmark cold start time: Measure time from docker run to first successful health check. Slow starts indicate excessive initialization work that could be deferred or parallelized.
Build CompleteMulti-stage output~250MB imageVulnerability ScanTrivy / GrypeCVE check + SBOMFail on HIGH/CRITICALRuntime TestStart containerHealth check passVerify non-root userPushRegistryTag + SignLayer Analysis (Dive)Inspect each layer for bloatIdentify unnecessary filesFeed back to Dockerfile optimizationContinuous Feedback LoopOptimization insights inform next build iteration
Post-build verification workflow ensures Ruby multi-stage images are secure, functional, and optimized before registry push

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.

Frequently Asked Questions

It separates build dependencies from the runtime image, keeping production containers small and secure by discarding compilers and headers after installation.

Single-stage images retain build tools like gcc and make, inflating size and attack surface. Multi-stage builds isolate these, producing leaner production artifacts with only runtime gems.

Define a builder stage installing dev packages and bundling gems, then copy only the app code and vendor bundle into a final slim runtime stage based on ruby-slim.

Use ruby:3.3-slim-bookworm for the final stage to minimize CVE exposure and size. Reserve full debian or alpine variants strictly for the builder stage where compilation tools are required.

Install build-essential and libpq-dev in the builder stage only. Copy the compiled vendor/bundle directory to the final stage, ensuring shared libraries exist in the runtime image.

Yes. Copy Gemfile and Gemfile.lock first, run bundle install, then copy application code. This layer caching avoids reinstalling gems when only source files change.

Runtime libraries like libpq5 were omitted from the final stage. Install required runtime dependencies explicitly in the final Dockerfile stage, separate from builder dev packages.

Use .dockerignore to exclude tests and docs, clean apt caches after installs, and remove unnecessary gems via bundle config set without default groups in the builder.

Generally no. Alpine uses musl libc causing compatibility issues with native gems like nokogiri. Debian slim offers better compatibility and faster builds despite slightly larger base size.

Use ARG for non-sensitive values like RUBY_VERSION. For secrets, use Docker BuildKit mount=type=secret to avoid embedding credentials in any image layer or cache.

Compile in the builder stage using Node.js and yarn, then copy public/assets to the final stage. This keeps the runtime image free of JavaScript toolchains entirely.

Run docker build with --progress=plain to see full output. Target the builder stage specifically using --target builder to inspect intermediate state without building the final image.

Initial builds take longer due to two stages, but subsequent builds benefit from layer caching. Overall deployment time decreases because smaller images transfer and start faster.

Run docker run --rm which gcc or dpkg -l | grep build-essential. Both commands should return nothing, confirming development packages were excluded from production.

Forgetting runtime libraries, copying entire vendor directories with wrong permissions, or mismatching Ruby versions between stages cause most failures. Always validate the final image runs bundle exec rails runner successfully.