Skip to content

About

Caching reverse proxy for raster and vector map tiles: disk cache with TTLs, conditional revalidation, stale-on-error and offline fallback tiles, per-layer rate limiting, and warm/purge tooling. Runs standalone or mounts into an existing ASGI app.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

tile-cache-server

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.

Features

  • One endpoint per layer — GET /{layer}/{z}/{x}/{y}.{ext}, raster or vector.
  • Disk cache in a plain layer/z/x/y.ext tree, 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 purge and warm CLI commands.
  • Operable — /healthz, Prometheus text at /metrics, and an X-Tile-Cache header 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.

Why this exists

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.

Install and run

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.yaml

Plain venv works identically:

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt

If 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.

Docker

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.yaml

Set 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.

How it works

        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. ttl is how long a tile is served without asking; stale_if_error is how much further past expiry a tile may be served when the upstream is broken. The two are additive, so ttl: 3600, stale_if_error: 86400 means "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. httpx transparently 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.

Configuration reference

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.

cache

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.

server

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.

layers[]

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.

HTTP API

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.

CLI reference

$ 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.

Worked example

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.

Using it as a library

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.

Cache invalidation from a pipeline

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.

Testing

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.

Limitations

  • Web Mercator XYZ only. No TMS y-flipping, no WMS, no non-3857 tile schemes. A TMS upstream needs its y inverted, 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-revalidate refresh 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.
  • bbox filtering 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.

Further reading

Background on the caching and tile-format decisions this proxy sits in the middle of:

Those guides are part of geo-dashboard.com, a set of notes on building and operating Python-generated map dashboards.

License

MIT — see LICENSE.

About

Caching reverse proxy for raster and vector map tiles: disk cache with TTLs, conditional revalidation, stale-on-error and offline fallback tiles, per-layer rate limiting, and warm/purge tooling. Runs standalone or mounts into an existing ASGI app.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages