Mocache - Mastodon Object Cache with multi-node read-only status panel https://mocache.de
  • Python 85%
  • Shell 6.2%
  • HTML 6.1%
  • Dockerfile 1.9%
  • Makefile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-02 21:17:50 +00:00
bin [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
deploy [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
panel [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
router [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
site Simplify project site typography 2026-11-02 21:17:50 +00:00
tests [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
.dockerignore [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
.gitignore [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
Dockerfile [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
LICENSE [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
Makefile [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00
README.md [verified] feat: introduce Mocache media cache 2026-11-01 18:20:17 +00:00

Mocache

Mocache = Mastodon Object Cache. A small, self-contained nginx cache for public, immutable Mastodon media on slow S3-compatible storage, with an optional read-only multi-node status panel and a downloadable nginx snippet.

It sits between clients and the object-storage origin and is designed for storage backends that answer many small sequential range requests slowly.

It is an HTTP cache only. It does not run Mastodon's media cleanup and does not delete objects from S3.

Why this exists

Browser video players often fetch media in many small ranges. Some S3-compatible backends have high per-request latency even when full-file downloads are fast. This cache:

  • strips the client Range header before contacting the origin;
  • strips the client Cookie header before contacting the origin, so no session state leaks to the object store;
  • downloads the complete object once;
  • continues filling the cache when the client disconnects;
  • answers subsequent full-file and range requests from the cache, including HTTP 206, with proxy_force_ranges even when the origin omits Accept-Ranges;
  • coalesces concurrent requests for the same object with a cache lock;
  • serves stale content if the origin has a temporary error or timeout.

Browser caching

Successful responses carry exactly one header, Cache-Control: public, max-age=31536000, immutable — a one-year browser TTL for immutable media. The header is deliberately sent without always, so intercepted origin error responses never inherit a year-long client TTL.

Retention defaults

  • CACHE_INACTIVE=24h: an object that has not been requested for 24 hours is removed from the nginx cache.
  • CACHE_VALID=48h: a cached HTTP 200 is considered fresh for 48 hours. The shorter inactivity timeout still applies.
  • CACHE_MAX_SIZE=20g: nginx evicts least-recently-used objects when this limit is reached, even before the time limits.

A later request simply fetches the object from S3 again. The S3 original is untouched.

Quick start with Docker Compose

cp deploy/.env.example deploy/.env
# Edit deploy/.env and set UPSTREAM_ORIGIN.
docker compose -f deploy/docker-compose.yml up --build -d

Example origin with a bucket path:

UPSTREAM_ORIGIN=https://s3.example.com/mastodon-media/

The cache listens on 8080. Put your normal TLS reverse proxy or ingress in front of it.

Check the service:

curl http://127.0.0.1:8080/healthz
curl -H 'Range: bytes=0-1048575' -D - -o /dev/null \
  http://127.0.0.1:8080/media_attachments/example.mp4

X-Mocache shows MISS, HIT, STALE or another nginx cache state. X-Mocache-Node identifies the serving cache host or pod.

Status panel and nginx snippet

The Compose example also starts a single-node read-only agent on 127.0.0.1:8081. It shows HIT/MISS/STALE counts, hit ratio, bytes served, current cache files and disk usage, and upstream latency. It exposes no object paths, credentials, query strings or purge actions.

Open http://127.0.0.1:8081/ for the local panel. The public project page is https://mocache.de; source and the standalone nginx configuration are in the public repository at https://git.nul.lv/nul/mocache. The snippet remains in this repository as deploy/nginx-snippet.conf and is also available at https://mocache.de/mocache.conf.

For multiple cache nodes, run the panel component with MOCACHE_MODE=agent beside each nginx instance. Point the aggregating panel at them with a comma-separated MOCACHE_AGENT_URLS list, or set MOCACHE_AGENT_DNS to a headless Kubernetes Service whose A records are the agent pods. Keep agents and aggregators on an internal network by default; publish them only as an explicit operator decision. The panel aggregates the recent log window; it is not a historical metrics database.

Connect Mastodon

Keep S3_ENDPOINT, bucket credentials and all S3 API operations pointed at the real object storage. Only set the public media alias to this cache:

S3_ALIAS_HOST=media.example.com

With S3_ALIAS_HOST, Mastodon omits the bucket name from generated URLs. Therefore the reverse proxy must add the bucket path through UPSTREAM_ORIGIN, or you must use a bucket-specific hostname.

Apply the environment change according to your Mastodon installation. For the traditional systemd setup, Mastodon's documentation recommends restarting mastodon-sidekiq and reloading mastodon-web.

Official background:

Configuration

Variable Default Purpose
UPSTREAM_ORIGIN required Literal HTTP(S) origin, optionally including a bucket path
LISTEN_PORT 8080 HTTP listen port inside the container
SERVER_NAME _ nginx server name
CACHE_PATH /var/cache/nginx/media Cache directory; mount persistent storage here if desired
CACHE_MAX_SIZE 20g Maximum cache size before LRU eviction
CACHE_KEYS_ZONE_SIZE 32m Shared-memory zone for cache keys
CACHE_INACTIVE 24h Remove objects not accessed for this duration
CACHE_VALID 48h Freshness lifetime for successful origin responses
CACHE_LOCK_TIMEOUT 30s Wait for another request filling the same object
UPSTREAM_CONNECT_TIMEOUT 3s Origin connect timeout
UPSTREAM_READ_TIMEOUT 60s Origin read timeout

Times accept seconds, minutes, hours or days (s, m, h, d). Sizes accept k, m or g.

Kubernetes

deploy/kubernetes.yaml contains a Deployment and Service example, including the read-only agent sidecar on port 8081. The public cache Service exposes only port 80; agents are reachable through the separate headless, internal-only mocache-agents Service. Replace the versioned images and origin before applying it. The example uses emptyDir; use a volume appropriate for your availability and persistence requirements.

The container runs with allowPrivilegeEscalation: false (the no-new-privileges flag) and the RuntimeDefault seccomp profile. It intentionally runs as root with a writable filesystem because the entrypoint must create/chown the cache directory and nginx privilege-drops its workers itself — do not add runAsNonRoot or readOnlyRootFilesystem. CPU and memory requests/limits are set; adjust them to your traffic.

Each replica has its own cache. This is not a shared cluster-wide cache.

Multi-node routing without Traefik

deploy/kubernetes-ha.yaml adds Mocache's own stateless router (router/Dockerfile) in front of two stable cache nodes. The router uses nginx hash $uri consistent: the same media URI normally goes to the same node, so the object is fetched from S3 and cached only once. Different URIs are distributed across both nodes. No shared RWX cache volume and no ingress-specific hashing feature is required; any HTTP ingress only needs to forward to the public router Service.

If a node fails, its URIs are remapped to the surviving node and refilled once. The included HA integration test proves URI pinning, one origin fetch across repeated requests, distribution across both nodes and failover with a subsequent HIT.

Security and scope

Use this only for public immutable media. It intentionally ignores query parameters in the cache key and removes client authorization before contacting the origin. Do not place it in front of private objects, signed download URLs or exports that require authorization.

Additional safeguards:

  • only GET and HEAD are proxied;
  • the root path returns 404;
  • origin cookies and common S3 request/encryption metadata are not forwarded to clients;
  • origin Cache-Control and Expires are ignored for cacheability (including no-store) and replaced with the configured public media policy;
  • origin errors are sanitized: upstream 4xx responses (400/401/403/404/410) collapse to a generic 404 and 5xx responses (500/502/503/504) to a generic 502, so S3 XML error documents and request IDs never reach clients;
  • Authorization and client Cookie headers are never forwarded;
  • responses include CORS, nosniff and a restrictive content security policy;
  • access logs use the method and path without the query string;
  • origin values containing credentials, query strings, fragments or config delimiters are rejected.

TLS is deliberately left to the surrounding reverse proxy or ingress.

Tests

Unit tests need Python 3, PyYAML and POSIX shell tools:

make test

The integration test builds the container, starts a fake slow-S3-style origin and proves the real nginx behavior: range 206, MISS then HIT, one origin request across repeated downloads, query-independent cache hits, HEAD (including HEAD-first on a fresh object), exactly one Cache-Control header, no client Cookie reaching the origin, sanitized origin error responses (no S3 XML/details), method filtering and security headers.

make test-integration
make test-panel-integration
make test-site-integration
make test-ha-integration

The panel integration test builds the panel image, starts two agent containers and one aggregator, and verifies multi-node totals, cache disk metrics, the HTML page, read-only method handling, the project-site link and removal of the old snippet route. The site integration test serves the actual static project page and verifies the repository link, snippet download and security headers.

Set CONTAINER_RUNTIME=docker if you use Docker instead of Podman. CONTAINER_EXTRA_ARGS can pass additional runtime arguments; on hosts whose AppArmor profile denies nginx socketpair(), use CONTAINER_EXTRA_ARGS=--security-opt=apparmor=unconfined for the local test only.

Operational notes

  • Keep the volume at least as large as CACHE_MAX_SIZE.
  • Cache files disappearing after CACHE_INACTIVE is expected and does not indicate S3 data loss.
  • If the origin URL changes, use a fresh cache volume or clear the old cache to avoid retaining obsolete objects.
  • A MISS followed by a HIT for the same object is the normal proof that the cache is working.
  • For existing Mastodon media retention jobs, treat their database/S3 cleanup policy as a separate topic. This project only manages its local nginx cache.