
Table of Contents
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.
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.
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.
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.statexport via theprometheus-clientgem. Monitorheap_live_slotsandgc_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 toRAILS_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_bymetrics.
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.