Create TaskManager app: Setup Ruby on Rails backend, react frontend, postgresql, Docker and Github Actions

Let’s create an application for managing Tasks which are created by different users. Let’s use React UI for quick data response. The app consists of CRUD operations of Tasks, Authentication of users using JWT-Tokens, Authorization of users for their tasks.

Technology stack

Here’s the technology stack we use in our application.

LayerTechnologyPurpose
BackendRuby on Rails 8.1+REST API
DatabasePostgreSQLPersistent storage
FrontendReactUI
LanguageTypeScriptStatic typing
Build toolViteFrontend development/build
UI libraryFluent UI React v9Components and styling
HTTPFetch API initiallyAPI communication
Server cacheTanStack QueryAPI data caching
Global stateRedux ToolkitShared client-side state
Testing backendRSpecAPI and model tests
Testing frontendVitest + React Testing LibraryComponent tests
ContainersDocker + Docker ComposeContainerization
CI/CDGitHub ActionsAutomation and deployment

Check ruby current version.

$ ruby -v

ruby 3.4.1 (2024-12-25 revision 48d4efcb85)

Check the security patches and updates of your version.

https://www.ruby-lang.org/en/downloads/releases

Then update the latest compatible version.

$ mise use -g ruby@3.4.12

(2026-10-23 revision 366f5fdc00)

To uninstall Ruby using mise, run mise uninstall ruby@ to delete a specific installed version.

$ mise uninstall ruby@3.4.1

Install latest ruby version that you need.

Switch between versions globally:

$ mise use -g ruby@3.4.12

$ mise use -g ruby@4.0.9

Check all needed versions exists:

ruby -v
gem -v
rails -v
node -v
npm -v
psql --version

Check Postgres versions available here: https://www.postgresql.org/download/macosx/

Download the one of the latest one if you have currently installed the old version.

$ brew install postgresql@18

Installing postgresql@18
==> Pouring postgresql@18--18.7.arm64_tahoe.bottle.tar.gz
Warning: postgresql@18 was installed but not linked because postgresql@14 is already linked.
To link this version, run:
brew link postgresql@18

$ brew link postgresql@18
Unlinking /opt/homebrew/Cellar/postgresql@14/14.17_1... 2165 symlinks removed.
Linking /opt/homebrew/Cellar/postgresql@18/18.7... 812 symlinks created.
$ psql --version
psql (PostgreSQL) 18.7 (Homebrew)

Update node js:

$ node -v
v24.14.0

install the latest Long-Term Support release

$ nvm install --lts
Installing latest LTS version.
Downloading and installing node v26.13.1...
Downloading https://nodejs.org/dist/v26.13.1/node-v26.13.1-darwin-arm64.tar.xz...
Computing checksum with sha256sum
Checksums matched!
Now using node v26.13.1 (npm v11.20.0)

Create app folder:

$ mkdir rails-react-task-manager
$ cd rails-react-task-manager
$ git init -b main

We want to manage the backend, frontend, Docker configuration and CI/CD workflows in one repository.

Update ruby gems:

A new release of RubyGems is available: 4.0.20 → 4.0.24!
See https://github.com/ruby/rubygems/blob/v4.0.24/CHANGELOG.md for the changes since 4.0.20.
Run `gem update --system 4.0.24` to update your installation

Install Rails:

check rails newest versions: https://rubygems.org/gems/rails/versions

$ gem install rails -v '~> 8.1.0'

Note: Pessimistic version constraint. Using -v “~> 8.1.0” installs the latest patch release within the 8.1 series (such as 8.1.4.1) while avoiding unintended jumps to future minor or major versions

Reopen the shell and check:

Create a github repository

name it: rails-react-task-manager

$ git remote add origin git@github.com:<username>/rails-react-task-manager.git

Problem 1:

The first problem I am facing is the command rails new already has the folders:
.git, .github, .gitattributes, .gitignore etc inside rails app.

So when I try to push the rails app:

git add backend/
error: 'backend/' does not have a commit checked out
error: unable to index file 'backend/'
fatal: adding files failed

This happens because Rails automatically initializes a Git repository when you run rails new.

Solution: Keep only one Git repository

Since we’re building a monorepo, we want Git initialized only at the project root.

Remove only the nested Git metadata:

rm -rf backend/.git
TypeWhen to Use It Example
choreUpgrading packages, libraries, or runtime versions chore: update Node.js version or chore(deps): bump lodash
featAdding a new feature or capability for the user.feat: add user login via OAuth
fixPatching a bug in the application code.fix: resolve crash on checkout page
refactorRestructuring production code without changing its behavior.refactor: simplify auth helper logic
docsUpdating READMEs, comments, or API guides.docs: add setup instructions
styleFixing formatting, whitespace, or missing semicolons.style: format files with Prettier
testAdding or updating unit and integration tests.test: add unit tests for cart total
build / ciMaking dedicated changes to build systems or CI pipelines (used by teams that prefer stricter categories over chore).ci: cache node_modules in pipeline

🔐 How to Implement Secure Rails APIs

Implementing Secure Rails APIs
Safeguarding your API isn’t a one-and-done task—it’s a layered approach combining transport encryption, robust authentication, granular authorization, data hygiene, and more. In this post, we’ll walk through twelve core pillars of API security in Rails 8, with code examples and practical tips.

⚙️ 1. Enforce HTTPS Everywhere

Why it matters

Unencrypted HTTP traffic can be intercepted or tampered with. HTTPS (TLS/SSL) ensures end-to-end confidentiality and integrity.

Rails setup

In config/environments/production.rb:

# Forces all access to the app over SSL, uses Strict-Transport-Security, and uses secure cookies.
config.force_ssl = true

This automatically:

  • Redirects any HTTP request to HTTPS
  • Sets the Strict-Transport-Security header
  • Flags cookies as secure

Tip: For development, you can use mkcert or rails dev:ssl to spin up a self-signed certificate.

🔑 2. Stateless Token Authentication with JWT

Why JWT?

  • Stateless: No session lookup in DB
  • Portable: Works across domains or mobile clients
  • Customizable: Embed claims (user roles, expiry, etc.)

Implementation Steps

  1. Install # Gemfile gem 'jwt'
  2. Generating a Token # app/lib/json_web_token.rb module JsonWebToken SECRET = Rails.application.secret_key_base def self.encode(payload, exp = 24.hours.from_now) payload[:exp] = exp.to_i JWT.encode(payload, SECRET) end end
  3. Decoding & Verification def self.decode(token) body = JWT.decode(token, SECRET)[0] HashWithIndifferentAccess.new body rescue JWT::ExpiredSignature, JWT::DecodeError nil end
  4. Authenticating Requests class ApplicationController < ActionController::API before_action :authenticate_request! private def authenticate_request! token = request.headers['Authorization']&.split(' ')&.last decoded = JsonWebToken.decode(token) @current_user = User.find_by(id: decoded[:user_id]) if decoded render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user end end

Tip: Always set a reasonable expiration (exp) and consider rotating your secret_key_base periodically.

🛡️ 3. Authorization with Pundit (or CanCanCan)

Why you need it

Authentication only proves identity; authorization controls what that identity can do. Pundit gives you policy classes that cleanly encapsulate permissions.

Example Pundit Setup

  1. Install bundle add pundit
  2. Include # app/controllers/application_controller.rb include Pundit rescue_from Pundit::NotAuthorizedError, with: :permission_denied def permission_denied render json: { error: 'Forbidden' }, status: :forbidden end
  3. Define a Policy # app/policies/post_policy.rb class PostPolicy < ApplicationPolicy def update? user.admin? || record.user_id == user.id end end
  4. Use in Controller def update post = Post.find(params[:id]) authorize post # raises if unauthorized post.update!(post_params) render json: post end

Pro Tip: Keep your policy logic simple. If you see repeated conditional combinations, extract them to helper methods or scopes.

🔐 4. Strong Parameters for Mass-Assignment Safety

The risk

Allowing unchecked request parameters can enable attackers to set fields like admin: true.

Best Practice

def user_params
  params.require(:user).permit(:name, :email, :password)
end

  • Require ensures the key exists.
  • Permit whitelists only safe attributes.

Note: For deeply-nested or polymorphic data, consider using form objects or contracts (e.g., Reform, dry-validation).

⚠️ 5. Rate Limiting with Rack::Attack

Throttling to the rescue

Protects against brute-force, scraping, and DDoS-style abuse.

Setup Example

