Docker Deployment
This guide covers deploying a React on Rails application using Docker containers, with specific instructions for Kamal, Kubernetes, and Control Plane.
Dockerfile for React on Rails
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"]
Key points
-
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=1letsassets:precompilerun 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 inpublic/webpack/production/. Both are copied into the runtime image. -
If you use
config.build_production_command, it runs duringassets:precompile. See Configuration. -
Add a
.dockerignorefile to prevent host-specific files from being copied into the build. Without it,COPY . .can overwrite the freshly installednode_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
Using pnpm instead of Yarn
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
Environment variables
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 |
Deploying with Kamal
Kamal deploys Docker containers to bare servers over SSH using Traefik as a reverse proxy. It is the default deployment tool for Rails 8.
Setup
bundle add kamal
kamal init
config/deploy.yml
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
Deploy
kamal setup # first deploy — provisions Traefik
kamal deploy # subsequent deploys
Kamal tips for React on Rails
- Build caching: Kamal uses Docker layer caching. Structure your Dockerfile so
Gemfile.lockandyarn.lockare copied before the full source to maximize cache hits. - Health checks: Kamal probes
/upby default (Rails 7.1+). Ensure this route is defined. - Asset serving: Set
RAILS_SERVE_STATIC_FILES=trueor configure an asset host / CDN. - Memory: Webpack/Rspack compilation is memory-intensive. If building on the server, allocate at least 2 GB for the builder. Using remote builds (
builder.remote) avoids this issue.
Deploying with Kubernetes
Container image
Build and push your image to a container registry:
docker build -t your-registry/myapp:latest .
docker push your-registry/myapp:latest
Deployment manifest
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
Kubernetes tips for React on Rails
-
Secrets: Use Kubernetes Secrets with
secretKeyRef(as shown above) rather than hardcoding values directly in theenvsection. 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.
Deploying with Control Plane
Control Plane provides Heroku-like ease of use with Kubernetes-level infrastructure. ShakaCode maintains the Control Plane Flow gem (cpflow) for Rails deployments.
Setup
gem install cpflow
cpflow setup
This creates a .controlplane/ directory with configuration templates.
.controlplane/controlplane.yml
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
.controlplane/templates/rails.yml
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
Deploy
cpflow build-image -a myapp # build + push image to registry
cpflow deploy-image -a myapp # deploy the pushed image to Control Plane
Control Plane tips for React on Rails
- GVC environment variables: Set shared environment variables at the GVC level so all workloads inherit them. See the Control Plane Flow guide to secrets and ENV values.
- Secrets: Use Control Plane's built-in secrets management (
cpln://secret/...) instead of environment variables for sensitive values. - One-off tasks: Run migrations and other one-off commands via
cpflow run -a myapp -- bundle exec rails db:migrate. - Multiple locations: Control Plane supports multi-region deployment. Add locations to your GVC to deploy globally.
Node Renderer in containers
If you use React on Rails Pro's Node Renderer for high-performance SSR, the runtime image needs Node.js.
Multi-container setup
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:# config/initializers/react_on_rails_pro.rb
ReactOnRailsPro.configure do |config|
config.renderer_url = ENV["REACT_RENDERER_URL"]
end
Configuration for containers
When running the Node Renderer in containers:
- Set
hostto0.0.0.0so health checks and the Rails container can reach it. See Node Renderer configuration. - On Control Plane, use
process.env.PORTfor the port — Control Plane assigns the port dynamically. See the Control Plane port docs. - Set
workersCountexplicitly rather than relying on CPU auto-detection, which can over-allocate workers in constrained containers. - Add a
/healthendpoint for container orchestrator probes. See Adding a Health Check Endpoint.
Static assets and CDN
For all Docker deployments, choose one of:
- Rails serves static files — Set
RAILS_SERVE_STATIC_FILES=true. Simplest option, suitable for low-traffic apps. - Reverse proxy serves files — Nginx, Traefik, or a cloud load balancer serves files from
/publicdirectly. - CDN — Upload
public/webpack/production/to a CDN and setconfig.asset_hostin Rails. Best for global performance.
Troubleshooting
Assets missing at runtime
If styles or JS are missing after deploy, verify:
assets:precompileran successfully duringdocker build- Client bundles exist in
public/webpack/production/in the built image RAILS_SERVE_STATIC_FILES=trueis set if there is no external file server
# Check assets inside a running container
docker exec <container> ls public/webpack/production/
Out of memory during build
Webpack/Rspack compilation can exceed default container memory. Solutions:
- Increase Docker builder memory:
docker build --memory=4g - Use a separate CI pipeline for image builds with higher resource limits
- With Kamal, use
builder.remoteto offload builds to a more powerful machine
Server rendering fails
If SSR with ExecJS fails in the container but works locally:
- Ensure
ssr-generated/server-bundle.jsexists in the image - Check that a JavaScript runtime is available. The Dockerfile above intentionally excludes Node from the runtime stage. If your app needs runtime JS execution (e.g. ExecJS), either add
mini_racerto your Gemfile (no Node required) or install Node in the runtime stage. For high-performance SSR, consider the Pro Node Renderer sidecar instead. - Check logs:
RAILS_LOG_TO_STDOUT=true bundle exec rails consoleand tryReactOnRails::ServerRenderingPool.reset_pool
See also
- Heroku Deployment — PaaS deployment without Docker
- Configuration Reference — All React on Rails settings
- Node Renderer Configuration — Pro Node Renderer setup
- Server Rendering Tips — SSR debugging and optimization
- Control Plane Flow — cpflow gem for Control Plane deployments