Rails with Node.js

If your application is based on Rails 5.2 through 6.1, or is a Rails 7 app selecting one of the JavaScript options or many of the css options, you will require Node.js to be included in your deployment image.

While it is unsurprising that the slim ruby image doesn’t include Nodejs by default, what is surprising is that the most obvious way of installing node and yarn using the Debian packages included with the distribution results in seriously outdated versions being installed. Specifically, nodejs version 12.22.12, and yarnpkg version 1.12.10.

Active support for node 12 ended in 2020, and security support ended in 2022.

Attempting to run anyway ends up with an error running ‘yarn build’, first because yarn is called yarnpkg, and second because the generated package.json has no build step.

Fortunately, there are alternatives.

Nodesource

We can start with the Node.js recommendation.

# syntax = docker/dockerfile:1

FROM ruby:slim as base

RUN apt-get update &&\
    apt-get install --yes git curl build-essential

RUN curl -sL https://deb.nodesource.com/setup_current.x | bash - &&\
    apt-get update && \
    apt-get install --yes --no-install-recommends nodejs &&\
    npm install -g yarn

FROM base as build

RUN gem install rails
RUN rails new demo --skip-active-record --javascript esbuild

FROM base

WORKDIR demo
COPY --from=build /demo /demo
COPY --from=build /usr/local/bundle /usr/local/bundle

COPY <<-"EOF" /demo/config/routes.rb
Rails.application.routes.draw { root "rails/welcome#index" }
EOF

ENV RAILS_ENV=production
RUN bin/rails assets:precompile
EXPOSE 3000
CMD bin/rails server

Unfortunately this results in a rather chonky image, clocking in at 750MB. And attempting to reduce the size by installing packages such as build-essentials later results in failures.

merging images

Another approach is merge files from the official node and ruby images.

# syntax = docker/dockerfile:1

FROM node:slim AS node
FROM ruby:slim as base

COPY --from=node /usr/lib /usr/lib
COPY --from=node /usr/local/share /usr/local/share
COPY --from=node /usr/local/lib /usr/local/lib
COPY --from=node /usr/local/include /usr/local/include
COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /opt /opt

FROM base as build

RUN apt-get update &&\
    apt-get install --yes build-essential git

RUN gem install rails
RUN rails new demo --skip-active-record --javascript esbuild

FROM base

WORKDIR demo
COPY --from=build /demo /demo
COPY --from=build /usr/local/bundle /usr/local/bundle

COPY <<-"EOF" /demo/config/routes.rb
Rails.application.routes.draw { root "rails/welcome#index" }
EOF

ENV RAILS_ENV=production
RUN bin/rails assets:precompile
EXPOSE 3000
CMD bin/rails server

The resulting images size is smaller at 465MB. The danger here is that the node and ruby images may not be based on the same image. At the current time both are based on Debian bullseye, but that may not always remain the case. Both ruby and node provide multiple alternates so it shouldn’t be difficult to find a match.

volta

A third approach is to use a version manager. Much like how Ruby has rvm, rbenv, chruby and others, Node has several. Volta is one written in Rust that works well for this task.

A minimal Dockerfile making use of Volta would look like the following:

# syntax = docker/dockerfile:1

FROM ruby:slim as base

ENV VOLTA_HOME=/usr/local

FROM base as build

RUN apt-get update &&\
    apt-get install --yes build-essential git curl

RUN curl https://get.volta.sh | bash &&\
    volta install node@lts yarn@latest

RUN gem install rails
RUN rails new demo --skip-active-record --javascript esbuild

FROM base

WORKDIR demo
COPY --from=build $VOLTA_HOME/bin $VOLTA_HOME/bin
COPY --from=build $VOLTA_HOME/tools $VOLTA_HOME/tools
COPY --from=build /demo /demo
COPY --from=build /usr/local/bundle /usr/local/bundle

COPY <<-"EOF" /demo/config/routes.rb
Rails.application.routes.draw { root "rails/welcome#index" }
EOF

ENV RAILS_ENV=production
RUN bin/rails assets:precompile
EXPOSE 3000
CMD bin/rails server

The resulting image is slightly larger at 499 MB. The advantage of this approach is that you don’t have to worry about base images matching.

Node as the base

