---
title: Rate Limiting, IP Allowlists & a Request Audit Trail
slug: rate-limiting-and-ip-controls
category: Auth
summary: Rack::Attack throttles for login brute-force and request floods, a trusted-IP safelist, an optional country allowlist, and a UserEvent audit log that records who did what from which IP. Includes the two silent failures that make throttling do nothing at all.
tags: [security, rack-attack, rate-limiting, ip, audit-log, throttle, brute-force]
status: stable
visibility: public
source_project: lohman.llamapress.ai
layers: [model, controller, sql]
related:
  - title: Two-Factor Authentication (TOTP) with Remember-This-Device
    url: /cookbook/two-factor-authentication-totp
    summary: The sibling security recipe — a second factor on the login endpoint this guide throttles.
  - title: Rack::Attack
    url: https://github.com/rack/rack-attack
    summary: Upstream docs for throttles, blocklists, safelists and Fail2Ban.
---

# Rate Limiting, IP Allowlists & a Request Audit Trail

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Two halves of the same job: **stop abusive traffic**, and **know afterwards what
happened**. Rack::Attack throttles login brute-force and request floods and bans scanners
at the middleware layer, before Rails ever loads a controller. A `UserEvent` audit trail
records every state-changing request with the user, the IP, the path and the parameters,
and puts it behind an admin screen you can filter.

**Read the Gotchas before you trust any of it.** This pattern has two failure modes that
leave a perfect-looking configuration file doing absolutely nothing, with no error
anywhere. Both were found live on a real customer box that believed it was rate limited.

> **When to use:** any app with a public login page — which is every app with a login
> page. Also whenever a customer asks "who changed this record?" or a security reviewer
> asks about brute-force protection.
> **When not to:** an app behind a corporate VPN with no public surface, where the
> audit trail alone is the useful half.

---

## The 80/20 in one breath

1. Write a `Rack::Attack` config: throttle logins per IP **and** per email, throttle
   overall requests per IP, safelist localhost and any trusted office IPs.
2. **Point `Rack::Attack.cache.store` at Redis explicitly.** The default is `Rails.cache`,
   which is a `NullStore` on a Leo box — counters vanish and every throttle no-ops.
3. **Make sure the file actually loads.** On a Leo box, most of
   `config/initializers/` is not mounted into the container. Verify with a runner, never
   by reading the file on disk.
4. Add a `user_events` table plus a `UserEvents::Tracker` that never raises, and call it
   from an `after_action` for write verbs only.
5. Add an admin index over `user_events` with filters for user, event, date and
   controller.
6. Optionally add a country allowlist driven by a CDN header, defaulting to fail-open.

---

## Layer 1 — Rack::Attack