# Gemfile
gem 'rack-attack'

# config/initializers/rack_attack.rb
class Rack::Attack
  # Throttle all requests by IP (60rpm)
  throttle('req/ip', limit: 60, period: 1.minute) do |req|
    req.ip
  end

  # Blocklist abusive IPs
  blocklist('block 1.2.3.4') do |req|
    req.ip == '1.2.3.4'
  end

  self.cache.store = ActiveSupport::Cache::MemoryStore.new 
end

Tip: Customize by endpoint, user, or even specific header values.

🚨 6. Graceful Error Handling & Logging

Leak no secrets

Catching exceptions ensures you don’t reveal stack traces or sensitive internals.

class ApplicationController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from Pundit::NotAuthorizedError, with: :forbidden
  rescue_from JWT::DecodeError, with: :unauthorized

  private
  def not_found;    render json: { error: 'Not Found' }, status: :not_found; end
  def forbidden;    render json: { error: 'Forbidden' }, status: :forbidden; end
  def unauthorized; render json: { error: 'Invalid Token' }, status: :unauthorized; end
end

Parameter Filtering

In config/initializers/filter_parameter_logging.rb:

Rails.application.config.filter_parameters += [:password, :token, :authorization]

Tip: Don’t log request bodies in production—only metadata and sanitized parameters.

🔍 7. Data Validation & Sanitization

Model-level safeguards

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, length: { minimum: 8 }
end

  • Presence & format guard against blank or malformed data.
  • Length, numericality, custom validators catch edge cases.

Advanced Contracts

For complex payloads, try dry-validation or Reform.


🧼 8. Controlled JSON Rendering

Why serializers?

Out-of-the-box render json: user dumps every attribute, which may include internal flags.

Popular Gems

  • ActiveModelSerializers
  • fast_jsonapi
  • Jbuilder
Example with ActiveModelSerializers
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :email
end

render json: @user, serializer: UserSerializer

Tip: Expose only what clients need—avoid oversharing.

🔄 9. Database Constraints & Migrations

Never trust application code alone

In your migration:

create_table :users do |t|
  t.string :email, null: false
  t.string :encrypted_password, null: false
  t.index  :email, unique: true
  t.timestamps
end

  • null: false ensures no blank data slips through.
  • Database-level unique index enforces uniqueness even under race conditions.

📦 10. Secure HTTP Headers

Defense in depth

Use the secure_headers gem to set headers like CSP, HSTS, X-Frame-Options, etc.

# Gemfile
gem 'secure_headers'

# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=31536000; includeSubDomains"
  config.x_frame_options = "DENY"
  config.x_content_type_options = "nosniff"
  config.x_xss_protection = "1; mode=block"
  config.csp = {
    default_src: %w('self'),
    script_src:  %w('self' 'unsafe-inline'),
    img_src:     %w('self' data:),
  }
end

Tip: Tailor your CSP to your front-end needs; overly broad CSPs defeat the purpose.

👀 11. CSRF Protection (Session-Based APIs)

When cookies are used

APIs are usually token-based, but if you mix in sessions:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session
end

  • Disables raising an exception for API requests, instead resets the session.
  • Ensures malicious forged requests don’t carry your user’s cookies.

🧪 12. Security Testing & CI Integration

Automate your checks

  • RSpec / Minitest: write request specs to cover auth/authorization failures.
  • Brakeman: static analysis tool spotting Rails vulnerabilities.
  • Bundler Audit: checks for known vulnerable gem versions.
Example RSpec test
require 'rails_helper'

RSpec.describe 'Posts API', type: :request do
  it 'rejects unauthenticated access' do
    get '/api/posts'
    expect(response).to have_http_status(:unauthorized)
  end
end

CI Tip: Fail your build if Brakeman warnings exceed zero, or if bundle audit finds CVEs.

🪵 12. Log Responsibly

Don’t log sensitive data (passwords, tokens, etc.)

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:password, :token, :authorization]

🏁 Conclusion

By combining transport security (HTTPS), stateless authentication (JWT), policy-driven authorization (Pundit), parameter safety, rate limiting, controlled data rendering, hardened headers, and continuous testing, you build a defense-in-depth Rails API. Each layer reduces the attack surface—together, they help ensure your application remains robust against evolving threats.


Happy Rails Security Setup!  🚀