> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate limiting Rails apps when you can't put Varnish in front

> When the edge isn't yours, rate limit from inside the application. How rack-attack works, why limits are about quality of service, and what Rails 8 changed.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const linkedin = entry.linkedin || null;
    const url = github ? `https://github.com/${github}` : linkedin || null;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} target="_blank" rel="noopener noreferrer" className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["laurent-arnoud"], date: "2026-05-12T09:00:00.000Z" }} />

A few years ago, I worked on a Rails app deployed to Heroku. We had public APIs handling device activation pings from Apple and Google. The kind of traffic where every phone in the wild could hit you at any moment, with no authentication, only a key per device.

It worked fine until it didn't. Some endpoints started getting hammered, and we needed a way to stop the abuse before it took everyone down.

The natural answer would have been to drop a reverse proxy in front of Rails and rate limit there. The [previous article in this series](/posts/hands-on/varnish-102-protecting-your-application-with-rate-limiting-on-upsun) shows exactly how to do that with Varnish and `vsthrottle`. But on Heroku, you don't get that layer. You get a router, a dyno, and your app. That's it.

We did the next best thing: we rate limited inside the application.

## Rack middleware: rate limiting before Rails wakes up

The tool we picked was [rack-attack](https://github.com/rack/rack-attack), a Rack middleware that runs ahead of your controllers, your views, and most of the Rails request lifecycle.

Rack middleware sits at the bottom of the stack. A request comes in, hits the middleware chain, and rack-attack decides whether to let it through or return a 429 immediately. The controller never runs. Your business logic stays untouched.

## What rack-attack actually loads

Rack-attack runs early, but it doesn't run in a vacuum. By the time the middleware kicks in, your Rails app is already booted. The database connection pool is alive. Models are loaded. Redis is connected.

When rate limiting blocks a request, you're not saving an entire process boot. You're saving the controller, the queries, the rendering, and whatever else the request would have triggered. On a Ruby app where each worker process serves multiple requests through fibers, that still adds up. CPU stops getting burned on requests you didn't want to serve. Memory pressure stays predictable.

It's not as cheap as filtering at the edge, but it's a lot cheaper than letting the request reach the database.

## Configuration is plain Ruby

Rack-attack configures via Ruby blocks. You give it a name, a key extractor, a limit, and a window. Anything you can read from the request can become the key.

```ruby theme={null}
Rack::Attack.throttle("api/ip", limit: 10, period: 1.second) do |req|
  req.ip if req.path.start_with?("/api/")
end
```

That's the entire pattern. Want to limit by IP? Use `req.ip`. By user? Read the auth header. By a combination? Concatenate and hash. The block has access to the full request object, and the rules can be as expressive as you need.

The trade-off is that any change requires a deploy. There's no live config. For a small set of stable rules, that's fine.

## Where you store the counters

Rack-attack needs somewhere to track how many requests each key has made. The two main options are an in-process memory cache or [Redis](https://docs.upsun.com/add-services/redis). Both are valid. They optimize for different things.

In-memory is the simplest path. No extra service to run, no network hop, counters live next to the code that reads them. It works well when you have a single process, or when each process serves a stable, well-defined slice of traffic. The catch is that each process keeps its own counter. Two dynos means two counters, and your effective limit doubles. Restart a process and the counters reset, which gives a fresh window to anyone patient enough to wait it out.

Redis flips that trade-off. The counters live in one place, shared across every process, and they survive restarts. The price is the dependency: you need Redis reachable, and you pay a small network hop on every request the limiter inspects. We used Redis on the project I described, and never had a performance issue with it.

Pick based on what you're actually defending against. Soft caps for fairness on a single-instance app? In-memory is enough. Hard caps that need to hold across deploys, scale-outs, and restarts? Reach for Redis.

## What we actually capped, and why

The point of capping isn't always to block attackers. Often it's about quality of service.

Your application has finite CPU. Every concurrent request takes a slice of it, and once the slices run out, every request slows down for everyone. Without rate limiting, one greedy client can monopolize that CPU and degrade the experience for the rest of your traffic. With rate limiting, that client gets pushed back to a fair share, and the other users keep getting fast responses.

For us, the CPU pressure came from abusive clients hidden among the legitimate Apple and Google device activation requests. We capped the noisiest endpoints at around 10 requests per second per IP. Higher than what a real device would ever need, low enough that no single client could starve the rest of the stack.

Downtime from these endpoints stopped. The other endpoints kept working through traffic spikes. Latency at the application level held steady. None of it was free, but it was the cheapest thing we could ship without owning the edge.

## What about authenticated traffic?

This is the case where edge rate limiting starts to struggle. Varnish doesn't know who your users are. It can throttle by IP and by URL, but if you want to throttle by authenticated user, you need session data, token lookups, or sometimes a database query. That's the application's job.

GitHub does this. Hit their API unauthenticated and you get 60 requests per hour. Authenticate and you get 5,000. That kind of tiered limit is hard to do at the edge without leaking auth state outside your application.

If you're building anything where the rate limit depends on who the user is, you want this layer.

## Rails 8 ships this out of the box

Rack-attack is still maintained, and it still works. If you're on Rails 8, you don't need it. Rails now ships its own rate limiting primitive in the framework, with the same kind of pluggable storage. Memory cache, Redis cache, whatever your `Rails.cache` is set to.

The API is smaller, and it covers most of what rack-attack does:

```ruby theme={null}
class ApiController < ApplicationController
  rate_limit to: 10, within: 1.second
end
```

Starting fresh on Rails 8? Start there. On an older version? Rack-attack is still the right answer.

## Use the layer that fits the constraint

Edge rate limiting and application rate limiting are not competing. They solve different problems.

Varnish is the right tool when you control the edge and the rules don't need to know anything beyond the request. It's faster, cheaper, and stops abuse before it ever reaches your app. The [previous article in this series](/posts/hands-on/varnish-102-protecting-your-application-with-rate-limiting-on-upsun) goes deep on that.

Rack-attack and Rails 8's built-in rate limiting are the right tools when the edge isn't yours, or when your rules need application context. Authenticated users, account tiers, business rules baked into the rate limiter. That's what they're for.

The good news is that you don't have to pick. On Upsun, you get [Varnish as a managed service](https://docs.upsun.com/add-services/varnish) for the edge, [Redis](https://docs.upsun.com/add-services/redis) for shared counters, and the freedom to layer application-level limits on top. Coarse rules at the edge for the worst offenders, fine-grained rules in Rails for the cases that need user context. If your platform doesn't give you the edge, do it in the application and move on.