```ruby
# config/initializers/rack_attack.rb
# frozen_string_literal: true

# Guard so the file is harmless if the gem is ever absent.
return unless defined?(Rack::Attack) && Rack::Attack.respond_to?(:throttle)

require "ipaddr"

class Rack::Attack
  ### Cache store — READ THIS BEFORE ANYTHING ELSE ############################
  # Throttle and Fail2Ban counters MUST live in a shared, persistent store.
  # Rack::Attack defaults to Rails.cache, which on a Leo box is NullStore:
  # every increment is discarded, every request looks like the first one, and
  # NOTHING below has any effect. There is no warning. Point it at Redis.
  begin
    # pool: false is required on this image — ActiveSupport 7.2's pooled
    # RedisCacheStore construction is incompatible with connection_pool 3.x and
    # raises "wrong number of arguments (given 1, expected 0)" at boot.
    self.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
      url: ENV.fetch("REDIS_URL", "redis://redis:6379/1"),
      pool: false
    )
  rescue => e
    # Last-resort boot guard. MemoryStore counts per Puma process, so limits
    # are effectively multiplied by your worker count — degraded, not off.
    Rails.logger.error("[Rack::Attack] Redis cache store init failed " \
                       "(#{e.class}: #{e.message}); falling back to MemoryStore.")
    self.cache.store = ActiveSupport::Cache::MemoryStore.new
  end

  ### Safelists — evaluated FIRST; they beat throttles AND blocklists #########
  # Without this, your own health checks and any in-container curl can ban you.
  safelist("allow-localhost") { |req| ["127.0.0.1", "::1"].include?(req.ip) }

  # Trusted office / VPN / partner addresses. Use CIDR ranges, not bare strings,
  # so a customer on a /24 doesn't get half their staff throttled.
  SAFE_RANGES = [
    "203.0.113.0/24",   # head office
  ].map { |c| IPAddr.new(c) }

  safelist("allow-trusted-ips") do |req|
    ip = (IPAddr.new(req.ip) rescue nil)   # req.ip can be malformed; never raise here
    ip && SAFE_RANGES.any? { |range| range.include?(ip) }
  end

  ### General per-IP flood ####################################################
  # Exclude assets and websockets: a real page load pulls 60-90 assets, and a
  # long-lived /cable reconnect loop can post hundreds of requests legitimately.
  # Counting those is how you throttle your best customer.
  throttle("req/ip", limit: 300, period: 5.minutes) do |req|
    req.ip unless req.path.start_with?("/assets", "/packs", "/cable")
  end

  ### Login brute-force #######################################################
  # Two throttles, deliberately. Per-IP stops one machine guessing many
  # passwords; per-email stops a botnet spreading guesses for ONE account across
  # many IPs, which the per-IP rule cannot see.
  throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
    req.ip if req.path == "/users/sign_in" && req.post?
  end

  throttle("logins/email", limit: 5, period: 20.seconds) do |req|
    if req.path == "/users/sign_in" && req.post?
      # Normalize, or "Alice@x.com " and "alice@x.com" count as two accounts
      # and the attacker gets double the attempts for free.
      req.params.dig("user", "email").to_s.downcase.gsub(/\s+/, "").presence
    end
  end

  ### Password-reset flooding (an email-bomb vector, not just a login one) ####
  throttle("password_resets/ip", limit: 5, period: 1.minute) do |req|
    req.ip if req.path == "/users/password" && req.post?
  end

  ### Auto-ban vulnerability scanners #########################################
  # 2 hits on a known recon path within 10 minutes => 1-hour 403 for that IP.
  # Matches recon paths only, so it can never catch a real app route.
  SCANNER_PATHS = %r{
    \.(php|aspx?|env|git|sql|bak|old|ini|sh)\b
    | /(wp-admin|wp-login|xmlrpc|adminer|phpmyadmin|phpinfo|\.env|\.git|\.aws)
  }xi

  blocklist("fail2ban/scanners") do |req|
    Rack::Attack::Fail2Ban.filter("scanner-#{req.ip}", maxretry: 2, findtime: 10.minutes, bantime: 1.hour) do
      SCANNER_PATHS.match?(req.path)
    end
  end

  ### Responses ###############################################################
  # Retry-After is not decoration: well-behaved clients honour it, and its
  # absence turns a throttle into an unexplained failure for legitimate users.
  self.throttled_responder = lambda do |request|
    md = request.env["rack.attack.match_data"] || {}
    retry_after = (md[:period].to_i - (md[:epoch_time].to_i % md[:period].to_i)) rescue 60
    [429,
     { "Content-Type" => "text/plain", "Retry-After" => retry_after.to_s },
     ["Rate limit exceeded. Please retry later.\n"]]
  end

  self.blocklisted_responder = lambda do |_request|
    [403, { "Content-Type" => "text/plain" }, ["Forbidden\n"]]
  end
end

### Log every match, or you are tuning blind ##################################
# Without this you cannot tell "no attacks" from "no rules loaded" — the two
# look identical. Grep the container logs for "[Rack::Attack]".
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload|
  req = payload[:request]
  next unless req && %i[throttle blocklist].include?(req.env["rack.attack.match_type"])
  Rails.logger.warn(
    "[Rack::Attack] #{req.env['rack.attack.match_type']} " \
    "rule=#{req.env['rack.attack.matched']} ip=#{req.ip} " \
    "#{req.request_method} #{req.path}"
  )
end

Rails.application.config.middleware.use Rack::Attack
```

### Verify it is actually live — this is not optional

The middleware being present proves nothing: the gem's railtie inserts
`Rack::Attack` into the stack whether or not your rules ever loaded.

