Rails API-only Applications

Rails API-only Applications are composed of two parts: a streamlined Rails server typically serving JSON responses, and a JavaScript client, typically using a framework such as React, Vue, or Angular.

Minimal Dockerfile

Below is a Dockerfile that deploys a Create React App client with a Rails API server:

# syntax = docker/dockerfile:1

FROM node:slim AS react

RUN npx create-react-app client

RUN cd client; npm run build

FROM ruby:slim AS build

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

RUN gem install rails
RUN rails new demo --minimal --skip-active-record --api

FROM ruby:slim

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

COPY --from=react /client/build /demo/public

WORKDIR demo
ENV RAILS_ENV=production
ENV RAILS_SERVE_STATIC_FILES=true
EXPOSE 3000
CMD bin/rails server

Differences from a minimal Rails app:

  • A react build stage based on the node:slim image and consisting of two RUN steps: one that creates a react app, and one that builds it.
  • An --api flag is added to the rails new command.
  • A COPY statement copies the build artifacts to the demo application’s public directory.
  • RAILS_SERVE_STATIC_FILES is set to true. This is necessary as [[statics]] won’t find index.html files at the root. Even so, adding a statics section to the toml file is useful for the remainder of the bundled assets:

      [[statics]]
      guest_path = "/demo/public"
      url_prefix = "/"
    

If the above is deployed you will see a spinning react logo.

Example

Following is a example that demonstrates a React client working with a Rails API server. First the React client:

# syntax = docker/dockerfile:1

FROM node:slim AS react

RUN npx create-react-app client

RUN node --version > client/.node-version

COPY <<-"EOF" client/src/App.js
import logo from './logo.svg';
import './App.css';
import React, { useState, useEffect } from 'react';

function App() {
  let [versions, setVersions] = useState('loading...');

  useEffect(() => {
    fetch('api/versions')
    .then(response => response.json())
    .then(versions => {
      setVersions(Object.entries(versions)
        .map(([name, version]) => `${name}: ${version}`).join(', ')
      )
    });
  });

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>{ versions }</p>
      </header>
    </div>
  );
}

export default App;
EOF

RUN cd client; npm run build

FROM ruby:slim AS build

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

RUN gem install rails
RUN rails new demo --minimal --skip-active-record --api

FROM ruby:slim

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

COPY --from=react /client/build /demo/public
COPY --from=react /client/.node-version /demo

WORKDIR demo

RUN bin/rails generate controller Api versions

COPY <<-"EOF" app/controllers/api_controller.rb
class ApiController < ApplicationController
  def versions
    render json: { 
      ruby: RUBY_VERSION,
      rails: Rails::VERSION::STRING,
      node: IO.read('.node-version').strip.sub(/^v/, '')
    }
  end
end
EOF

ENV RAILS_ENV=production
ENV RAILS_SERVE_STATIC_FILES=true
EXPOSE 3000
CMD bin/rails server

The above captures the node version into a file, and defines a React hook that fetches version information from the API server and renders the rotating React logo followed by this version information.

Now the server implementation:

# syntax = docker/dockerfile:1

FROM node:slim AS react

RUN npx create-react-app client

RUN node --version > client/.node-version

COPY <<-"EOF" client/src/App.js
import logo from './logo.svg';
import './App.css';
import React, { useState, useEffect } from 'react';

function App() {
  let [versions, setVersions] = useState('loading...');

  useEffect(() => {
    fetch('api/versions')
    .then(response => response.json())
    .then(versions => {
      setVersions(Object.entries(versions)
        .map(([name, version]) => `${name}: ${version}`).join(', ')
      )
    });
  });

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>{ versions }</p>
      </header>
    </div>
  );
}

export default App;
EOF

RUN cd client; npm run build

FROM ruby:slim AS build

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

RUN gem install rails
RUN rails new demo --minimal --skip-active-record --api

FROM ruby:slim

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

COPY --from=react /client/build /demo/public
COPY --from=react /client/.node-version /demo

WORKDIR demo

RUN bin/rails generate controller Api versions

COPY <<-"EOF" app/controllers/api_controller.rb
class ApiController < ApplicationController
  def versions
    render json: { 
      ruby: RUBY_VERSION,
      rails: Rails::VERSION::STRING,
      node: IO.read('.node-version').strip.sub(/^v/, '')
    }
  end
end
EOF

ENV RAILS_ENV=production
ENV RAILS_SERVE_STATIC_FILES=true
EXPOSE 3000
CMD bin/rails server

This consists of an additional COPY statement to add the .node-version information to the image, and a controller that returns various version strings as a JSON object.

Recap

From a Fly.io perspective what you have is a standard Rails application with an additional build step. That build step bundles the client into static assets (HTML, CSS, and JavaScript).

Downloads