docs/oss/deployment/docker-deployment.md
This guide covers deploying a React on Rails application using Docker containers, with specific instructions for Kamal, Kubernetes, and Control Plane.
Rails 7.1+ ships with a production-ready Dockerfile. React on Rails needs Node.js available during the build stage to compile JavaScript bundles. Here is a representative multi-stage Dockerfile:
# syntax=docker/dockerfile:1
ARG RUBY_VERSION=3.3
ARG NODE_VERSION=20
###############################################################################
# Base stage — shared between build and runtime
###############################################################################
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
ENV RAILS_ENV="production" \
NODE_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development:test"
###############################################################################
# Build stage — install gems, Node, and compile assets
###############################################################################
FROM base AS build
# Install build dependencies
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential curl git libpq-dev node-gyp pkg-config python-is-python3 && \
rm -rf /var/lib/apt/lists/*
# Install Node.js and Yarn (or use corepack for pnpm/yarn)
ARG NODE_VERSION
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install --no-install-recommends -y nodejs && \
corepack enable && \
rm -rf /var/lib/apt/lists/*
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
# Install JS dependencies
COPY package.json yarn.lock ./
RUN yarn install --immutable # Yarn Berry (v2+); for Yarn Classic (v1), use --frozen-lockfile
# Copy the full application
COPY . .
# Precompile assets (builds client and server bundles)
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
# Remove node_modules — not needed at runtime and saves hundreds of MBs
RUN rm -rf node_modules
###############################################################################
# Runtime stage — lean image for production
###############################################################################
FROM base
# Install runtime dependencies only
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y libpq5 && \
rm -rf /var/lib/apt/lists/*
# Copy built artifacts
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Create non-root user
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Node.js is only needed at build time. The runtime stage does not include Node unless you use the Pro Node Renderer (see Node Renderer in containers below).
SECRET_KEY_BASE_DUMMY=1 lets assets:precompile run without a real secret. Rails 7.1+ supports this natively.
Server bundles land in ssr-generated/ (private, never served to browsers) while client bundles land in public/webpack/production/. Both are copied into the runtime image.
If you use config.build_production_command, it runs during assets:precompile. See Configuration.
Add a .dockerignore file to prevent host-specific files from being copied into the build. Without it, COPY . . can overwrite the freshly installed node_modules/ with modules built for a different OS/architecture. A minimal .dockerignore (expand as appropriate for your project):
node_modules
.git
log
tmp
spec
test
.github
Replace the Yarn lines with:
RUN corepack enable && corepack prepare pnpm@9 --activate # pin to your project's major version
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
Set these at runtime (not baked into the image):
| Variable | Purpose |
|---|---|
SECRET_KEY_BASE | Rails secret key |
DATABASE_URL | Database connection string |
RAILS_SERVE_STATIC_FILES | Set to true when there is no CDN or reverse proxy serving /public |
RAILS_LOG_TO_STDOUT | Set to true for container log collection |
RAILS_ENV | Should be production |
Kamal deploys Docker containers to bare servers over SSH using Traefik as a reverse proxy. It is the default deployment tool for Rails 8.
bundle add kamal
kamal init
service: myapp
image: your-registry/myapp
servers:
web:
hosts:
- 192.168.0.1
options:
memory: 512m
proxy:
ssl: true
host: myapp.example.com
registry:
server: ghcr.io
username: your-username
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
RAILS_SERVE_STATIC_FILES: true
RAILS_LOG_TO_STDOUT: true
secret:
- SECRET_KEY_BASE
- DATABASE_URL
builder:
arch: amd64
kamal setup # first deploy — provisions Traefik
kamal deploy # subsequent deploys
Gemfile.lock and yarn.lock are copied before the full source to maximize cache hits./up by default (Rails 7.1+). Ensure this route is defined.RAILS_SERVE_STATIC_FILES=true or configure an asset host / CDN.builder.remote) avoids this issue.Build and push your image to a container registry:
docker build -t your-registry/myapp:latest .
docker push your-registry/myapp:latest
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: rails
image: your-registry/myapp:latest
ports:
- containerPort: 3000
env:
- name: RAILS_ENV
value: production
- name: RAILS_SERVE_STATIC_FILES
value: 'true'
- name: RAILS_LOG_TO_STDOUT
value: 'true'
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: myapp-secrets
key: secret-key-base
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: database-url
readinessProbe:
httpGet:
path: /up
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /up
port: 3000
initialDelaySeconds: 60
periodSeconds: 20
resources:
requests:
memory: '256Mi'
cpu: '250m'
limits:
memory: '512Mi'
cpu: '1000m'
---
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
ports:
- port: 80
targetPort: 3000
type: ClusterIP
Secrets: Use Kubernetes Secrets with secretKeyRef (as shown above) rather than hardcoding values directly in the env section. Never commit secret values to your manifest files.
Migrations: Run migrations as a Kubernetes Job or init container before the Deployment rolls out:
Warning: With
replicas > 1, each pod's init container runs concurrently. Prefer a Kubernetes Job for migrations unless every migration is idempotent.
initContainers:
- name: migrate
image: your-registry/myapp:latest
command: ['bundle', 'exec', 'rails', 'db:migrate']
env:
- name: RAILS_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: database-url
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: myapp-secrets
key: secret-key-base
Horizontal Pod Autoscaler: Scale based on CPU or custom metrics. React on Rails apps doing SSR are CPU-bound, so CPU-based scaling is a good starting point.
Ingress: Use an Ingress controller (nginx-ingress, Traefik, etc.) with TLS termination in front of the Service.
Control Plane provides Heroku-like ease of use with Kubernetes-level infrastructure. ShakaCode maintains the Control Plane Flow gem (cpflow) for Rails deployments.
gem install cpflow
cpflow setup
This creates a .controlplane/ directory with configuration templates.
aliases:
common: &common
cpln_org: your-org
location: aws-us-east-2
one_off_workload: rails
app_workloads:
- rails
additional_workloads:
- redis
- postgres
apps:
myapp:
<<: *common
Control Plane workloads are similar to Kubernetes Deployments. Key settings for React on Rails:
kind: workload
name: rails
spec:
type: standard
containers:
- name: rails
cpu: '500m'
memory: 512Mi
ports:
- number: 3000
protocol: http
env:
- name: RAILS_ENV
value: production
- name: RAILS_SERVE_STATIC_FILES
value: 'true'
- name: RAILS_LOG_TO_STDOUT
value: 'true'
- name: SECRET_KEY_BASE
value: 'cpln://secret/myapp-secrets.SECRET_KEY_BASE'
- name: DATABASE_URL
value: 'cpln://secret/myapp-secrets.DATABASE_URL'
readinessProbe:
httpGet:
path: /up
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /up
port: 3000
initialDelaySeconds: 60
periodSeconds: 20
cpflow build-image -a myapp # build + push image to registry
cpflow deploy-image -a myapp # deploy the pushed image to Control Plane
cpln://secret/...) instead of environment variables for sensitive values.cpflow run -a myapp -- bundle exec rails db:migrate.If you use React on Rails Pro's Node Renderer for high-performance SSR, the runtime image needs Node.js.
Run the Node Renderer as a separate container/sidecar alongside the Rails container. The node-renderer container needs a separate image that includes Node.js — the main runtime image from the Dockerfile above does not include Node.
# Kubernetes example — two containers in one Pod
containers:
- name: rails
image: your-registry/myapp:latest
ports:
- containerPort: 3000
env:
- name: REACT_RENDERER_URL
value: 'http://localhost:3800'
- name: node-renderer
image: your-registry/myapp-node-renderer:latest # must include Node.js
command: ['node', 'node-renderer.js']
ports:
- containerPort: 3800
env:
- name: RENDERER_HOST
value: '0.0.0.0'
- name: RENDERER_PORT
value: '3800'
readinessProbe:
httpGet:
path: /health
port: 3800
Note:
REACT_RENDERER_URLmust be read in your initializer for it to take effect:ruby# config/initializers/react_on_rails_pro.rb ReactOnRailsPro.configure do |config| config.renderer_url = ENV["REACT_RENDERER_URL"] end
When running the Node Renderer in containers:
host to 0.0.0.0 so health checks and the Rails container can reach it. See Node Renderer configuration.process.env.PORT for the port — Control Plane assigns the port dynamically. See the Control Plane port docs.workersCount explicitly rather than relying on CPU auto-detection, which can over-allocate workers in constrained containers./health endpoint for container orchestrator probes. See Adding a Health Check Endpoint.For all Docker deployments, choose one of:
RAILS_SERVE_STATIC_FILES=true. Simplest option, suitable for low-traffic apps./public directly.public/webpack/production/ to a CDN and set config.asset_host in Rails. Best for global performance.If styles or JS are missing after deploy, verify:
assets:precompile ran successfully during docker buildpublic/webpack/production/ in the built imageRAILS_SERVE_STATIC_FILES=true is set if there is no external file server# Check assets inside a running container
docker exec <container> ls public/webpack/production/
Webpack/Rspack compilation can exceed default container memory. Solutions:
docker build --memory=4gbuilder.remote to offload builds to a more powerful machineIf SSR with ExecJS fails in the container but works locally:
ssr-generated/server-bundle.js exists in the imagemini_racer to your Gemfile (no Node required) or install Node in the runtime stage. For high-performance SSR, consider the Pro Node Renderer sidecar instead.RAILS_LOG_TO_STDOUT=true bundle exec rails console and try ReactOnRails::ServerRenderingPool.reset_pool