A fourth option is to flip the script: start with node as the base and add ruby. Debian bullseye includes Ruby 2.7 which ended support on March 31, 2023.

# syntax = docker/dockerfile:1

FROM node:slim AS base

RUN apt-get update &&\
    apt-get install --yes ruby

FROM base as build

RUN apt-get install --yes ruby-dev build-essential git

RUN gem install rails
RUN rails new demo --skip-active-record --javascript esbuild

FROM base

WORKDIR demo
COPY --from=build /demo /demo
COPY --from=build /var/lib/gems /var/lib/gems

COPY <<-"EOF" /demo/config/routes.rb
Rails.application.routes.draw { root "rails/welcome#index" }
EOF

ENV RAILS_ENV=production
RUN bin/rails assets:precompile
EXPOSE 3000
CMD bin/rails server

Node the change to the second COPY --from=gems line as the location of the gem directory has changed.

This results in the smallest image yet, at 424 MB. The downside is that you get a dated Ruby.

Variations are possible, including copying Ruby from the base docker image or using a Ruby version manager such as rvm, rbenv, or chruby.

Example application

Below is an example application that demonstrates React.js working with esbuild.

Start by adding the following lines immediately before the FROM base line:

WORKDIR demo
RUN yarn add react react-dom

Replace the three lines starting with COPY <<-"EOF" config/routes.rb with the following:

# syntax = docker/dockerfile:1

FROM ruby:slim as base

RUN apt-get update &&\
    apt-get install --yes git curl build-essential

RUN curl -sL https://deb.nodesource.com/setup_current.x | bash - &&\
    apt-get update && \
    apt-get install --yes --no-install-recommends nodejs &&\
    npm install -g yarn

FROM base as build

RUN gem install rails
RUN rails new demo --skip-active-record --javascript esbuild

WORKDIR demo
RUN yarn add react react-dom

FROM base

WORKDIR demo
COPY --from=build /demo /demo
COPY --from=build /usr/local/bundle /usr/local/bundle

RUN bin/rails generate controller Time index

RUN cat <<-"EOF" >> app/javascript/application.js 
import "./components/counter"
EOF

RUN mkdir app/javascript/components

COPY <<-"EOF" app/javascript/components/counter.jsx
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';

const Counter = ({ arg }) => {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(countRef.current + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return <div>{`${arg} - counter = ${count}!`}</div>;
};

document.addEventListener("DOMContentLoaded", () => {
  const container = document.getElementById("root");
  const root = createRoot(container);
  root.render(<Counter arg={`
    Node ${container.getAttribute('node')}
    Ruby ${container.getAttribute('ruby')}
    Rails ${container.getAttribute('rails')}`} />);
});
EOF

COPY <<-"EOF" app/views/time/index.html.erb
<!DOCTYPE html>
<html>
<head>
<style>
body {
  margin: 0;
}

svg {
  height: 40vmin;
  pointer-events: none;
  margin-bottom: 1em;
}

@media (prefers-reduced-motion: no-preference) {
  svg {
    animation: App-logo-spin infinite 20s linear;
  }
}

main {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>
</head>
<body>
<main>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
  <title>React Logo</title>
  <circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
  <g stroke="#61dafb" stroke-width="1" fill="none">
    <ellipse rx="11" ry="4.2"/>
    <ellipse rx="11" ry="4.2" transform="rotate(60)"/>
    <ellipse rx="11" ry="4.2" transform="rotate(120)"/>
  </g>
</svg>
<div id="root"
  node=<%= `node -v`.strip.sub(/^v/, '') %>
  ruby=<%= RUBY_VERSION %>
  rails=<%= Rails::VERSION::STRING %>>
</div>
</main>
</body>
</html>
EOF

COPY <<-"EOF" config/routes.rb
  Rails.application.routes.draw { root "time#index" }
EOF

ENV RAILS_ENV=production
RUN bin/rails assets:precompile
EXPOSE 3000
CMD bin/rails server

Since this isn’t a React or Rails tutorial, an overview of the contents will suffice:

  • A counter component which extracts attributes from the root element and displays a counter which increments every second.
  • A time controller and view which includes an SVG image and sets HTML attributes containing the Ruby, Rails, and Node versions.

Recap

There are multiple ways to build an image containing both Node.js and Ruby. Finding the right one for your application requires a bit of trial and error and personal preference.

Downloads