RUBY · STEP 5 · API

Ruby · Rails: building and deploying an API

Scaffold a JSON API in Rails 8.1 with one command, shape it to the shared Todos contract, test it with curl, deploy it with a Postgres database on Railway, and open it up to your frontend with rack-cors.

Step 5 of 5Railsv8.1.4.1© Arthur Bernier Jrrewritten · verified13 code blocks1642 words

What you will learn#

  • Creating an API
  • Setting Cors Headers
  • Testing an API
  • Deploying an API

The Todos contract#

RequestResponse
GET /health200 {"ok":true}
GET /todos200 [{id,subject,details}] ordered by id
GET /todos/:id200 the object, or 404 {"error":"not found"}
POST /todos with {subject,details}201 the new object
PUT /todos/:id with {subject,details}200 the updated object, or 404
DELETE /todos/:id204 with no body, or 404
blank or missing subject or details400 {"error":"…"}

Every backend guide on this site builds this same API, with no trailing slashes, objects that contain exactly id, subject and details, and CORS open to any origin so the four frontend guides can point at any of them. The finished Rails version is github.com/arthurbernierjr/dtc-ruby-api, live at api-production-0197.up.railway.app.


Setup#

  • Create a new Rails project: rails new todos --api -d postgresql --skip-solid, then cd todos

  • Set up your database settings in config/database.yml as before (only if your local Postgres wants a username/password), then bin/rails db:create

WHY --skip-solid: Rails 8 ships Solid Queue, Solid Cache and Solid Cable, each with its own database. With Postgres that means the generated production config lists four databases, and the DATABASE_URL a host gives you only fills in the first. A small API doesn't need background jobs, a cache store or websockets, so we skip them and get a single-database production block that works unchanged on Railway. (Add them back later with bin/rails solid_queue:install and friends.)

Keep your development and test blocks, and make production read like the example's config/database.yml (the whole file, since the example only runs in production):

YAML
default: &default  adapter: postgresql  encoding: unicode  pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %> production:  <<: *default  url: <%= ENV["DATABASE_URL"] %>

Rails would merge DATABASE_URL into production on its own; the explicit line just makes it visible.

The example repo is trimmed to the files this lesson touches: no test files, no credentials, no development config. Treat it as the answer key for what to write, not as a snapshot of rails new output. Its Gemfile pins gem "rails", "8.1.4.1" next to pg, puma and rack-cors, which Railway resolved to Ruby 3.4.12, Rails 8.1.4.1, Puma 6.6.1, pg 1.7.0, rack 3.2.8 and rack-cors 3.0.0.

Creating the API#

While we can build the API brick by brick like we did yesterday, one of the coolest features in Rails is its scaffold generator. We can build our whole API in just a few commands like so...

SH
bin/rails generate scaffold Todo subject:string details:string

