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:
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