Skip to main content

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=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

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):

VariablePurpose
SECRET_KEY_BASERails secret key
DATABASE_URLDatabase connection string
RAILS_SERVE_STATIC_FILESSet to true when there is no CDN or reverse proxy serving /public
RAILS_LOG_TO_STDOUTSet to true for container log collection
RAILS_ENVShould 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.lock and yarn.lock are copied before the full source to maximize cache hits.
  • Health checks: Kamal probes /up by default (Rails 7.1+). Ensure this route is defined.
  • Asset serving: Set RAILS_SERVE_STATIC_FILES=true or 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 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.

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_URL must 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 host to 0.0.0.0 so health checks and the Rails container can reach it. See Node Renderer configuration.
  • On Control Plane, use process.env.PORT for the port — Control Plane assigns the port dynamically. See the Control Plane port docs.
  • Set workersCount explicitly rather than relying on CPU auto-detection, which can over-allocate workers in constrained containers.
  • Add a /health endpoint for container orchestrator probes. See Adding a Health Check Endpoint.

Static assets and CDN

For all Docker deployments, choose one of:

  1. Rails serves static files — Set RAILS_SERVE_STATIC_FILES=true. Simplest option, suitable for low-traffic apps.
  2. Reverse proxy serves files — Nginx, Traefik, or a cloud load balancer serves files from /public directly.
  3. CDN — Upload public/webpack/production/ to a CDN and set config.asset_host in Rails. Best for global performance.

Troubleshooting

Assets missing at runtime

If styles or JS are missing after deploy, verify:

  1. assets:precompile ran successfully during docker build
  2. Client bundles exist in public/webpack/production/ in the built image
  3. RAILS_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/

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.remote to offload builds to a more powerful machine

Server rendering fails

If SSR with ExecJS fails in the container but works locally:

  1. Ensure ssr-generated/server-bundle.js exists in the image
  2. 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_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.
  3. Check logs: RAILS_LOG_TO_STDOUT=true bundle exec rails console and try ReactOnRails::ServerRenderingPool.reset_pool

See also