Deploy a Ruby Service to Kubernetes

Khimananda Oli 10 min read Programming and Languages
Deploy a Ruby Service to Kubernetes

By Khimananda Oli | Last reviewed: August 2026

You need to deploy a Ruby service to Kubernetes reliably, but default Rails guides often skip the operational details that prevent downtime and memory bloat. Ruby’s garbage collector and Puma’s threading model require specific container tuning that differs significantly from Node.js or Go workloads. This guide provides the exact configuration patterns I use in production to ensure your Ruby applications scale predictably without crashing under load.

Ingress ControllerTLS TerminationK8s ServiceClusterIP / LoadBalancerRuby Pod (Puma)Workers + ThreadsLiveness: /upPostgreSQLManaged / StatefulSetPod InternalsPuma MasterManages WorkersWorker 12 ThreadsWorker 22 ThreadsWorker N2 ThreadsMemory Limit: 512Mi | CPU Limit: 500m | WEB_CONCURRENCY: 2 | RAILS_MAX_THREADS: 2Formula: Workers × Threads ≈ CPU Cores Available
Production architecture when you deploy a Ruby service to Kubernetes: Ingress routes traffic through a Service to Puma pods with tuned concurrency

How do you containerize a Ruby application for Kubernetes?

The foundation of any successful Kubernetes deployment is a lean, secure container image. For Ruby, this means avoiding the common mistake of using the full ruby:3.3 base image which includes build tools, compilers, and documentation that increase attack surface and startup time. A multi-stage build separates dependencies from runtime artifacts.

Multi-stage Dockerfile for Rails

This Dockerfile produces an image under 250MB by compiling native gems in a builder stage and copying only the installed bundle to a slim runtime stage. It also sets up proper user permissions to avoid running as root, which is critical for passing security audits like SOC 2.

# syntax=docker/dockerfile:1
FROM ruby:3.3-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' \
    && bundle config set --local without 'development test' \
    && bundle install --jobs 4

FROM ruby:3.3-slim AS runtime

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    curl \
    tini \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser

WORKDIR /app
COPY --from=builder /app/vendor/bundle ./vendor/bundle
COPY --chown=appuser:appuser . .

ENV BUNDLE_PATH=/app/vendor/bundle \
    BUNDLE_DEPLOYMENT=true \
    RAILS_ENV=production \
    RAILS_LOG_TO_STDOUT=true \
    RAILS_SERVE_STATIC_FILES=true

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -f http://localhost:3000/up || exit 1

ENTRYPOINT ["tini", "--"]
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

Key details in this configuration deserve attention. The tini init system properly reaps zombie processes, which is essential because Ruby’s Puma master process doesn’t handle SIGCHLD correctly in PID 1 mode. The HEALTHCHECK instruction provides container-level health signaling independent of Kubernetes, useful for local debugging. Setting RAILS_LOG_TO_STDOUT ensures logs flow to the container runtime for aggregation via tools described in our structured logging best practices guide.

How should you configure Puma workers and threads for Kubernetes?

Puma’s concurrency model directly determines whether your pods stay healthy or get OOM-killed. Unlike stateless microservices written in Go, Ruby processes have significant per-worker memory overhead due to the Global Interpreter Lock (GIL) and object allocation patterns. Getting this wrong is the most common reason teams fail to deploy a Ruby service to Kubernetes stably.

Calculating Resource-Aware Concurrency

The formula for Puma in containers differs from bare-metal deployments. Each worker forks the master process, consuming roughly 80–150MB depending on your gem footprint. Threads within a worker share memory but add GC pressure. Here is a production-tested config/puma.rb:

# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 2).to_i
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }.to_i
threads min_threads_count, max_threads_count

# Workers should match CPU limit, not host cores
# For 500m CPU limit → 1-2 workers max
# For 1000m CPU limit → 2-3 workers max
workers ENV.fetch("WEB_CONCURRENCY", 2).to_i

