Skip to content

Latest commit

Β 

History

421 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Build Instagram by Ruby on Rails

CI Ruby Rails

Instuigram is an Instagram clone built on Ruby on Rails, covering what a real Rails application needs beyond CRUD: authentication, background jobs, caching, full-text search, real-time direct messaging, a follow graph, and a CI pipeline that enforces security and style on every change to master.

Article Series on Medium

This project began as a step-by-step Medium series walking through building it from scratch:

Tech Stack

Back-end

  • Ruby 3.3.11 Β· Rails 8.1.3.1
  • PostgreSQL β€” primary database
  • Redis β€” Rails cache store, Sidekiq queue backend, and Action Cable pub/sub
  • Sidekiq 8.1 β€” background job processing
  • Elasticsearch 8.x β€” full-text search
  • Puma 8 β€” application server
  • Devise 5 (authentication) Β· Kaminari (pagination) Β· Active Storage (file uploads)
  • JWT β€” token issuance for the /api/v1 surface

Real-time

  • Action Cable over Redis, with hand-written Stimulus controllers β€” ConversationChannel, InboxChannel, PresenceChannel, PostChannel
  • Turbo Streams via declarative turbo_stream_from β€” follow buttons, follower counts, comments and reactions

Front-end

  • Server-rendered ERB
  • Turbo + Stimulus
  • Bootstrap 5.3 (CSS only, no jQuery)
  • Sprockets serves CSS, fonts and images; importmap-rails serves all JS

