A caching reverse proxy for raster and vector map tiles. It sits between your web map and
one or more upstream tile providers, stores every tile it fetches on disk, honours per-layer
TTLs, and — the part that matters at three in the morning — keeps serving tiles when the
upstream is down, rate-limiting you, or simply unreachable. Point Leaflet, MapLibre, OpenLayers
or a Folium export at it instead of the provider and your map stops being only as reliable as
someone else's tile server. It runs as a standalone service, and the same engine is importable
as a TileCache class you can mount inside an existing ASGI app. Built alongside the notes at
geo-dashboard.com.
- One endpoint per layer —
GET /{layer}/{z}/{x}/{y}.{ext}, raster or vector. - Disk cache in a plain
layer/z/x/y.exttree, indexed by SQLite (ETag, Last-Modified, fetched-at, size, content type). The tree is a valid static tile directory on its own. - Conditional revalidation — expired tiles are refreshed with
If-None-Match/If-Modified-Since; a 304 resets the TTL without re-downloading a byte. - Stale on error — when the upstream returns 5xx/429 or times out, an expired tile is still
served (within a configurable window) with
X-Tile-Cache: STALE. - Offline fallback — nothing cached and no upstream still yields a valid tile: a generated transparent or "no data" PNG, or an empty vector tile, at a status code you choose.
- Per-layer policy — upstream template with
{s}subdomain rotation, zoom range, bounding box, TTL, stale window, request headers, and an upstream rate limit. - Secrets from the environment —
${API_KEY}interpolation keeps keys out of the config. - LRU eviction against a max-bytes budget, plus
purgeandwarmCLI commands. - Operable —
/healthz, Prometheus text at/metrics, and anX-Tile-Cacheheader on every response saying exactly what happened. - Not an open proxy — upstream hosts come only from the config file; nothing from a client request reaches the outbound URL except three validated integers.
Public tile providers publish usage policies with real request ceilings, and self-hosted tile servers fall over under a dashboard that suddenly gets shared widely. A browser cache does not help: every new visitor starts cold, and a hard refresh throws it away. A CDN helps, but you still need somewhere to decide what is stale, what to do when the origin is unavailable, and how to warm a region before a demo.
This is that somewhere. It is deliberately small — one process, a directory of tiles, and a SQLite index — so you can read all of it, run it next to your map, and reason about what it will do when the upstream misbehaves.
The primary path is clone-and-run; there is no package index step.
git clone https://github.com/geo-dashboard-generation/tile-cache-server.git
cd tile-cache-server
uv venv
uv pip install -r requirements.txt
cp config.example.yaml tilecache.yaml
$EDITOR tilecache.yaml
.venv/bin/python -m tilecache validate -c tilecache.yaml
.venv/bin/python -m tilecache serve -c tilecache.yamlPlain venv works identically:
python3 -m venv .venv && .venv/bin/pip install -r requirements.txtIf you prefer a tilecache command on your PATH, install the clone in editable mode with
uv pip install -e . — but every example below also works as python -m tilecache.
Requires Python 3.11 or newer.
The bundled Dockerfile builds a small runtime image:
docker build -t tile-cache-server .
docker run --rm -p 8080:8080 \
-v "$PWD/tilecache.yaml:/etc/tilecache/tilecache.yaml:ro" \
-v tilecache-data:/var/cache/tilecache \
-e TILE_API_KEY \
tile-cache-server serve -c /etc/tilecache/tilecache.yamlSet cache.directory to /var/cache/tilecache and server.host to 0.0.0.0 in the mounted
config, or the port mapping will not reach the process.
browser tile-cache-server upstream
GET /osm/12/2046/1362.png ───▶ ┌──────────────────┐
│ validate z/x/y │ reject bad coords, zooms,
│ + layer policy │ and tiles outside the bbox
├──────────────────┤
│ SQLite index │
│ fresh? ────────┼──▶ serve from disk HIT
│ expired? ───────┼──▶ If-None-Match ──▶ 304 REVALIDATED
│ ───────┼──▶ 200 MISS
│ upstream down? ─┼──▶ serve expired body STALE
│ nothing at all? ┼──▶ generated blank tile FALLBACK
└──────────────────┘
Each request resolves to exactly one of five outcomes, reported in the X-Tile-Cache header:
| Status | Meaning |
|---|---|
HIT |
Inside the TTL; served from disk without contacting the upstream. |
MISS |
Downloaded from the upstream and stored. |
REVALIDATED |
Expired, but the upstream answered 304; TTL reset, no bytes moved. |
STALE |
Expired and the upstream failed; the old body was served anyway. |
FALLBACK |
Nothing cached and the upstream failed; a generated tile was served. |
A few design decisions worth knowing about:
- Freshness follows RFC 5861's shape.
ttlis how long a tile is served without asking;stale_if_erroris how much further past expiry a tile may be served when the upstream is broken. The two are additive, sottl: 3600, stale_if_error: 86400means "ask hourly, but survive a day-long outage". - Request collapsing. Concurrent requests for the same tile share one upstream fetch, so a cold cache under load does not stampede the provider.
- The index is the source of truth. Tile bodies are written to a temp file and renamed, so a reader never sees a half-written tile. A body that disappears from disk is treated as a miss and its row is cleaned up.
- Bodies are stored decompressed.
httpxtransparently decodes gzip and brotli, so the disk tree holds plain tile payloads and the server re-compresses vector tiles on the way out for clients that accept it. That keeps the cache directory usable as a static tile tree. - Rate limits are token buckets, one per layer, applied to upstream requests only. Cache hits never consume a token.
A full annotated example lives in config.example.yaml. Any string value may reference the
environment as ${VAR} or ${VAR:-default}; an unset variable with no default is a startup
error rather than a silently empty API key.
| Key | Type | Default | Meaning |
|---|---|---|---|
directory |
path | ./tilecache-data |
Where tile bodies and index.sqlite live. |
max_bytes |
int | 2147483648 |
Eviction budget for tile bodies. 0 disables eviction. |
evict_target |
float | 0.9 |
Fraction of the budget to shrink to when evicting. |
| Key | Type | Default | Meaning |
|---|---|---|---|
host |
string | 127.0.0.1 |
Bind address. |
port |
int | 8080 |
Bind port. |
cors_origins |
list | [] |
Allowed origins; empty disables CORS. |
cors_methods |
list | [GET, HEAD, OPTIONS] |
Allowed CORS methods. |
metrics_enabled |
bool | true |
Serve /metrics, or 404 it. |
| Key | Type | Default | Meaning |
|---|---|---|---|
name |
string | required | URL path segment for the layer. |
upstream |
string | required | http(s) template containing {z}, {x}, {y}, optionally {s}. |
subdomains |
list | [] |
Values rotated into {s}, round-robin per upstream request. |
extension |
string | png |
png, jpg, jpeg, webp, avif, pbf, mvt, json, geojson. |
min_zoom |
int | 0 |
Lowest zoom served. |
max_zoom |
int | 19 |
Highest zoom served. |
ttl |
int | 86400 |
Seconds a stored tile stays fresh. |
stale_if_error |
int | 604800 |
Extra seconds past expiry a tile may be served on upstream failure. |
bbox |
list | null |
[west, south, east, north]; tiles outside it are refused with a 404. |
headers |
map | {} |
Request headers sent upstream (User-Agent, Referer, API keys). |
rate_limit |
float | 0 |
Upstream requests per second. 0 is unlimited. |
rate_limit_burst |
int | from the rate | Token bucket capacity. |
timeout |
float | 10.0 |
Upstream request timeout in seconds. |
fallback |
string | transparent |
transparent, nodata, empty, or none to error instead. |
fallback_status |
int | 200 |
Status code for a fallback tile — 200 or 503 are the useful choices. |
cache_control |
string | from the TTL | Overrides the Cache-Control sent to clients. |
Validation is strict and the errors say what to fix: a template missing {y}, subdomains
configured without an {s} placeholder, min_zoom above max_zoom, an inverted bounding box,
or an unknown key are all refused at startup rather than at the first request.
| Route | Description |
|---|---|
GET /{layer}/{z}/{x}/{y}.{ext} |
The tile. Also accepts HEAD. Honours If-None-Match. |
GET /healthz |
{"status": "ok", "cache": {...}} with tile counts and bytes. |
GET /metrics |
Prometheus text exposition. |
GET / |
JSON list of configured layers and their limits. |
Response headers on a tile: X-Tile-Cache, Age, Cache-Control, and ETag /
Last-Modified passed through from the upstream. Degraded responses add
X-Tile-Cache-Error with the reason.
Error statuses are deliberate: 400 for a coordinate that cannot exist (x out of range for
the zoom), 404 for an unknown layer, a wrong extension, or a tile outside the layer's zoom
range or bbox, and 502 only when a layer has fallback: none and the upstream failed.
$ python -m tilecache --help
usage: tilecache [-h] [-c PATH] COMMAND ...
Caching reverse proxy for raster and vector map tiles. Sits in front of one or more
upstream tile providers, stores tiles on disk, and keeps serving them when the upstream
is down.
positional arguments:
COMMAND
serve run the HTTP tile proxy
warm pre-fetch a bbox and zoom range into the cache
purge delete cached tiles for a layer
stats show cache size and per-layer tile counts
validate load the configuration and report any problems
options:
-h, --help show this help message and exit
-c, --config PATH path to the YAML configuration (default: tilecache.yaml)
serve — runs uvicorn. --host and --port override the config; --log-level accepts the
usual uvicorn levels.
validate — loads and checks the config, printing a per-layer summary. Run it in CI:
$ python -m tilecache -c config.example.yaml validate
config.example.yaml: OK -- 4 layer(s)
osm: z0-19, .png, ttl 604800s, stale_if_error 2592000s, 2.0/s
swiss-terrain: z6-17, .png, ttl 2592000s, stale_if_error 7776000s, bbox [5.956, 45.818, 10.492, 47.808], 5.0/s
basemap-vector: z0-14, .pbf, ttl 86400s, stale_if_error 1209600s, 20.0/s
internal: z0-16, .pbf, ttl 300s, stale_if_error 3600s
cache: tilecache-data (budget 5.0 GiB)
warm — pre-seeds a region. --zoom takes 10, 10-14 or 10,12,14-16. --dry-run
counts first, which you should always do before warming a large area:
$ python -m tilecache warm osm --bbox -0.51,51.28,0.33,51.69 --zoom 10-13 --dry-run
444 tiles across z10-13 for 'osm' in bbox (-0.51, 51.28, 0.33, 51.69)
Drop --dry-run to fetch, --concurrency N to widen the fan-out (the layer's rate limit still
applies), and --refetch to ignore tiles that are already fresh. Tile counts grow by roughly
4x per zoom level, so warming a country to z16 is millions of requests — check the provider's
usage policy first.
purge — deletes cached tiles for a layer, optionally scoped by --zoom and --bbox.
Prompts unless you pass -y:
$ python -m tilecache purge osm --bbox -0.51,51.28,0.33,51.69 --zoom 14-18 -y
purged 3812 tiles (94.2 MiB) from 'osm'
stats — cache size and per-layer counts.
$ python -m tilecache stats
cache directory : tilecache-data
tiles : 0
size : 0 B
budget : 5.0 GiB
per layer : (cache is empty)
A bounding box beginning with a minus sign (anywhere west of Greenwich or south of the equator)
works as written — --bbox -0.51,51.28,0.33,51.69 is spliced into the unambiguous --bbox=
form before argparse sees it.
A complete session against a local upstream, showing every freshness state. The layer here has
ttl: 5 and stale_if_error: 600 so the transitions happen in seconds rather than days.
First request — nothing cached, so the tile is fetched and stored:
$ curl -sS -D- -o/dev/null http://127.0.0.1:8097/demo/6/32/21.png
HTTP/1.1 200 OK
x-tile-cache: MISS
age: 0
last-modified: Sun, 19 Jul 2026 17:53:48 GMT
cache-control: public, max-age=5
content-length: 1443
content-type: image/png
Immediately again — inside the TTL, served from disk, upstream untouched:
x-tile-cache: HIT
age: 0
After the TTL expires — a conditional request goes out, the upstream answers 304, and the TTL resets without transferring the body:
x-tile-cache: REVALIDATED
age: 0
Now stop the upstream and ask again. The tile is expired and unverifiable, but it is inside the
stale_if_error window, so the old body is served with an explanation and a Cache-Control
that stops anything downstream treating it as fresh:
x-tile-cache: STALE
age: 6
cache-control: public, max-age=0, must-revalidate
x-tile-cache-error: transport error fetching http://127.0.0.1:8098/6/32/21.png: All connection attempts failed
Ask for a different tile, which was never cached, with the upstream still down. There is nothing to serve stale, so a generated tile keeps the map from rendering broken images:
x-tile-cache: FALLBACK
age: 0
cache-control: no-store
x-tile-cache-error: transport error fetching http://127.0.0.1:8098/6/33/21.png: All connection attempts failed
The counters tell the whole story:
$ curl -sS http://127.0.0.1:8097/metrics | grep -v '^#'
tilecache_hits_total{layer="demo"} 1
tilecache_misses_total{layer="demo"} 2
tilecache_stale_total{layer="demo"} 1
tilecache_revalidations_total{layer="demo"} 2
tilecache_revalidated_304_total{layer="demo"} 1
tilecache_fetches_total{layer="demo"} 1
tilecache_fallbacks_total{layer="demo"} 1
tilecache_errors_total{layer="demo"} 2
tilecache_bytes_served_total{layer="demo"} 6106
tilecache_bytes_fetched_total{layer="demo"} 1443
tilecache_cache_bytes 1443
tilecache_cache_tiles 1
tilecache_cache_max_bytes 104857600
Five requests served, one tile downloaded.
TileCache is the whole engine minus the HTTP surface. Mount it in an app you already have:
from fastapi import FastAPI, Response
from tilecache import TileCache, load_config
from tilecache.cache import LayerNotFound, TileNotAllowed
from tilecache.tiles import TileCoordinateError
app = FastAPI()
cache = TileCache(load_config("tilecache.yaml"))
@app.on_event("shutdown")
async def close_cache() -> None:
await cache.aclose()
@app.get("/tiles/{layer}/{z}/{x}/{y}.png")
async def tile(layer: str, z: int, x: int, y: int) -> Response:
try:
result = await cache.get_tile(layer, z, x, y)
except (LayerNotFound, TileNotAllowed):
return Response(status_code=404)
except TileCoordinateError:
return Response(status_code=400)
return Response(
result.body,
status_code=result.status,
media_type=result.content_type,
headers=result.response_headers(),
)get_tile() raises only for requests that are wrong (unknown layer, impossible coordinates,
a tile the layer refuses). Upstream outages, timeouts and rate limits never raise — they resolve
to a STALE or FALLBACK result, which is the behaviour you want behind a live map.
Other useful entry points: cache.warm(layer, tiles, concurrency=...) for programmatic
pre-seeding, cache.store.purge(layer, zooms=..., bbox=...) for invalidation from a data
pipeline, cache.cache_stats() for a summary dict, and cache.render_metrics() for the
Prometheus text. TileCache is also an async context manager.
When a rebuild changes the underlying data, purge the affected area rather than the whole layer:
from tilecache import TileCache, load_config
from tilecache.tiles import BBox
cache = TileCache(load_config("tilecache.yaml"))
result = cache.store.purge(
"internal",
zooms=list(range(10, 17)),
bbox=BBox(5.956, 45.818, 10.492, 47.808),
)
print(f"dropped {result.removed} tiles, freed {result.bytes_freed} bytes")Then warm the same box back up so the first visitor after the rebuild does not pay for it. If a CDN sits in front of this proxy, purge there too — the notes on purging a CDN tile cache from Python cover that side.
uv pip install -r requirements-dev.txt
uv run pytest -q
uv run ruff check .312 tests, no network access required. Every test runs against a stubbed upstream ASGI app whose failure modes (500, 429, timeouts, missing validators, changed ETags) are controlled directly, so the stale and fallback paths are exercised deterministically rather than hoped for. CI runs the suite on Python 3.11, 3.12 and 3.13, plus a Docker build.
- Web Mercator XYZ only. No TMS y-flipping, no WMS, no non-3857 tile schemes. A TMS
upstream needs its
yinverted, which this does not do for you. - Single node. The SQLite index is local. Several processes can share a cache directory on one machine (WAL mode handles that), but there is no cluster-wide coordination, so two nodes behind a load balancer keep independent caches.
- No background revalidation. An expired tile is revalidated on the request that finds it,
which costs that one request a round trip. There is no
stale-while-revalidaterefresh happening off the request path. - Eviction is global, not per layer. The byte budget covers the whole cache; a busy layer can push out a quiet one. Run separate instances if layers need separate budgets.
bboxfiltering is tile-granular. A tile that merely overlaps the box is served in full, because tiles are the unit of storage.- No authentication. If the proxy is reachable from the internet, put it behind whatever you normally use — it will happily serve tiles to anyone, which is usually the point.
- Metrics are in-process. Counters reset when the process restarts; the cache size gauges are read live from the index and do not.
Background on the caching and tile-format decisions this proxy sits in the middle of:
- Purging a Cloudflare CDN tile cache from a Python pipeline
- Versioning tile URLs with content hashes for cache busting
- Clearing browser tile cache after Python data updates
- Choosing between raster tiles and vector tiles for web dashboards
- Generating vector tiles from PostGIS with tippecanoe
Those guides are part of geo-dashboard.com, a set of notes on building and operating Python-generated map dashboards.
MIT — see LICENSE.