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.
This project began as a step-by-step Medium series walking through building it from scratch:
- Build Instagram by Ruby on Rails (Part 1) β π 2K Β· π¬ 11
- Build Instagram by Ruby on Rails (Part 2) β π 628 Β· π¬ 9
- Build Instagram by Ruby on Rails (Part 3) β π 578 Β· π¬ 3
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/v1surface
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 testannotaterbβ schema annotations above each model, with CI failing on drift- CI runs five independent, parallel GitHub Actions jobs on every push to
masterand every pull request targeting it:brakeman,bundler_audit,rubocop,testandsystem_test(Rails' default test glob excludestest/system, so the browser suite needs its own job)
- 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
- Posts β image upload through Active Storage with named variants,
#hashtagsparsed 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;
/exploresurfaces posts from people you don't follow yet - JSON API β a token-authenticated
/api/v1surface: 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:
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_forsorts the two user ids into aparticipants_keycarrying a unique index, soConversations::FindOrCreatecan never open a second thread for the same two people. - One service owns the write.
Messages::Createsaves 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.
ConversationChannelpushes rendered HTML to whoever has the thread open;InboxChannelpushes JSON so badges and inbox rows update anywhere. - Presence needs no extra table.
PresenceChanneltouchesusers.last_seen_aton a timer, withHEARTBEAT_INTERVALderived asONLINE_WINDOW / 2so nobody flickers offline between pings.
Threads are 1:1 by construction β no group chats, typing indicators or attachments.
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_countandusers.following_count, maintained byFollow's twocounter_cachedeclarations. - The database rejects duplicates and self-follows β a unique index on
[follower_id, followed_id]and afollows_no_self_followcheck constraint sit behind the model validations, soFollows::Createstays idempotent under a double click. - Two broadcast streams.
Follows::BroadcastCountsreplaces the count partials on both profiles;Follows::BroadcastButtonreplaces every follow button on the actor's own stream, so one click flips them all. - Discovery reads the graph.
User.suggested_forfills the suggestions rail andPost.discoverable_forfills/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.
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 manyposts, an avatar via Active Storage; behaviour split acrossFollowable,Conversable,AvatarableandPresenceablePostβ belongs to a user, one attached image, auto-extracted#hashtagassociations, indexed into Elasticsearch on commit; behaviour split acrossImageable,HashTaggableandSearchableCommentandReaction(polymorphic, emoji-style: like/love/haha/wow/sad/angry) attach to postsFollowβ the social graph, a self-join acrossuserswith a counter cache on each sideConversation/ConversationParticipant/Messageβ 1:1 direct messaging, with a per-participant unread countHashTag/PostHashTagβ many-to-many tagging, populated from post descriptionsEventLogβ 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 withhas_secure_password)POST /api/v1/oauthβ exchanges those credentials for a short-lived JWT (1h) via a client-credentials-style flowApi::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
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:3000Redis 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).