```bash
docker compose exec -T llamapress bin/rails runner '
  puts "cache store: #{Rack::Attack.cache.store.class}"
  puts "throttles:   #{Rack::Attack.throttles.keys.inspect}"
  puts "safelists:   #{Rack::Attack.safelists.keys.inspect}"
  puts "blocklists:  #{Rack::Attack.blocklists.keys.inspect}"
'
```

Empty arrays, or `NullStore`, mean you have **zero** protection. Then prove it end to end
by tripping a rule:

```bash
for i in $(seq 1 12); do
  curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/users/sign_in \
    -d "user[email]=throttle-test@example.com&user[password]=wrong"
done; echo
# Expect the tail of that line to become 429.
```

---

## Layer 2 — Country allowlist (optional)

A blunt instrument for customers who say "only our country should be able to reach this".

```ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  ALLOWED_COUNTRIES = %w[US AU DE].freeze

  # Headers CDNs and proxies use to report the visitor's 2-letter country.
  # CF-IPCountry is Cloudflare's.
  COUNTRY_CODE_HEADERS = %w[
    HTTP_CF_IPCOUNTRY HTTP_X_COUNTRY_CODE HTTP_X_APPENGINE_COUNTRY
  ].freeze

  # Runs BEFORE authentication so a blocked location cannot even see the login
  # page — otherwise you are only hiding the app, not the attack surface.
  before_action :restrict_by_country

  private

  def restrict_by_country
    return unless country_allowlist_enabled?
    country = request_country_code
    # FAIL OPEN. There is no GeoIP database in the image, so the country comes
    # only from an upstream header. If the proxy stops sending it, failing
    # closed locks out every user worldwide, including you.
    return if country.blank?
    return if ALLOWED_COUNTRIES.include?(country)

    render plain: "Access from your location is not permitted.", status: :forbidden
  end

  # Env-gated so the capability can ship dark and be flipped on per environment.
  def country_allowlist_enabled?
    ActiveModel::Type::Boolean.new.cast(ENV["ENABLE_COUNTRY_ALLOWLIST"])
  end

  def request_country_code
    COUNTRY_CODE_HEADERS.each do |header|
      value = request.headers[header]
      return value.to_s.strip.upcase if value.present?
    end
    nil
  end
end
```

---

## Layer 3 — The audit trail: model & SQL

```ruby
# db/migrate/20260101000000_create_user_events.rb
class CreateUserEvents < ActiveRecord::Migration[7.2]
  def change
    create_table :user_events do |t|
      t.bigint :user_id            # nullable on purpose: anonymous events matter too
      t.string :session_id
      t.string :event_name, null: false
      t.string :source, default: "server", null: false
      t.string :controller_name
      t.string :action_name
      t.string :path
      t.string :ip_address
      t.text   :user_agent
      t.text   :referer
      t.jsonb  :metadata, default: {}, null: false
      t.text   :admin_notes        # lets an admin annotate a suspicious event
      t.timestamps
    end

    add_index :user_events, :user_id
    add_index :user_events, :created_at
    add_index :user_events, [:event_name, :created_at]
    add_index :user_events, [:controller_name, :action_name]
    add_index :user_events, :source
  end
end
```

```ruby
# app/models/user_event.rb
class UserEvent < ApplicationRecord
  belongs_to :user, optional: true

  SOURCES = %w[server browser].freeze

  # Only constrains what an UNAUTHENTICATED browser endpoint may write, to stop
  # log-spam injection. Server-side callers may log any event name.
  TRACKABLE_EVENTS = %w[page_viewed record_opened export_clicked].freeze

  validates :event_name, presence: true, length: { maximum: 200 }
  validates :source, inclusion: { in: SOURCES }

  scope :recent,        -> { order(created_at: :desc) }
  scope :by_event,      ->(name)   { where(event_name: name)      if name.present? }
  scope :by_source,     ->(source) { where(source: source)        if source.present? }
  scope :by_controller, ->(name)   { where(controller_name: name) if name.present? }

  # Accepts a user id (all digits) or an email fragment; no-op when blank.
  scope :for_user, ->(value) {
    next all if value.blank?
    if value.to_s.match?(/\A\d+\z/)
      where(user_id: value)
    else
      joins(:user).where("users.email ILIKE ?", "%#{sanitize_sql_like(value.to_s)}%")
    end
  }

  scope :on_or_after,  ->(date) { where("user_events.created_at >= ?", date.beginning_of_day) if date.present? }
  scope :on_or_before, ->(date) { where("user_events.created_at <= ?", date.end_of_day)       if date.present? }
end
```