Notice all that was created for us...

  • A migration file in db/migrate with t.string :subject, t.string :details and t.timestamps (the example's is db/migrate/20261107000000_create_todos.rb; yours carries the timestamp of when you ran the generator)
  • A model file, app/models/todo.rb
  • A controller with all the resource actions ready to go
  • resources :todos in config/routes.rb
  • Test files (no views or helpers, because this is an --api app)

The table keeps created_at and updated_at; the contract just doesn't show them. That's the model's job. Replace app/models/todo.rb with:

RB
class Todo < ApplicationRecord  validates :subject, :details, presence: true   def as_json(options = {})    super(options.merge(only: %i[id subject details]))  endend

WHY as_json: render json: @todo calls as_json on the record, so one override here shapes every response in the app to exactly id, subject and details. presence: true rejects both missing and blank (" ") values, which is what gives us the contract's 400s.

Three things can go wrong in any action: the id doesn't exist, the todo key is missing, or the body isn't JSON. Handle them once, in app/controllers/application_controller.rb:

RB
class ApplicationController < ActionController::API  rescue_from ActiveRecord::RecordNotFound do    render json: { error: "not found" }, status: :not_found  end   rescue_from ActionController::ParameterMissing do    render json: { error: "subject and details are required" }, status: :bad_request  end   rescue_from ActionDispatch::Http::Parameters::ParseError do    render json: { error: "malformed JSON body" }, status: :bad_request  endend

Now open app/controllers/todos_controller.rb — it's yesterday's turtles controller, written for you. Make it read like this (what changed is listed underneath):

RB
class TodosController < ApplicationController  before_action :set_todo, only: %i[show update destroy]   def index    render json: Todo.order(:id)  end   def show    render json: @todo  end   def create    @todo = Todo.new(todo_params)    if @todo.save      render json: @todo, status: :created    else      render json: { error: @todo.errors.full_messages.to_sentence }, status: :bad_request    end  end   def update    if @todo.update(todo_params)      render json: @todo    else      render json: { error: @todo.errors.full_messages.to_sentence }, status: :bad_request    end  end   def destroy    @todo.destroy!    head :no_content  end   private   def set_todo    @todo = Todo.find(params.expect(:id))  end   def todo_params    params.expect(todo: [:subject, :details])  endend

What changed from the generated file:

  • index orders by id instead of Todo.all, so the list is stable
  • the two failure branches answer 400 with one error sentence ("Subject can't be blank and Details can't be blank") instead of 422 with @todo.errors as a hash, and create drops location: @todo
  • destroy adds head :no_content so the 204 is explicit; params.expect and Todo.find stay, because when the key or the row is missing they raise and ApplicationController answers the 400 or 404

Add the health check, app/controllers/health_controller.rb:

RB
class HealthController < ApplicationController  def show    render json: { ok: true }  endend

And config/routes.rb:

RB
Rails.application.routes.draw do  resources :todos, only: %i[index show create update destroy]  get "health", to: "health#show"  root to: "home#show"end

only: lists the five actions we have (resources would also route new and edit, which an API doesn't define). root points at a one-method HomeController in the example repo that does render html: PAGE, layout: false with a small inline HTML page, so / in a browser isn't a 404; copy it from the repo, or drop that line. The example also deletes public/index.html so root wins.

WHY flat bodies work: the contract sends {"subject": "...", "details": "..."}, but params.expect(todo: [...]) reads params[:todo]. Parameter wrapping copies the keys that match the model's columns under todo for every JSON request. Rails 8.1 does this by default; the example says so out loud in config/initializers/wrap_parameters.rb (wrap_parameters format: [:json]), and a body nested under todo keeps working too.

All we have left to do is bin/rails db:migrate. That's it, our API is ready to go...

Testing the API#

Run your server (bin/rails server) and run the following tests, in Postman or with curl -i so you can see the status line:

  • create 3 todos with a POST request to /todos — expect 201 Created and {"id":1,"subject":"Learn Rails","details":"scaffold is magic"}
SH
curl -i -X POST http://localhost:3000/todos \  -H "Content-Type: application/json" \  -d '{"subject": "Learn Rails", "details": "scaffold is magic"}'
  • see all the todos with a GET request to /todos — curl http://localhost:3000/todos — expect 200 and an array with no created_at in it
  • see an individual todo with a GET request to /todos/:id — curl http://localhost:3000/todos/1 — expect 200
  • update a todo with a PUT request to /todos/:id, sending both fields — expect 200 and the updated object (PATCH is routed too)
SH
curl -i -X PUT http://localhost:3000/todos/1 \  -H "Content-Type: application/json" \  -d '{"subject": "Learn Rails", "details": "done"}'
  • send a bad body — curl -i -X POST http://localhost:3000/todos -H "Content-Type: application/json" -d '{}' — expect 400 Bad Request and {"error":"subject and details are required"}; {"subject": " ", "details": "x"} also answers 400, with the validation sentence
  • ask for a todo that isn't there — curl -i http://localhost:3000/todos/999 — expect 404 and {"error":"not found"}
  • delete a todo with a DELETE request to /todos/:id — curl -i -X DELETE http://localhost:3000/todos/1 — expect 204 No Content, and 404 if you run it again

Run the contract check against your own deploy: node check.mjs https://your-api.up.railway.app (copy check.mjs from the example repo). All 16 lines should say PASS. It works against http://localhost:3000 too.

Deploying the API#

We'll deploy to Railway, which deploys straight from GitHub and builds whatever Dockerfile it finds at the root of the repo. rails new generated one, and it works, but it is a long multi-stage build. The example replaces it with eight lines:

DOCKER
FROM ruby:3.4-bookwormWORKDIR /appENV BUNDLE_DEPLOYMENT=0COPY Gemfile ./RUN bundle installCOPY . .RUN chmod +x /app/bin/boot /app/bin/railsCMD ["/app/bin/boot"]

The CMD is a two-line script, bin/boot, which migrates and then starts Puma on the port Railway hands us:

SH
#!/bin/bashcd /app || exit 1echo BOOTbundle exec ruby bin/rails db:prepareexec bundle exec ruby bin/rails server -b 0.0.0.0 -p "${PORT:-8080}"

WHY db:prepare: it creates the database if needed and then loads the schema or runs pending migrations, so every deploy migrates itself — there is no "open a console and run db:migrate" step like the old Heroku flow, and no custom start command on Railway either. exec hands the process over to Puma so Railway's stop signals reach it. Railway sets PORT; the ${PORT:-8080} fallback is for running the image locally.

Production secrets come from the environment, not from config/master.key: Rails reads SECRET_KEY_BASE in production, and the example's config/environments/production.rb says so with config.secret_key_base = ENV["SECRET_KEY_BASE"] (plus config.hosts << /.*\.up\.railway\.app/ so Puma answers for the Railway domain; a rails new app leaves config.hosts empty, which allows every host). Then:

  • Commit and push your code to a repo on GitHub

  • On railway.com create a New Project → Deploy from GitHub repo and pick your repo (link your GitHub account if asked)

  • Add a database: Create → Database → Add PostgreSQL

  • In your app service's Variables, add DATABASE_URL with the value ${{Postgres.DATABASE_URL}} (a reference to the Postgres service you just created), RAILS_ENV set to production, and SECRET_KEY_BASE set to the output of bin/rails secret

  • Deploy, then watch View logs for BOOT, the db:prepare output and Puma listening

  • Under Settings → Networking, click Generate Domain to get a public URL

Everything the app reads from the environment: DATABASE_URL (required), SECRET_KEY_BASE (required in production), RAILS_ENV (production on Railway), PORT (Railway sets it; falls back to 8080) and RAILS_MAX_THREADS (Puma's pool, defaults to 5). Never commit SECRET_KEY_BASE or DATABASE_URL.

Your API is deployed! Rerun your tests against your new https://<your-app>.up.railway.app/todos URL, then node check.mjs https://<your-app>.up.railway.app. Congrats!

CORS#

While your API is deployed and working, our frontend application will currently find itself experiencing CORS errors since we haven't configured the CORS headers. So let's do that!

  • In your Gemfile, uncomment the gem "rack-cors" line (the example's Gemfile above already has it)

  • run bundle install

  • edit config/initializers/cors.rb — Rails generated it with everything commented out; make it read:

RB
Rails.application.config.middleware.insert_before 0, Rack::Cors do  allow do    origins "*"     resource "*",      headers: :any,      methods: %i[get post put patch delete options head]  endend
  • now commit and push your code back up; Railway redeploys on every push and you shouldn't receive any more CORS errors on your frontend.

origins "*" is right for this lesson: any of the four frontend guides, running on any origin, can call your API, and check.mjs sends its preflights from a made-up origin. In a real product list the exact origins of your frontend instead, with no trailing slash:

RB
origins "http://localhost:5173", "https://your-frontend.example.com"

("*" can't be combined with credentials: true, so cookie-based auth needs the explicit list.) bin/rails middleware shows Rack::Cors at the top of the stack, which is what insert_before 0 guarantees.


Resources to Learn More#


See also