preload_app!

port ENV.fetch("PORT", 3000)
environment ENV.fetch("RAILS_ENV", "production")

# Critical for Kubernetes: bind to all interfaces
bind "tcp://0.0.0.0:#{ENV.fetch('PORT', 3000)}"

# Graceful shutdown timeout matches K8s terminationGracePeriodSeconds
shutdown_timeout 25

# Reap zombies in case tini fails
worker_timeout 60

on_worker_boot do
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

plugin :tmp_restart

A common mistake is setting WEB_CONCURRENCY based on the node’s core count rather than the pod’s CPU limit. If your pod has a 500m CPU limit (half a core), running 4 workers causes severe throttling and latency spikes. Match workers to your CPU request, and use threads to handle I/O-bound requests within that constraint. For deeper guidance on setting appropriate boundaries, see Kubernetes resource limits and requests.

CI/CD Pipeline: Deploy Ruby Service to KubernetesGit Pushmain branchTest SuiteRSpec + BrakemanBundle AuditBuild ImageMulti-stage DockerTag: SHA + latestScan & PushTrivy + RegistryFail on HIGH/CVSS>7Deploykubectl applyRolling UpdateDeployment Manifest Checklist✓ resources.requests.memory == resources.limits.memory (prevent GC OOM)✓ livenessProbe: httpGet /up (not TCP socket check)✓ readinessProbe: initialDelaySeconds ≥ 15 (Rails boot time)✓ terminationGracePeriodSeconds: 30 (allow Puma graceful shutdown)✓ envFrom: secretRef for DATABASE_URL, RAILS_MASTER_KEY✓ strategy.type: RollingUpdate with maxUnavailable: 0
CI/CD pipeline and manifest checklist required when you deploy a Ruby service to Kubernetes in production environments

What Kubernetes manifests are needed for a production Ruby deployment?

With a solid container image and Puma configuration, you need Deployment and Service manifests tuned for Ruby’s behavior. The defaults from kubectl create deployment will cause problems: TCP health checks don’t verify Rails actually booted, missing resource limits trigger noisy neighbor issues, and abrupt SIGKILL terminates in-flight requests.

Production Deployment Manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ruby-api
  labels:
    app: ruby-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ruby-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app: ruby-api
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: ruby-api
        image: registry.example.com/ruby-api:sha-a1b2c3d
        ports:
        - containerPort: 3000
          protocol: TCP
        env:
        - name: RAILS_ENV
          value: "production"
        - name: WEB_CONCURRENCY
          value: "2"
        - name: RAILS_MAX_THREADS
          value: "2"
        envFrom:
        - secretRef:
            name: ruby-api-secrets
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "512Mi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /up
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 3
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /up
            port: 3000
          initialDelaySeconds: 15
          periodSeconds: 5
          timeoutSeconds: 2
          failureThreshold: 2
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 5"]

Several configurations here address Ruby-specific failure modes. Setting memory requests equal to limits prevents the kernel from killing your pod during GC pauses when RSS temporarily spikes. The preStop sleep gives the kube-proxy time to remove the pod from the Service endpoints before Puma receives SIGTERM, preventing connection resets. The /up endpoint should be a lightweight Rails route that verifies database connectivity, not just a static file. For managing sensitive values like RAILS_MASTER_KEY, follow Kubernetes secrets management done right.

How do you handle database migrations during Ruby Kubernetes deployments?

Running rake db:migrate inside your main container’s entrypoint is an anti-pattern that causes race conditions during rolling updates. Multiple pods starting simultaneously can corrupt schema state or deadlock on advisory locks. Migrations must run as a separate, one-time job before new pods serve traffic.

Init Container vs. Job Pattern

For simple deployments, an init container works well because it blocks pod readiness until migrations complete. For complex schemas or large datasets, use a Kubernetes Job with proper backoff policies:

apiVersion: batch/v1
kind: Job
metadata:
  name: ruby-api-migrate-v42
  annotations:
    helm.sh/hook: pre-install,pre-upgrade
    helm.sh/hook-delete-policy: before-hook-creation
spec:
  backoffLimit: 3
  activeDeadlineSeconds: 300
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: migrate
        image: registry.example.com/ruby-api:sha-a1b2c3d
        command: ["bundle", "exec", "rake", "db:migrate"]
        envFrom:
        - secretRef:
            name: ruby-api-secrets
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"

This approach decouples migration from application startup. The activeDeadlineSeconds prevents hung migrations from blocking deployments indefinitely. Always test migrations against a staging database that mirrors production schema size; a migration taking 2 seconds on an empty dev database might take 4 minutes on a 50GB production table. When troubleshooting failed pods after migration issues, the techniques in debugging CrashLoopBackOff are invaluable.

Ruby Server Comparison for Kubernetes DeploymentPumaDefault Rails ServerFalconAsync / Fiber-basedSidekiqBackground Jobs OnlyCriterionPumaFalconSidekiqConcurrency ModelProcess + ThreadFiber (async)Thread PoolMemory per UnitHigh (fork)Low (shared)MediumK8s Scaling UnitPod (workers fixed)Pod (fibers auto)Separate DeploymentBest ForCPU-bound APIsI/O-heavy / WebSocketAsync Job ProcessingK8s ComplexityStandardRequires Async RuntimeNeeds Redis + Separate PodsRecommendationStart HereAdvanced I/OAlways Pair with Puma
Server comparison to consider before you deploy a Ruby service to Kubernetes: Puma for most APIs, Falcon for I/O-heavy workloads, Sidekiq for background jobs

How do you monitor and troubleshoot Ruby pods in Kubernetes?

Ruby applications expose different signals than other languages. Memory usage isn’t linear with request count due to GC behavior, and thread pool exhaustion manifests as latency rather than errors. Your observability stack must account for these characteristics.

Essential Metrics and Probes

  • Puma stats endpoint: Expose /puma/stats (protected by auth) to track running threads, queued requests, and worker utilization. High queue depth indicates insufficient concurrency or slow queries.
  • Ruby GC metrics: Enable GC.stat export via the prometheus-client gem. Monitor heap_live_slots and gc_count; frequent major GC cycles signal memory pressure even if RSS looks stable.
  • Database connection pool: Track ActiveRecord::Base.connection_pool.stat. Connection wait times exceeding 100ms indicate pool size misconfiguration relative to RAILS_MAX_THREADS.
  • Request duration percentiles: Use OpenTelemetry instrumentation to capture p95/p99 latency. Ruby’s tail latency often correlates with GC pauses; correlate spikes with gc_major_by metrics.

When pods enter CrashLoopBackOff, check kubectl logs <pod> --previous for OOM messages or unhandled exceptions during boot. Ruby’s eager loading in production means missing constants or failed DB connections surface immediately at startup, not lazily. Ensure your /up health endpoint actually exercises the database connection; a static response hides infrastructure failures until real traffic arrives.

Deploy a Ruby Service to Kubernetes With Confidence

Successfully running Ruby in Kubernetes requires respecting the language’s runtime characteristics rather than treating it like any other containerized workload. Start with a multi-stage Docker build, tune Puma to your actual CPU limits, implement proper HTTP health checks, and decouple migrations from application startup. Monitor GC behavior and connection pools, not just HTTP status codes. These patterns have proven reliable across multiple production environments serving millions of requests. If your team needs help architecting or auditing a Ruby Kubernetes deployment, reach out to discuss your specific requirements.

Frequently Asked Questions

Use the official ruby:3.3-slim or ruby:3.4-alpine images to minimize attack surface and layer size. Avoid latest tags in production manifests; always pin specific versions like 3.3.5-bookworm to ensure reproducible builds and prevent unexpected dependency breakages during cluster rollouts.