---

## Layer 4 — The tracker

One rule governs this class: **it must never raise.** A logging failure that breaks a
customer's save is worse than the missing log line.

```ruby
# app/services/user_events/tracker.rb
module UserEvents
  # Server-side entry point for logging events from anywhere — controllers, jobs,
  # services.
  #
  #   UserEvents::Tracker.track("invoice_sent", user: current_user, request: request,
  #                             metadata: { invoice_id: 42 })
  class Tracker
    MAX_METADATA_BYTES = 8_192

    # Matched case-insensitively ANYWHERE in the key. Never persist secrets or
    # credentials into a table that admins browse and support staff screenshot.
    SENSITIVE_KEY_PATTERN = /password|token|secret|api[_-]?key|card|cvv|cvc|ssn|auth|credential|cookie|session/i

    def self.track(event_name, user: nil, source: "server", request: nil, metadata: {}, session_id: nil)
      attrs = {
        event_name: event_name.to_s,
        source:     source.to_s,
        user_id:    user&.id,
        session_id: session_id,
        metadata:   sanitize_metadata(metadata)
      }

      if request
        # remote_ip, NOT request.ip — remote_ip walks X-Forwarded-For and honours
        # trusted proxies, so behind Caddy/Cloudflare you record the real client
        # instead of your own reverse proxy on every single row.
        attrs[:ip_address]      = request.remote_ip
        attrs[:user_agent]      = request.user_agent
        attrs[:referer]         = request.referer
        attrs[:path]            = request.path
        attrs[:controller_name] = request.params[:controller]
        attrs[:action_name]     = request.params[:action]
        attrs[:session_id]    ||= (request.session.id.to_s rescue nil)
      end

      # create, not create! — a validation failure must not raise into the caller.
      UserEvent.create(attrs)
    rescue => e
      Rails.logger.error("[UserEvents::Tracker] #{e.class}: #{e.message}")
      nil
    end

    # Plain hash, string keys, sensitive keys stripped, total size capped.
    def self.sanitize_metadata(metadata)
      hash =
        case metadata
        when ActionController::Parameters then metadata.to_unsafe_h
        when Hash then metadata
        else return {}
        end

      cleaned = hash.each_with_object({}) do |(key, value), acc|
        key = key.to_s
        next if key.match?(SENSITIVE_KEY_PATTERN)
        acc[key] = scrub_value(value)
      end

      # A single file upload or pasted spreadsheet can otherwise write megabytes
      # per row and bloat the table until the admin page times out.
      if cleaned.to_json.bytesize > MAX_METADATA_BYTES
        return { "_truncated" => true, "_note" => "metadata exceeded #{MAX_METADATA_BYTES} bytes and was dropped" }
      end

      cleaned
    rescue => e
      Rails.logger.error("[UserEvents::Tracker] metadata sanitize failed: #{e.class}: #{e.message}")
      {}
    end

    # Recursive, because sensitive keys hide in nested params too.
    def self.scrub_value(value)
      case value
      when Hash
        value.each_with_object({}) do |(k, v), acc|
          k = k.to_s
          next if k.match?(SENSITIVE_KEY_PATTERN)
          acc[k] = scrub_value(v)
        end
      when Array  then value.first(100).map { |v| scrub_value(v) }
      when String then value.length > 2_000 ? value[0, 2_000] : value
      else value
      end
    end
    private_class_method :scrub_value
  end
end
```

---

## Layer 5 — Automatic tracking from ApplicationController

```ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Log every successful data-changing request. Tracking never raises, so it
  # cannot break the request it is recording.
  after_action :track_user_activity

  # Write verbs only. Logging GETs would bury the signal in page views and grow
  # the table by an order of magnitude for no audit value.
  TRACKED_HTTP_METHODS = %w[POST PUT PATCH DELETE].freeze

  # Controllers with their own, more descriptive tracking — excluded to avoid
  # duplicate rows. Add your sign-in controller here and log it explicitly with
  # a real event name instead.
  UNTRACKED_CONTROLLER_PATHS = %w[api/user_events users/sessions].freeze

  private

  def track_user_activity
    return unless TRACKED_HTTP_METHODS.include?(request.request_method)
    # Successful or redirect only. This is what keeps failed validations (422)
    # out of the audit trail — a rejected save did not change anything.
    return unless response.successful? || response.redirect?
    return if UNTRACKED_CONTROLLER_PATHS.include?(controller_path)

    UserEvents::Tracker.track(
      "#{controller_path}##{action_name}",
      user:     (current_user if respond_to?(:current_user)),
      request:  request,
      metadata: activity_metadata
    )
  rescue => e
    Rails.logger.error("[track_user_activity] #{e.class}: #{e.message}")
  end

  def activity_metadata
    meta = {}
    meta[:resource_id] = params[:id] if params[:id].present?
    body = params.except(:controller, :action, :format, :id,
                         :authenticity_token, :_method, :utf8, :commit, :api_token)
    # Sensitive keys are stripped downstream by the Tracker — do not rely on
    # this list alone.
    meta[:params] = body.to_unsafe_h if body.respond_to?(:to_unsafe_h)
    meta
  rescue
    {}
  end
end
```

```ruby
# app/controllers/admin/user_events_controller.rb
# Admin "Activity Logs" — browse, filter and annotate the event log.
class Admin::UserEventsController < Admin::BaseController
  def index
    scope = UserEvent.includes(:user)          # includes(:user) or the index N+1s
                     .by_event(params[:event_name])
                     .by_source(params[:source])
                     .by_controller(params[:controller_name])
                     .for_user(params[:user])
                     .on_or_after(parse_date(params[:start_date]))
                     .on_or_before(parse_date(params[:end_date]))
                     .recent

    @pagy, @events = pagy(scope, limit: 50)
    @event_names = UserEvent.distinct.order(:event_name).pluck(:event_name)
  end

  def show
    @event = UserEvent.find(params[:id])
  end

  private

  def parse_date(value)
    return nil if value.blank?
    Date.parse(value)
  rescue ArgumentError
    nil    # a half-typed date filter must not 500 the page
  end
end
```

---

## Gotchas (the hard-won stuff)

- **🛑 On a Leo box, `config/initializers/` is mostly NOT mounted into the container.**
  Check `docker-compose.yml`: older templates bind-mount only individual initializer
  files (often just `devise.rb` and `llama_bot_rails.rb`). A `rack_attack.rb` you add to
  `rails/config/initializers/` then exists on the host, reads perfectly, is committed,
  and **never loads**. Verified on a real customer box: the file defined seven throttles
  and an IP allowlist, while the running app reported
  `Rack::Attack.throttles == {}` and `safelists == {}`. Confirm the file is inside the
  container before you believe anything:

  ```bash
  docker compose exec -T llamapress ls -la /rails/config/initializers/
  ```

  If it is missing, add the bind-mount to `docker-compose.yml` and recreate the service
  (`docker compose up -d --force-recreate llamapress`). Note that `docker-compose.yml` is
  on the platform update allowlist, so a per-box hand-edit can be overwritten by a later
  update — re-verify after any version push.

- **🛑 `Rails.cache` is a `NullStore` on a Leo box, and Rack::Attack defaults to it.**
  Every counter increment is silently discarded, so no throttle ever reaches its limit,
  no matter how correct the rules are. This is the second independent way to have
  "rate limiting" that does nothing. Set `Rack::Attack.cache.store` to Redis explicitly,
  as in Layer 1, and check `Rack::Attack.cache.store.class` in a runner.

- **Middleware present ≠ rules loaded.** The rack-attack railtie inserts the middleware
  on its own. `Rails.application.middleware` listing `Rack::Attack` tells you nothing
  about whether your config ran. Only `throttles.keys` / `safelists.keys` do.

