My Mastodon Server Compose files
  • Shell 86.5%
  • Ruby 13.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-15 13:57:56 +00:00
backup allow single project backup 2026-08-04 08:26:14 +01:00
caddy Add caddy so we route mastodon directly to the node from internal networks 2026-06-16 07:21:24 +00:00
diun remove pushover notification for diun 2026-07-23 06:53:06 +00:00
fedifetcher try longer backfill 2026-08-07 07:56:19 +00:00
mastodon update to v4.7.2 2026-09-15 13:57:56 +00:00
node-exporter basic node exporter 2026-07-03 08:41:20 +00:00
.gitignore simplify config of backup script 2026-06-25 11:00:11 +00:00
compose-autostart.service create an autostart service 2026-08-10 19:38:30 +01:00
compose-autostart.sh update links for tidyness 2026-08-10 21:02:53 +01:00
docker-tailscale-routing.service add note regarding routing table 2026-06-18 09:43:55 +00:00
README.md typo fix 2026-08-10 21:04:45 +01:00
update-mastodon.sh perform backup sooner, so user does not have to wait for pull 2026-06-25 11:55:27 +00:00

mastodon-compose

Docker Compose files for my Mastodon instance.

Contents

Rate limiting and client IPs

See Exempting an IP from Mastodons rate limits for a description of this.

Mastodon exposes no configuration for its rate limits, and config/initializers/rack_attack.rb ships no safelist. rack_attack_safelist.rb is mounted into the web container as config/initializers/zz_rack_attack_safelist.rb (the zz_ prefix makes it load last) and adds three things:

  • A rack_attack notification subscriber logging any throttle or blocklist match with the IP. Quiet unless something matches, so it stays enabled.
  • A probe behind RACK_ATTACK_PROBE=true that logs the client IP of every API request without exempting anything. Noisy — one line per request; only turn it on to discover an IP.
  • The safelist itself, driven by RACK_ATTACK_SAFELIST_IPS (comma-separated IPs or CIDRs). A Rack::Attack safelist short-circuits every throttle, which matters because the API throttles are keyed on the user and token rather than the IP.

FediFetcher is currently the only exemption. Its container IP is pinned to 172.31.240.2 in fedifetcher/docker-compose.yml via an explicit subnet, because a Docker-assigned address drifts across recreates. Both files must be changed together.

Dependency on the edge proxy

Public traffic reaches Mastodon as Cloudflare → Caddy on the Oracle VPS over Tailscale, bypassing caddy here (which only serves host-local and tailnet requests).

That edge Caddyfile is not in this repo, and TRUSTED_PROXY_IP in mastodon/docker-compose.yml depends on it. Its mstdn.thms.uk block must set the client IP explicitly on both handlers:

reverse_proxy <ip-address>:3000 {
    header_up X-Forwarded-For {client_ip}
}

{client_ip} is derived from CF-Connecting-IP under trusted_proxies_strict, so this replaces the whole forwarded chain with one verified entry. Without it, Cloudflare's edge IP lands at the end of X-Forwarded-For, and since Cloudflare's ranges are not in TRUSTED_PROXY_IP, Mastodon treats that as the client: per-IP throttles get bucketed per Cloudflare edge, IpBlock blocks Cloudflare instead of the offender, and the admin UI shows Cloudflare IPs for every account.

Overwriting the header is also what makes safelisting a private address safe — a client cannot forge X-Forwarded-For past the edge. If the edge ever stops setting this, revisit the safelist.

The local Caddy in caddy does not overwrite the header; it appends the peer address. ActionDispatch::RemoteIp takes the rightmost entry not matched by TRUSTED_PROXY_IP, so the appended address beats anything the client prepended, and a forger only names itself.

A client reaching Puma directly on 3000 bypasses Caddy and controls the whole chain, but that's blocked by the firewall.

To verify after any change to this path, set RACK_ATTACK_PROBE=true and confirm remote_ip is the real client address rather than a Cloudflare range.

Routing service

For a Headscale exit node to work, you need an extra entry in the routing table.

The docker-tailscale-routing.service unit handles this. (See also Using a Tailscale exit node on a Docker host .)

To enable it:

sudo ln -s <absolute-directory>/docker-tailscale-routing.service /etc/systemd/system/docker-tailscale-routing.service
sudo systemctl daemon-reload
sudo systemctl enable --now docker-tailscale-routing.service

Boot-time recovery

restart: always only covers containers that fail after they have started. If dockerd cannot start them at all, nothing retries and the stacks simply stay down — which is what happened on 2026-08-10, when it could not load its AppArmor profile after a host reboot.

compose-autostart.sh and compose-autostart.service bring every stack up at boot and keep retrying until they start. (See also When restart: always isnt enough: making Compose stacks recover at boot .)

It runs only at boot, never on a timer: backup.sh stops each stack while it snapshots it, and a scheduled reconciler would restart them mid-backup.

To enable it:

sudo ln -s <absolute-directory>/compose-autostart.sh /usr/local/sbin/compose-autostart
sudo ln -s <absolute-directory>/compose-autostart.service /etc/systemd/system/compose-autostart.service
sudo systemctl daemon-reload
sudo systemctl enable compose-autostart.service

Both symlinks are needed: the unit invokes /usr/local/sbin/compose-autostart, and the script resolves that symlink to work out which directory to scan.