- Python 85%
- Shell 6.2%
- HTML 6.1%
- Dockerfile 1.9%
- Makefile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| deploy | ||
| panel | ||
| router | ||
| site | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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
Rangeheader before contacting the origin; - strips the client
Cookieheader 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, withproxy_force_rangeseven when the origin omitsAccept-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:
- https://docs.joinmastodon.org/admin/optional/object-storage/
- https://docs.joinmastodon.org/admin/optional/object-storage-proxy/
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-ControlandExpiresare ignored for cacheability (includingno-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;
Authorizationand clientCookieheaders are never forwarded;- responses include CORS,
nosniffand 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_INACTIVEis 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
MISSfollowed by aHITfor 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.