Quality & security

  • Minitest β€” model, controller, service, channel, job and Capybara/Selenium system tests
  • SimpleCov β€” coverage report generated on every local bin/rails test
  • RuboCop (rubocop-rails-omakase) β€” style
  • Brakeman 8 β€” static security analysis
  • bundler-audit β€” dependency CVE scanning
  • bullet β€” N+1 query detection in development and test
  • annotaterb β€” schema annotations above each model, with CI failing on drift
  • CI runs five independent, parallel GitHub Actions jobs on every push to master and every pull request targeting it: brakeman, bundler_audit, rubocop, test and system_test (Rails' default test glob excludes test/system, so the browser suite needs its own job)

What you'll learn from this project

  • Bootstrapping a Rails app and structuring it around MVC
  • Active Record: migrations, validations, callbacks, associations, and the query interface
  • Views: layouts, partials, and form helpers
  • Controllers: actions and strong parameters
  • Rails routing
  • File uploads with Active Storage
  • Authentication with Devise, pagination with Kaminari
  • Background jobs with Sidekiq and caching with Redis
  • Full-text search with Elasticsearch
  • Real-time UI with Action Cable and Turbo Streams, no SPA framework
  • Keeping a growing model tidy: concerns, service objects, and counter caches
  • Standing up a token-authenticated JSON API alongside the session-based web app

Screenshots

Homepage

Chat

Profile, follow and post detail

User Profile Page

Follow

Post details

Key features

  • Posts β€” image upload through Active Storage with named variants, #hashtags parsed out of the description on create, comments and six emoji reactions, and an infinite-scroll feed
  • Chat β€” one-to-one conversations with live delivery, unread badges and online presence
  • Follow β€” a self-join social graph with counter-cached totals and live button and count updates
  • Search and Explore β€” Elasticsearch across post descriptions and hashtags plus username matching; /explore surfaces posts from people you don't follow yet
  • JSON API β€” a token-authenticated /api/v1 surface: machine credentials exchanged for a short-lived JWT, both entry points rate-limited
  • Event log β€” key domain events (posts, comments, reactions, follows, messages, profile updates) written asynchronously to an audit table

The two features worth reading the code for:

Chat

One-to-one messaging with live delivery. A sent message reaches the other browser immediately β€” the unread badge ticks up and the thread jumps to the top of their inbox, wherever they are in the app. A dot on each avatar shows who is online.

  • One thread per pair. Conversation.participants_key_for sorts the two user ids into a participants_key carrying a unique index, so Conversations::FindOrCreate can never open a second thread for the same two people.
  • One service owns the write. Messages::Create saves the message, updates the conversation's last-message columns and adjusts unread counts in a single transaction, then broadcasts once it commits.
  • Two broadcasts, two audiences. ConversationChannel pushes rendered HTML to whoever has the thread open; InboxChannel pushes JSON so badges and inbox rows update anywhere.
  • Presence needs no extra table. PresenceChannel touches users.last_seen_at on a timer, with HEARTBEAT_INTERVAL derived as ONLINE_WINDOW / 2 so nobody flickers offline between pings.

Threads are 1:1 by construction β€” no group chats, typing indicators or attachments.

Follow

Follow and unfollow from a profile, a post header, the people results in search, or the suggestions rail. Counts and button state update without a reload, in every tab you have open at once.

  • Counts are counter caches, not COUNT(*) β€” users.followers_count and users.following_count, maintained by Follow's two counter_cache declarations.
  • The database rejects duplicates and self-follows β€” a unique index on [follower_id, followed_id] and a follows_no_self_follow check constraint sit behind the model validations, so Follows::Create stays idempotent under a double click.
  • Two broadcast streams. Follows::BroadcastCounts replaces the count partials on both profiles; Follows::BroadcastButton replaces every follow button on the actor's own stream, so one click flips them all.
  • Discovery reads the graph. User.suggested_for fills the suggestions rail and Post.discoverable_for fills /explore, both by excluding people you already follow.

The home feed is deliberately not follow-filtered β€” it stays global and reverse-chronological, and follow state only decides whether a post header offers a Follow button. Following someone sends no notification; it writes an EventLog row.

Architecture

Standard Rails MVC. User and Post are each split into concerns under app/models/user/ and app/models/post/ rather than growing into god objects, and multi-step writes live in app/services/ instead of controllers or model callbacks.

Domain model β€” Devise-authenticated, PostgreSQL-backed:

  • User β†’ has many posts, an avatar via Active Storage; behaviour split across Followable, Conversable, Avatarable and Presenceable
  • Post β†’ belongs to a user, one attached image, auto-extracted #hashtag associations, indexed into Elasticsearch on commit; behaviour split across Imageable, HashTaggable and Searchable
  • Comment and Reaction (polymorphic, emoji-style: like/love/haha/wow/sad/angry) attach to posts
  • Follow β†’ the social graph, a self-join across users with a counter cache on each side
  • Conversation / ConversationParticipant / Message β†’ 1:1 direct messaging, with a per-participant unread count
  • HashTag / PostHashTag β†’ many-to-many tagging, populated from post descriptions
  • EventLog β†’ a lightweight audit trail of key domain events (post created/destroyed, profile updated, comment/reaction/follow created, message sent), written asynchronously

Real-time β€” four Action Cable channels, each authenticated from the Devise session: PostChannel (reaction and comment counts), ConversationChannel (messages in an open thread), InboxChannel (unread badges and inbox rows) and PresenceChannel (online status). Follows, comments and reactions additionally broadcast declaratively through Turbo::StreamsChannel, so the app runs both a hand-written and a declarative real-time path on purpose.

JSON API (/api/v1) β€” a separate, token-authenticated surface alongside the session-based web app:

  • POST /api/v1/clients β€” verifies an email and password, then issues machine credentials (client_id / client_secret, stored with has_secure_password)
  • POST /api/v1/oauth β€” exchanges those credentials for a short-lived JWT (1h) via a client-credentials-style flow
  • Api::V1::PostsController β€” exposes posts (index/show/create/destroy) to authenticated API clients, scoped to the token's own user
  • Both unauthenticated endpoints are throttled with Rails 8's native rate_limit

Getting Started

Prerequisites β€” Ruby 3.3.11 (managed with RVM; .ruby-version and .ruby-gemset are committed), PostgreSQL, Redis, ImageMagick, and an Elasticsearch 8 node.

bundle install

# Services. docker-compose.yml defines Elasticsearch only β€”
# Postgres and Redis are expected on the host.
docker compose up -d                       # Elasticsearch on localhost:9200
brew services start postgresql@14 redis    # or however you prefer to run them

bin/rails db:create db:migrate
bin/rails db:seed                 # sample users and posts; prints the generated password
bin/rails elasticsearch:reindex   # create the Post index and backfill

bundle exec sidekiq               # second shell: search indexing and event logging
bin/rails server                  # http://localhost:3000

Redis is not optional in development β€” it backs the cache store, the Sidekiq queue and Action Cable, so chat, presence and live counts all need it. Any Elasticsearch 8 node will do; the app reads ELASTICSEARCH_URL (default http://localhost:9200), REDIS_URL (Sidekiq and Action Cable, default DB 1) and REDIS_CACHE_URL (the cache store, default DB 2, kept separate so cache keys can't collide with queue data).

Seeds are split so either half can run on its own β€” bin/rails db:seed:users and bin/rails db:seed:posts. Sample avatars and post images live under db/seeds/.

Run the test suite with bin/rails test, and the browser tests with bin/rails test:system (headless by default; HEADED=1 opens a real Chrome window).

Releases

Packages

Used by

Contributors

Languages