Tuning MapProxy Cache Seeding for Large Extents
How to seed a MapProxy tile cache across national or continental extents without blowing out disk, saturating the source WMS, or leaving half-rendered zoom levels behind.
This walkthrough is a hands-on companion to GeoNode vs MapProxy Architecture Comparison and sits within the wider Core Portal Architecture & Security Boundaries practice; read the parent comparison first if you are still deciding whether MapProxy or a bundled GeoNode cache owns your tile pipeline. Here the scope is one operational task: writing a seed.yaml that pre-renders exactly the tiles a large-extent basemap needs and nothing more.
Prerequisites
Naive seeding of a wide extent to deep zoom levels can mean billions of tiles. Bound the job before you run it.
- MapProxy 1.16+ with the
mapproxy-seedCLI on the path and a workingmapproxy.yamlwhose caches and grids you intend to seed. - A reachable source WMS or WMTS that can sustain the concurrency you plan to point at it; confirm you are permitted to hammer it.
- A coverage geometry: a bounding box, or a polygon file (GeoJSON, Shapefile, or a PostGIS query) in a known SRS to clip seeding to land, a country, or a service area.
- Disk headroom sized from a tile-count estimate, not a guess — a web-mercator grid roughly quadruples tile count per zoom level.
- A writable cache backend (file, MBTiles, or S3) and permission to run long-lived jobs off-peak.
gdalinfo/ogrinfoormapproxy-utilhandy to sanity-check SRS and extents before committing to a multi-hour run.
The diagram below is the mental model for bounding a seed job: a tile pyramid whose per-level tile count explodes with depth, clipped by a coverage polygon so only tiles intersecting the area of interest are rendered.
Step-by-step implementation
1. Define coverages in seed.yaml
Every seed job begins with a named coverage that constrains where tiles are rendered. Use a bounding box for a rectangular region, or a geometry file when you must clip to a coastline or administrative boundary. Always declare the coverage SRS — a mismatched SRS silently seeds the wrong area.
coverages:
mainland_bbox:
bbox: [-124.8, 24.4, -66.9, 49.4]
srs: 'EPSG:4326'
service_area_poly:
datasource: '/data/coverages/service_area.geojson'
srs: 'EPSG:4326'
# only tiles intersecting this polygon are seeded
A polygon coverage is what turns a rectangular billions-of-tiles job into a tractable one: seeding a coastal service area clipped to land can eliminate the majority of ocean tiles a bounding box would otherwise render. Point datasource at any OGR-readable file, or at a PostGIS table if your boundaries already live in the database.
2. Constrain the level range per coverage
The second lever is depth. A single seeds entry ties a cache to a coverage and a levels range; keep low zooms global and reserve deep zooms for the tight coverage so tile count stays bounded.
seeds:
overview_levels:
caches: [basemap_cache]
grids: [webmercator]
coverages: [mainland_bbox]
levels:
from: 0
to: 8
detail_levels:
caches: [basemap_cache]
grids: [webmercator]
coverages: [service_area_poly]
levels:
from: 9
to: 16
Splitting the job in two — a shallow range over the wide box and a deep range over the clipped polygon — is the core tactic for large extents: you get a complete low-zoom basemap everywhere and high-resolution tiles only where users actually pan. This mirrors the layering trade-off discussed in the parent architecture comparison.
3. Set concurrency against the source’s ceiling
mapproxy-seed -c sets how many tiles render in parallel. Too low wastes hours; too high overruns the source WMS and trips its rate limits. Tune to the upstream’s capacity, not the seeder’s.
# Dry-run first: report the tile count WITHOUT rendering
mapproxy-seed -f mapproxy.yaml -s seed.yaml --dry-run --summary
# Then seed with bounded concurrency (8 parallel render workers)
mapproxy-seed -f mapproxy.yaml -s seed.yaml \
--seed overview_levels --concurrency 8
Always run --dry-run --summary first: it prints the tile count and estimated size per seed so you can catch a runaway job before it writes a single tile. Raise --concurrency only until the source WMS response time starts climbing, then back off one step.
4. Run incremental seeding with refresh_before
For updates, you rarely want to re-render everything. refresh_before re-seeds only tiles older than a cutoff, so a nightly job touches just what has changed while leaving warm tiles alone.
seeds:
nightly_refresh:
caches: [basemap_cache]
grids: [webmercator]
coverages: [service_area_poly]
levels:
from: 9
to: 16
refresh_before:
weeks: 1
With refresh_before: {weeks: 1}, the seeder skips any tile rendered within the last week and regenerates the rest — turning a full multi-hour reseed into a short incremental one. Pair this with a cleanup section to purge tiles that fall outside a shrunken coverage so stale bytes do not accumulate.
5. Monitor progress and disk during the run
A large seed is a long-running job; watch both its render progress and the filling disk so you can abort before the volume fills.
# Verbose progress: percent complete, ETA, tiles/second
mapproxy-seed -f mapproxy.yaml -s seed.yaml \
--seed detail_levels --concurrency 8 --progress-file .seed_progress -v
# In another shell, watch cache growth against free space
watch -n 30 'du -sh /data/mapproxy/tiles; df -h /data/mapproxy'
The --progress-file lets an interrupted job resume where it stopped instead of restarting from zoom zero — essential when a national seed spans hours and a node may be recycled mid-run. If the df free space trends toward zero faster than the ETA, stop and tighten the coverage or level range.
6. Schedule off-peak reseeds
Fold the incremental refresh into a scheduler so it runs when both the source WMS and the portal are quiet, and so an overrun never collides with peak read traffic.
# /etc/cron.d/mapproxy-seed — run the incremental refresh at 02:15 nightly
15 2 * * * mapproxy /usr/local/bin/mapproxy-seed -f /etc/mapproxy/mapproxy.yaml -s /etc/mapproxy/seed.yaml --seed nightly_refresh --concurrency 6 >> /var/log/mapproxy/seed.log 2>&1
Scheduling the deep-zoom refresh for the small hours keeps render load off the source during business hours and means the tiles your stale-while-revalidate layer serves are already warm by morning — the freshness technique in Configuring Stale-While-Revalidate Tile Caching.
Budgeting a seed run before you start it
A seed run is an expensive, long-lived batch job, and the three resources it consumes — renderer CPU, cache storage, and wall-clock time — are all decided before the first tile is drawn, by the levels and coverage you chose. Estimating them takes a minute and routinely changes the plan, because the tile count roughly quadruples per zoom level: the cost of levels 0–14 is a rounding error next to level 18, and a run that looks reasonable at the top of the pyramid is unfinishable at the bottom.
Turn that shape into three numbers before scheduling anything. Multiply the dry-run tile count by the average rendered tile size to get storage, and check it against free space with headroom for the cache’s own metadata. Divide the tile count by the measured throughput of a short sample run — tiles per second at the concurrency you intend to use — to get wall-clock time, then ask whether that fits in the maintenance window you actually have. If either number is uncomfortable, the fix is almost always to tighten the coverage polygon at the deepest two levels rather than to drop a level entirely, because deep tiles outside populated areas are the ones nobody requests.
Verification
Confirm the seed produced the tiles you intended and no more.
# 1. Dry-run count matches expectation before a real run
mapproxy-seed -f mapproxy.yaml -s seed.yaml --dry-run --summary
# expect: a tile total in the millions, not billions
# 2. Deep-zoom tiles exist only inside the coverage polygon
find /data/mapproxy/tiles/basemap_cache/14 -name '*.png' | wc -l
# expect: non-zero, but far below a full-level count
# 3. A sampled tile actually renders (not a 0-byte file)
gdalinfo /data/mapproxy/tiles/basemap_cache/14/EPSG3857/00/... .png >/dev/null && echo OK
# 4. Cache size tracks the estimate from the dry run
du -sh /data/mapproxy/tiles
A dry-run count that lands in the expected order of magnitude, deep-zoom tiles present only within the coverage, and a cache size close to the estimate together prove the job was correctly bounded. Wire the dry-run count into CI so a coverage or level change that would 10x the tile count is caught before it runs.
Keeping the seed off the readers’ path
The failure that turns a routine refresh into an incident is resource contention: the seeding workers and live readers share one renderer and one database, and a seed run at full concurrency is a sustained load spike shaped exactly like a traffic surge. Readers experience it as slow tiles, the tile proxy experiences it as timeouts, and the failover logic described in fallback routing strategies for tile servers may start diverting traffic to a tier that is competing for the same CPU.
Whichever you pick, make the seeding load observable as its own series rather than mixed into total renderer load. Tag seed requests with a distinct user agent or header at the proxy so the metrics split cleanly, and you can answer the only question that matters during a slow-tiles complaint: is the portal busy because readers arrived, or because a refresh is running?
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Seed job estimates billions of tiles | Deep levels.to applied over a wide bbox instead of a clipped polygon |
Split into shallow-box and deep-polygon seeds; add a coverages clip to the deep range |
| Source WMS returns errors or throttles mid-run | --concurrency exceeds the upstream’s capacity |
Lower concurrency until source response time stabilizes, then resume via the progress file |
| Deep-zoom tiles rendered over ocean/empty areas | Coverage is a bbox, not a land/service polygon | Point the coverage datasource at a GeoJSON/PostGIS boundary and set its SRS |
| Nightly refresh re-renders everything | refresh_before omitted, so all tiles are regenerated |
Add refresh_before with a week/day cutoff to touch only aged tiles |
| Disk fills before the job finishes | Tile count under-estimated; no dry run performed | Run --dry-run --summary first; tighten level range or coverage; add a cleanup section |
| Interrupted job restarts from zoom 0 | No --progress-file, so no resume state |
Always pass --progress-file; resume the same job after a node recycle |
| Tiles seeded in the wrong area | Coverage SRS mismatched with the grid SRS | Set the coverage srs explicitly and confirm it against the grid definition |
For how a standalone MapProxy seed pipeline compares with GeoNode’s bundled tile handling — and when to run each — see the parent GeoNode vs MapProxy Architecture Comparison.
Related
- Managing Cross-Domain CORS for OpenLayers Clients — serving the seeded tiles to browser clients on another origin.
- Configuring Stale-While-Revalidate Tile Caching — keeping seeded tiles fresh at the edge without blocking reads.
Up one level: GeoNode vs MapProxy Architecture Comparison.