- **The generic SQL-injection Fail2Ban filter bans real users.** A rule that matches an
  apostrophe in the query string (`/(\%27)|(\')|(\-\-)/`) fires on any legitimate search
  for `O'Brien` or a hyphenated part number, and the third one earns an hour-long 403.
  Prefer a path-based scanner rule (`SCANNER_PATHS` above), which can only match recon
  URLs, and treat the query-string filter as opt-in for apps with no free-text search.

- **Safelist before you throttle, and safelist localhost first.** Safelists beat both
  throttles and blocklists. Skip this and your own health check, uptime monitor, or
  in-container `curl` can trip a ban and take the app down from the inside.

- **Exclude `/assets`, `/packs` and `/cable` from the global per-IP throttle.** One real
  page load pulls 60–90 assets and a websocket reconnect loop can post hundreds of
  requests in minutes. Counting those throttles your most active customer first — they
  generate the most traffic by definition.

- **Throttle logins by IP *and* by email.** They catch different attacks: per-IP stops
  one machine trying many passwords; per-email stops many machines trying one account.
  Normalize the email (downcase, strip whitespace) or the attacker gets a fresh bucket
  per capitalization.

- **`request.remote_ip`, not `request.ip`, in application code.** `remote_ip` walks
  `X-Forwarded-For` and respects trusted proxies. Behind Caddy or Cloudflare, `ip`
  records your own reverse proxy on every row, and an audit trail where every request
  came from `172.18.0.1` is worthless. (Inside a Rack::Attack block, `req.ip` is the
  right call — that is Rack's own already-resolved value.)

- **Fail OPEN on the country check.** There is no GeoIP database in the image, so the
  country comes only from an upstream CDN header. If that proxy is removed or
  reconfigured, failing closed locks out every user on earth, including whoever would
  fix it. Gate the whole feature behind an env var too.

- **Scrub nested params, not just top-level ones.** `user[password]` and
  `payment[card][cvv]` both hide one level down. The recursive `scrub_value` above is the
  reason the audit table is safe to browse; a flat key filter is not.

- **Cap the metadata size.** A file upload or pasted spreadsheet in the params will write
  megabytes into a single `jsonb` column, and a few hundred of those make the admin index
  time out.

- **Log every throttle match.** Without the `ActiveSupport::Notifications` subscriber,
  "we are under no attack" and "our rules never loaded" produce identical evidence:
  silence.

- **Plan for table growth.** `user_events` grows with write traffic forever. Add a
  retention job (`UserEvent.where("created_at < ?", 1.year.ago).delete_all`) before the
  table, not after it hits tens of millions of rows.

---

## Files this pattern touches

```
config/initializers/rack_attack.rb
db/migrate/20260101000000_create_user_events.rb
app/models/user_event.rb
app/services/user_events/tracker.rb
app/controllers/application_controller.rb
app/controllers/admin/user_events_controller.rb
app/views/admin/user_events/index.html.erb
app/views/admin/user_events/show.html.erb
config/routes.rb
docker-compose.yml          # only if the initializer isn't mounted — see Gotchas
```

## How to adapt to your schema

1. **Fix the paths first.** Every throttle keys off a literal path. If you are not on
   stock Devise, replace `/users/sign_in` and `/users/password` with your real routes —
   a typo produces a rule that matches nothing and reports no error. Confirm each one
   with `bin/rails routes | grep sign_in`.
2. **Set the limits from your own traffic.** Query the audit table before guessing:
   `UserEvent.where("created_at > ?", 1.day.ago).group(:ip_address).count.values.max`
   tells you what a busy legitimate user actually does.
3. **Add throttles for anything expensive or public.** Endpoints that send email, call a
   paid API, upload files, or create accounts each deserve their own rule — a global
   per-IP limit is far too loose to protect a per-call cost.
4. **Fill in `SAFE_RANGES`** with the customer's office and VPN CIDR blocks. This is the
   "IP allowlist" most customers mean when they ask for one.
5. **Log domain events, not just controller actions.** The `after_action` gives you
   `projects#update` for free; call `UserEvents::Tracker.track("contract_signed", ...)`
   by hand where the business meaning is what an auditor will look for.
6. **Safe to drop for small apps:** the country allowlist, the Fail2Ban scanner rule, and
   the admin annotation column. **Never drop** the Redis cache store or the login
   throttles — those are the parts doing the actual work.