Run migrations as a separate initContainer or Job before the main application pods start serving traffic. This prevents schema mismatch errors during rolling updates. Use kubectl wait or Helm hooks to ensure the migration completes successfully before new app replicas become ready and receive ingress traffic.

Check resource limits and liveness probe configuration first. Ruby apps often exceed default memory limits during boot, triggering OOMKill. Also verify that health check endpoints respond within the configured initialDelaySeconds, as slow framework initialization causes premature restarts before the application fully loads.

Set workers based on CPU limits using WEB_CONCURRENCY environment variable, typically one worker per vCPU plus one. Configure max threads between five and ten depending on I/O patterns. Enable preload_app to reduce memory usage via copy-on-write and speed up pod readiness times significantly.

Never bake credentials into container images. Use Kubernetes Secrets mounted as environment variables or volumes, preferably integrated with external secret operators like External Secrets Operator syncing from AWS Secrets Manager or HashiCorp Vault. Rotate credentials without redeploying by updating the external source and letting the operator reconcile changes automatically.

Only if you require mTLS, advanced observability, or traffic splitting beyond what Kubernetes provides natively. For most Ruby deployments, native Service mesh features or simple ingress controllers suffice. Adding Envoy or Istio increases latency and memory overhead that may not justify operational complexity for smaller teams.

Handle SIGTERM signals in your application code to stop accepting new connections and finish in-flight requests. Set terminationGracePeriodSeconds higher than your longest expected request duration. Configure Puma or Unicorn with appropriate timeout settings to prevent forced SIGKILL terminations that cause client-facing errors during deployments.

Yes, use ReadWriteOnce PVCs backed by cloud block storage for single-replica uploads. For multi-replica deployments, prefer object storage like S3 via Active Storage instead of shared filesystems. NFS-based ReadWriteMany volumes introduce performance bottlenecks and consistency issues unsuitable for production Ruby applications handling user-generated content.

Precompile assets during Docker build, enable Puma preload_app, and use keepalive connections to databases and caches. Consider KEDA or custom metrics-based scaling over HPA to maintain warm replicas during predictable traffic patterns. Profile boot time with benchmark-ips to identify slow initializers blocking readiness probes.

Output structured JSON logs to stdout so container runtimes and log aggregators can parse fields correctly. Use gems like semantic_logger or lograge to enrich entries with request IDs and metadata. Avoid writing to files inside containers since ephemeral storage disappears on pod restart and complicates centralized log collection pipelines.

Profile actual usage under load testing rather than guessing. Set requests near median consumption and limits at p99 plus buffer. Ruby garbage collection spikes cause transient memory increases, so insufficient limits trigger throttling or OOMKill. Monitor with Prometheus node_exporter and adjust quarterly as dependencies evolve.

Absolutely. Define Kubernetes CronJob resources pointing to the same image as your web service but with different entrypoint commands. Ensure idempotency since concurrent executions may overlap. Use concurrencyPolicy Forbid to prevent duplicate processing and configure successfulJobsHistoryLimit to avoid etcd bloat from accumulated completed job records.

HPA scales replicas based on CPU, memory, or custom metrics exposed via Prometheus adapter. Ruby apps often scale better on request queue depth or response latency than raw CPU utilization due to GC pauses skewing metrics. Test scaling behavior under realistic load patterns before relying on it in production environments.

DNS resolution failures during pod startup, connection pool exhaustion from excessive replica counts, and missing service account permissions for cloud APIs. Configure ndots and search domains correctly in resolv.conf. Size database connection pools relative to total workers across all replicas to avoid overwhelming backend services during peak traffic.

It depends on scale and team expertise. Below ten services, managed platforms like Render or Heroku reduce operational overhead significantly. Kubernetes becomes cost-effective at larger scale where bin-packing improves resource utilization and spot instances offset compute costs, but requires dedicated platform engineering investment that small teams cannot justify economically.