Skip to content

Mastodon server Returning 429 Too Many Requests with Mac host

The Problem

My self-hosted Mastodon instance (single user instance just for me) running on a Mac often returned 429 Too Many Requests errors, primarily when using the official iOS app. The server was otherwise healthy — the database, Redis, and Sidekiq were all fine.

I've been lazy about migrating the deployment to my Talos k8s cluster. Looks like I should get around to that sooner, because Docker Desktop and OrbStack on Mac are contributing to the problem due to them NATing the network traffic.

The Setup

  • Host: Mac mini (Apple Silicon)
  • Runtime: Docker Desktop for Mac (later migrated to OrbStack)
  • Reverse proxy: SWAG (Secure Web Application Gateway) container
  • DNS: Cloudflare (DNS-only, proxy enabled/disabled didn't improve the situation)
  • Request flow: iPhone → LAN → Mac mini → SWAG container → Mastodon container

Root Cause: Two Problems Compounding

1. Docker Desktop's NAT Eats Client IPs

Docker Desktop for Mac runs containers inside a Linux VM. All traffic from the host to containers passes through a NAT layer, which replaces the real client IP with an internal Docker network address (e.g. 172.16.16.1).

This means every client on the LAN — phone, laptop, tablet — appears to Mastodon as the same internal IP. SWAG correctly sets X-Forwarded-For with the real client IP, but $remote_addr in the SWAG container is already the Docker NAT IP, not the real client.

Migrating to OrbStack changed the NAT IP from 172.16.16.1 to 192.168.97.10, but the fundamental problem remained — real client IPs were still lost at the container runtime boundary. On the final configuration, Mastodon saw 127.0.0.1 due to its own internal nginx proxying to Puma.

2. Rack::Attack Throttles Per OAuth Token, Not Just Per IP

Mastodon uses Rack::Attack for rate limiting. The throttle that actually fires for API clients is throttle_per_token_api, which is keyed by OAuth access token with a default limit of 300 requests per 5 minutes.

The iOS app uses a single OAuth token and fires bursts of ~20 parallel API requests on launch (timeline, notifications, trending, etc.). Normal usage burns through 300 requests well within the 5-minute window.

This is the critical detail: fixing the IP forwarding would not have solved the problem. The throttle key is the token, not the IP. Even with perfect IP visibility, the same token hitting 300 requests in 5 minutes triggers the limit.

Why It's Worse on Docker for Mac

On a native Linux host with bridged networking, containers see real client IPs directly. The IP-based throttles (throttle_per_ip_api) distribute limits correctly across clients. On Docker Desktop for Mac (and WSL2 on Windows), all clients collapse to one IP, so IP-based throttles stack on top of the per-token throttle, making the effective limits even tighter.

The Fix

For a personal single-user instance, if you must remain on Docker Desktop or OrbStack, the simplest fix is disabling Rack::Attack entirely via a custom container init script:

#!/bin/bash
cat > /app/www/config/initializers/zz_disable_rack_attack.rb << "RUBY"
Rack::Attack.enabled = false
RUBY

This is mounted via the LinuxServer.io custom-cont-init.d volume and persists across container recreations.

For small multi-user instances, the better approach is increasing the throttle_per_token_api limit (e.g. 300 → 1500) rather than disabling entirely.

The Proper Fix: A Real Linux Host

The proper fix is to migrate to a real Linux host. On Linux, containers can use host networking or bridged networking where no VM NAT layer sits between the client and the container. The request chain becomes:

Client (192.168.8.x) → Linux Host → SWAG → Mastodon internal nginx → Puma

The LinuxServer.io Mastodon image runs its own nginx inside the container, which proxies to Puma on localhost. This means Puma always sees 127.0.0.1 as the direct connection source — regardless of the host OS. This is normal and not a problem, because the internal nginx passes the X-Forwarded-For header through to Puma, and Rails uses that header (via the TRUSTED_PROXY_IP environment variable) to determine the real client IP. Since 127.0.0.1 is already in the trusted proxy list, Rails skips it and reads the real IP from the header.

The difference is what's in that header:

  • On Linux: X-Forwarded-For: 192.168.8.x — the real client IP, because SWAG received it directly from the network.
  • On Docker Desktop / OrbStack for Mac: X-Forwarded-For: 172.30.30.x — a useless NAT IP, because the real client IP was already lost before traffic reached SWAG.

With real client IPs flowing through, Rack::Attack's IP-based throttles distribute limits correctly across clients, and you can keep rate limiting enabled with sensible defaults.

Published: 2026-06-07