Skip to content

About

Mapsight Tile Proxy

Topics

Resources

Code of conduct

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

Tile proxy

Tile proxy is a PHP-based server for processing and caching tiles.

Usage

You can initialize the proxy by providing a configuration array or by pointing to a JSONC (JSON with comments) configuration file.

Initialization

Use Proxy when a single config serves both raster tile pipelines and Mapbox style assets. It routes style asset requests under mapAssetBasePath to MapboxStyleProxy and everything else with an ops pipeline to Base.

Combined or JSONC configuration

use OpenMapsight\TileProxy\Proxy;

Proxy::runFromJsonConfigFile('/path/to/config.jsonc');

Raster tiles only

use OpenMapsight\TileProxy\Base;

Base::runFromJsonConfigFile('/path/to/config.jsonc');

Mapbox style assets only

use OpenMapsight\TileProxy\MapboxStyleProxy;

MapboxStyleProxy::run($config, $_SERVER['REQUEST_URI']);

Using array configuration

use OpenMapsight\TileProxy\Proxy;

$config = [
    // ... configuration ...
];

Proxy::run($config);

Both Proxy, Base, and MapboxStyleProxy::run() send HTTP status, cache, and content headers automatically. Use MapboxStyleProxy::handleRequest() or Base::handleTileRequest() when you need the HttpResponse object without sending output.

Configuration

The configuration defines the behavior of the proxy.

  • cacheServerPath: Base directory for caching tiles and map assets.
  • cacheLockTimeout: (Raster tiles, optional) Maximum seconds to wait for a tile's exclusive cache lock. Defaults to 2.85 for compatibility; set it above the full upstream fetch and processing budget.
  • ops: (Raster tiles) Operation pipeline for bitmap tile requests.
  • mapAssetBasePath: (Mapbox styles) URL path prefix for proxied style JSON, vector tiles, sprites, and glyphs.
  • styles: (Mapbox styles) Named style configurations for MapboxStyleProxy.
  • laxContentTypes: (Mapbox styles) When true, accept application/octet-stream for vector tile and glyph upstream responses in addition to the expected protobuf type. Set per style or at the config root. Can also be an array of additional MIME types to accept for any validated upstream fetch in that style.
  • upstreamHttp: (Optional) Shared HTTP client settings for upstream fetches in tile and Mapbox style proxying.
  • logErrors: (Optional) If set to true, log upstream fetch warnings and request handler failures to PHP's error_log.
  • prefixArgName: (Optional) Name of the GET parameter to use for prefixing (e.g., to support different map styles).
  • allowedPrefixes: (Optional) List of allowed values for the prefix argument.

Combined raster tiles and Mapbox styles

Use one JSONC file and Proxy::runFromJsonConfigFile() when you need both bitmap tiles and Mapbox/MapLibre style assets. Root-level settings such as cacheServerPath, upstreamHttp, and logErrors apply to both modes.

{
    "cacheServerPath": "/var/cache/mapsight-tile-proxy",
    "upstreamHttp": {
        "timeout": 30
    },
    "logErrors": true,

    "ops": [
        {
            "cacheServerName": "base-map",
            "urls": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
            "mimeType": "image/png",
            "cacheBrowserTtl": 3600,
            "cacheServerTtl": 86400
        }
    ],

    "mapAssetBasePath": "/map-assets",
    "styles": {
        "city-default": {
            "upstreamStyleUrl": "https://example.com/styles/base.json",
            "allowedHosts": ["example.com"],
            "allowedPathPrefixes": [
                "/styles/",
                "/tiles/",
                "/sprites/",
                "/fonts/"
            ]
        }
    }
}

Proxy routes by request path:

/tile-proxy.php?z=12&x=2200&y=1340              → raster pipeline (`ops`)
/map-assets/styles/city-default.json            → Mapbox style proxy (`styles`)
/map-assets/tiles/city-default/source/0/12/2200/1340.pbf  → vector tile from style proxy

Raster tiles use x, y, and z query parameters. Mapbox assets use path segments under mapAssetBasePath. Keep those URL spaces separate so Proxy can pick the right handler.

Upstream HTTP settings

Both the tile src operation and MapboxStyleProxy use the same optional upstreamHttp configuration for outbound requests. HTTP(S) fetches go through Guzzle; file:// URLs are read from disk. Supported keys map to Guzzle request options: proxy, timeout, connect_timeout, allow_redirects, and headers.

"upstreamHttp": {
    "proxy": "tcp://proxy.example.com:8080",
    "timeout": 30,
    "connect_timeout": 10,
    "allow_redirects": true,
    "headers": {
        "User-Agent": "mapsight-tile-proxy"
    }
}

For tile pipelines, set upstreamHttp at the root of the config. A src operation can override it with its own upstreamHttp block.

Raster cache retention

cacheServerTtl controls freshness: an expired tile is refreshed on its next request, and a failed refresh can still serve its cached copy. It does not evict unused files.

Call CachePruner::prune() from cron or your application's scheduler to remove raster tile files older than a separate retention period. This includes obsolete cacheServerName namespaces, old metadata without retained tiles, and empty directories. The return value counts deleted files. For example, run this daily:

use OpenMapsight\TileProxy\CachePruner;

$deleted = CachePruner::prune('/var/cache/mapsight-tile-proxy', 30 * 86400);

Choose a retention period longer than your raster cacheServerTtl values and any upstream minimum caching requirements. Age is measured from tile writes, not filesystem atime; metadata tracks the last completed request. Retention limits the age of stored tiles, not total disk usage. The reserved mapbox-style-proxy subtree, including its empty directories, is excluded from raster cleanup.

The pruner takes the same per-tile lock as Base, skips busy tiles without waiting, and rechecks timestamps under the lock. Waiting requests reopen metadata that was removed by the pruner before using the cache. Run cleanup only after all processes sharing the cache use this version's locking, and leave yoloOnLockTimeout disabled. Use a POSIX filesystem supporting flock() across those processes. Cache directories are reserved for proxy files; symlinks are not followed by the pruner.

For an upstream timeout of ten seconds plus tile processing, set cacheLockTimeout to a larger budget, for example:

"cacheLockTimeout": 15,
"upstreamHttp": { "timeout": 10 }

Zero means a single non-blocking lock attempt. Negative or non-finite timeouts are rejected. Locks are closed on both successful requests and failures; the default remains 2.85 seconds for existing deployments.

Error logging

Most failures are handled gracefully in HTTP responses, but are otherwise silent unless logging is enabled.

When logErrors is true, or a PSR-3 logger is wired via Log::setLogger(), the library logs:

  • Warnings for upstream transport failures (invalid proxy URI, timeouts, unreadable file:// URLs, content-type mismatches)
  • Errors for uncaught request handler failures that become HTTP 500 responses (cache write failures, missing extensions, pipeline misconfiguration)

HTTP 4xx client errors and missing upstream tiles are not logged by default.

Enable PHP error_log output in config:

"logErrors": true

For production, wire a PSR-3 compatible logger before handling requests. The library calls warning() for upstream issues and error() for request handler failures.

Plain PHP entry file with Monolog

Install Monolog in your project (composer require monolog/monolog), then use a small front script such as public/tile-proxy.php:

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use OpenMapsight\TileProxy\Log;
use OpenMapsight\TileProxy\Proxy;

$logger = new Logger('tile-proxy');
$logger->pushHandler(new StreamHandler('php://stderr', Level::Warning));

Log::setLogger($logger);
Proxy::runFromJsonConfigFile(__DIR__ . '/../config/tile-proxy.jsonc');

Point your web server at that script (or at index.php if you inline the same setup there). Logging to php://stderr works well in Docker; use a file path such as /var/log/tile-proxy.log on a normal VM instead.

Symfony app logger in a dedicated entry script

Symfony already provides a PSR-3 logger (Monolog). Because Proxy::run() sends headers and body itself, call it from a dedicated entry script rather than returning a Symfony Response:

<?php
declare(strict_types=1);

use OpenMapsight\TileProxy\Log;
use OpenMapsight\TileProxy\Proxy;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Kernel;

require dirname(__DIR__) . '/vendor/autoload.php';

/** @var Kernel $kernel */
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();

/** @var LoggerInterface $logger */
$logger = $kernel->getContainer()->get('logger');

Log::setLogger($logger);
Proxy::runFromJsonConfigFile(dirname(__DIR__) . '/config/tile-proxy.jsonc');

Route /tiles or /map-assets directly to that script in nginx or Apache. In a Symfony controller action the same logger wiring works, but you would need to capture output yourself via Base::handleTileRequest() or MapboxStyleProxy::handleRequest() instead of Proxy::run().

Failed upstream fetches also expose a short reason on UpstreamFetchResult::$error when you call UpstreamFetcher directly.

Operation Pipeline (Chaining)

Operations are chained sequentially as defined in the ops array. The first operation must be the src operation, which defines the source URL(s).

"ops": [
    {
        "cacheServerName": "source-1",
        "urls": ["https://example.com/tiles/{z}/{x}/{y}.png"],
        "mimeType": "image/png",
        "cacheBrowserTtl": 3600,
        "cacheServerTtl": 86400
    },
    {
        "op": "colorFilter",
        "filter": "reducedSaturation",
        "cacheServerName": "filter-1"
    },
    {
        "op": "imgOpt",
        "cacheServerName": "opt-1"
    }
]

Available Operations

  • src: Fetches the tile from the defined urls, or from a wms GetMap source. URL templates support {z}, {x}, {y}, {prefix}, {bbox} / {bbox3857} (Web Mercator meters), and {bbox4326} (lon,lat,lon,lat).
  • colorFilter: Applies color filters. Supported filters: reducedSaturation, muted, culture.
  • colorKey: Makes nodata fill colors transparent (colors as #RRGGBB, optional fuzz, optional fromEdges). Defaults to a flood from the tile edge and already-transparent pixels so isolated interior matches stay put. Always encodes PNG. Optional soft fades alpha by distance to the key color instead of a hard cut. Optional feather (integer pixel passes) blurs alpha afterward. Optional protectDarkerThan (0–255 luminance) restores partial-alpha pixels darker than that value so labels stay opaque after a street-fill punch.
  • encode: Re-encodes the current tile to another image type (mimeType, optional quality for JPEG/WebP).
  • imgOpt: Optimizes the image using image optimizers.
  • merge: Merges the current tile with another set of operations.

Any operation may include an optional prefixes array. When set, the operation runs only when the resolved tile prefix (from prefixArgName / defaultPrefix) is listed. This applies to sub-pipelines inside merge as well.

WMS as public XYZ tiles

The public request stays a normal OSM-style XYZ tile (?z=&x=&y= or a /tiles/{z}/{x}/{y}.jpg rewrite). The src operation turns that tile into a WMS GetMap bbox and fetches one 256×256 image.

Use a wms block for the common GeoServer case. The default CRS is EPSG:3857, which matches slippy-map tiles:

"ops": [
    {
        "cacheServerName": "luftbild-2024",
        "wms": {
            "url": "https://geoportal.example.de/geoserver/luftbilder/wms",
            "layers": "Luftbild_2024",
            "version": "1.1.1",
            "format": "image/jpeg",
            "srs": "EPSG:3857",
            "transparent": false
        },
        "mimeType": "image/jpeg",
        "cacheBrowserTtl": 86400,
        "cacheServerTtl": 604800
    }
]

format defaults to the src mimeType when omitted. Supported srs values are EPSG:3857, EPSG:4326, and CRS:84. WMS 1.3.0 uses CRS and the EPSG:4326 lat/lon axis order.

Or keep a full URL template when you need extra query parameters:

"urls": [
    "https://geoportal.example.de/geoserver/luftbilder/wms?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image/jpeg&STYLES=&TRANSPARENT=false&LAYERS=Luftbild_2024&WIDTH=256&HEIGHT=256&SRS=EPSG:3857&BBOX={bbox3857}"
]

JPEG is the right WMS FORMAT for aerial photos (smaller than PNG, widely supported by GeoServer). WebP is rarely offered by WMS. Fetch JPEG, then encode if you want to serve WebP tiles to the browser:

"ops": [
    {
        "cacheServerName": "luftbild-2024",
        "wms": {
            "url": "https://geoportal.example.de/geoserver/luftbilder/wms",
            "layers": "Luftbild_2024",
            "format": "image/jpeg"
        },
        "mimeType": "image/jpeg",
        "cacheBrowserTtl": 86400,
        "cacheServerTtl": 604800
    },
    {
        "op": "encode",
        "mimeType": "image/webp",
        "quality": 80,
        "cacheServerName": "luftbild-webp"
    }
]

To punch a solid nodata fill out of an aerial before overlaying it, add colorKey. fuzz is a per-channel tolerance. Keep fromEdges at its default (true) so interior roofs or shadows that happen to match the fill stay put:

{
    "op": "colorKey",
    "colors": ["#000000"],
    "fuzz": 8,
    "cacheServerName": "luftbild-keyed"
}

Set fromEdges to false only when every matching pixel should become transparent, including interior matches.

To punch cartographic fills (street interiors) and keep labels, turn off the edge flood, fade the cut, then protect dark ink:

{
    "op": "colorKey",
    "colors": ["#ffffff", "#fff8bd", "#d0d0d0"],
    "fuzz": 8,
    "fromEdges": false,
    "soft": true,
    "feather": 2,
    "protectDarkerThan": 80,
    "cacheServerName": "stadtplan-ink"
}

Leaflet, OpenLayers, and MapLibre can consume the proxied XYZ URL like any other raster tileset.

If the WMS requires Basic auth, set upstreamHttp.headers.Authorization in the PHP bootstrap (not in committed JSONC). Some municipal WMS licenses also forbid local storage — disable or shorten cacheServerTtl when that applies.

Mapbox Style Vector Proxy

MapboxStyleProxy proxies named Mapbox/MapLibre style assets through same-origin URLs. It rewrites style JSON references for TileJSON, vector tiles, sprites, and glyphs, resolves relative asset URLs, then fetches only allow-listed upstream URLs on demand.

use OpenMapsight\TileProxy\MapboxStyleProxy;

MapboxStyleProxy::run($config, $_SERVER['REQUEST_URI']);

For custom response handling:

use OpenMapsight\TileProxy\HttpResponse;
use OpenMapsight\TileProxy\MapboxStyleProxy;

$response = MapboxStyleProxy::handleRequest($config, $_SERVER['REQUEST_URI']);
HttpResponse::send($response);

Example configuration:

{
    "cacheServerPath": "/var/cache/mapsight-tile-proxy",
    "mapAssetBasePath": "/map-assets",
    "upstreamHttp": {
        "proxy": "tcp://proxy.example.com:8080",
        "timeout": 30
    },
    "styles": {
        "city-default": {
            "upstreamStyleUrl": "https://example.com/styles/base.json",
            "allowedHosts": ["example.com"],
            "allowedPathPrefixes": [
                "/styles/",
                "/tiles/",
                "/sprites/",
                "/fonts/"
            ],
            "laxContentTypes": true,
            "cacheTtls": {
                "style": 86400,
                "tilejson": 86400,
                "tile": 604800,
                "sprite": 2592000,
                "glyph": 2592000
            },
            "transforms": [
                { "op": "removeLayersById", "ids": ["duplicate-poi-layer"] },
                { "op": "removeLayersByIdContains", "contains": "RailTrack" }
            ],
            "attribution": "City map attribution, Darstellung verandert"
        }
    }
}

Request the proxied style at:

/map-assets/styles/city-default.json

Generated asset routes use the same base path:

/map-assets/tilejson/{styleName}/{sourceName}.json
/map-assets/tiles/{styleName}/{sourceName}/{tileIndex}/{z}/{x}/{y}.pbf
/map-assets/sprites/{styleName}/{encodedSpriteUrl}.json
/map-assets/sprites/{styleName}/{encodedSpriteUrl}.png
/map-assets/glyphs/{styleName}/{encodedGlyphTemplate}/{fontstack}/{range}.pbf

Web fonts for OpenLayers

OpenLayers/ol-mapbox-style uses CSS fonts, not the style's PBF glyphs. Without a separate font policy it can load fonts directly from jsDelivr. Set "olWebfonts": true in the named style configuration to opt into rewriting font metadata. When omitted or false, the proxy leaves ol:webfonts metadata untouched, including any upstream value, even if webfonts families are configured. Other renderers can continue using the proxied PBF glyphs.

With olWebfonts enabled, the proxy sets a same-origin metadata["ol:webfonts"] template, replacing any upstream value:

/map-assets/fonts/{styleName}/{font-family}/{fontweight}{-fontstyle}.css
/map-assets/fonts/{styleName}/{font-family}/{subset}-{weight}-{normal|italic}.woff2

webfonts controls which font families and weights the proxy can download. With olWebfonts enabled and webfonts omitted, false, or empty, CSS requests return an empty stylesheet, so missing fonts use the browser's fallback. To enable selected fallback faces, configure them below. No client-side URL mapping is needed:

"olWebfonts": true,
"webfonts": {
    "ubuntu": {
        "version": "5.2.8",
        "faces": ["400", "400-italic", "700"],
        "subsets": ["latin", "latin-ext"]
    }
},
"allowedHosts": ["example.com", "cdn.jsdelivr.net"],
"allowedPathPrefixes": ["/styles/", "/tiles/", "/npm/@fontsource/ubuntu@5.2.8/"]

Versions must be fixed. Only configured families, faces and subsets can be fetched. The server fetches Fontsource CSS once per face, keeps only the selected @font-face blocks and rewrites their sources to local WOFF2 routes. Unicode ranges are preserved; WOFF alternatives and other subsets are removed. Both CSS and font bytes use the existing disk/stale cache and upstreamHttp settings. Optional cacheTtls keys font-css and font default to 30 days.

Use the host's existing fonts by mapping exact style font names:

"transforms": [{
    "op": "mapFonts",
    "fonts": {
        "Noto Sans Regular": "Ubuntu Regular",
        "Roboto Medium": "Ubuntu SemiBold"
    }
}]

mapFonts replaces matching names inside text-font values, including expression literals and formatted text overrides. Unmapped names and other layout/paint properties are retained. For system fonts, use setLayerProperties with layout: {"text-font": ["sans-serif"]}.

Match the host's CSS weights exactly. If the host declares a face under another weight, faces can instead map requested to upstream variants, e.g. {"600": "500", "600-italic": "500-italic"}. This serves Fontsource's Medium face as CSS weight 600. Existing page fonts are used first; the proxy is only a fallback. Keep the font license and attribution available when redistributing font files (Ubuntu uses the Ubuntu Font Licence; licenses differ by family).

Disable all proxy font downloads with "webfonts": false; this does not change olWebfonts. To disable one family, set its entry to false. To disable or substitute individual weights (including italic variants), use a faces object instead of a list:

"olWebfonts": true,
"webfonts": {
    "noto-sans": false,
    "ubuntu": {
        "version": "5.2.8",
        "faces": {
            "400": "400",
            "600": "500",
            "600-italic": "500-italic",
            "700": false
        },
        "subsets": ["latin", "latin-ext"]
    }
}

Here 600 uses the upstream 500 face, while 700 and every unlisted variant are disabled. Disabling affects proxy downloads; it does not remove fonts from style stacks or prevent use of fonts already installed or loaded by the host. Use mapFonts to substitute a style font family or weight in the rendered labels.

When olWebfonts is enabled, disabled fonts retain the local ol:webfonts template: their CSS endpoint returns an empty stylesheet and their WOFF2 endpoint rejects requests. This also works with existing ol-mapbox-style versions. Setting ol:webfonts metadata to false is not a reliable way to disable loading: applyStyle() treats it as absent and falls back to its webfonts option. The client-side webfonts: false option added by ol-mapbox-style #1564 disables automatic loading for styles without a metadata template; it does not override the template emitted by this proxy.

Formatted text needs an additional client setting: the current ol-mapbox-style renderer calls its font loader without forwarding the metadata template for rich-text sections. With the change from #1564, pass {webfonts: false} to apply() / applyStyle() to disable that fallback CDN path while ordinary labels continue using the proxy template. Fonts for those rich-text sections must already be available on the host page.

After deploying, invalidate previously cached style JSON in clients/CDNs to activate the new metadata and font mapping. Also invalidate cached font CSS when enabling or changing faces, including empty responses for previously unconfigured faces. Raw upstream style caches do not need deleting.

Recolouring and adjusting vector styles

setLayerProperties changes existing layers without touching their source data. An omitted or empty selector matches all layers. Selector fields (ids, prefixes, sourceLayers, types) combine with AND; values within a field combine with OR. Unknown selector fields are rejected to catch configuration typos.

"transforms": [
    {
        "op": "setLayerProperties",
        "selector": {"sourceLayers": ["Gebaeudeflaeche"], "types": ["fill"]},
        "properties": {"paint": {"fill-color": "#0071ad", "fill-pattern": null}}
    },
    {
        "op": "setLayerProperties",
        "selector": {"types": ["symbol"]},
        "properties": {"paint": {"text-color": "#e1eaf0", "text-halo-color": "#001f33"}}
    }
]

Supported properties are paint, layout, filter, minzoom, and maxzoom. Paint/layout entries are merged by property name; null removes an entry. Other properties are replaced (null removes them). MapLibre expressions are copied verbatim. Transforms run in order, so later rules can refine earlier rules. They apply on every style response; raw upstream assets remain cached, allowing palette edits without deleting tile caches. Sources and source URLs cannot be changed by this transform.

For a regional extract, optional per-style tileBounds: [west, south, east, north] restricts vector tile requests before fetching. Intersecting XYZ tiles are allowed in full; their geometries are not clipped. TileJSON responses and style sources with inline tiles advertise the intersection with their original bounds. Antimeridian-crossing bounds are not supported. XYZ coordinates are validated independently of this option. This does not restrict raster-pipeline requests.

Examples

Layering Tiles

You can layer tiles by using the merge operation to overlay another tile set on top of the base map. If the overlay request fails (e.g., returns a 404), the merge operation is skipped and the base tile is returned.

"ops": [
    {
        "cacheServerName": "base-map",
        "urls": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
        "mimeType": "image/png",
        "cacheBrowserTtl": 3600,
        "cacheServerTtl": 86400
    },
    {
        "op": "merge",
        "ops": [
            {
                "cacheServerName": "overlay",
                "urls": ["https://overlay.example.com/{z}/{x}/{y}.png"],
                "mimeType": "image/png",
                "cacheBrowserTtl": 3600,
                "cacheServerTtl": 86400
            }
        ]
    }
]

Prefix-specific operations

Use prefixes to run an operation only for certain map styles. For example, apply a color filter to the base map before merging an overlay when prefix=muted:

"ops": [
    {
        "cacheServerName": "base-map",
        "urls": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
        "mimeType": "image/png",
        "cacheBrowserTtl": 3600,
        "cacheServerTtl": 86400
    },
    {
        "op": "colorFilter",
        "prefixes": ["muted"],
        "filter": "reducedSaturation",
        "cacheServerName": "desaturated"
    },
    {
        "op": "merge",
        "ops": [
            {
                "cacheServerName": "overlay",
                "urls": ["https://example.com/tiles/{prefix}/{z}/{x}/{y}.png"],
                "mimeType": "image/png",
                "cacheBrowserTtl": 3600,
                "cacheServerTtl": 86400
            }
        ]
    },
    {
        "op": "imgOpt",
        "cacheServerName": "opt-1"
    }
],
"prefixArgName": "prefix",
"allowedPrefixes": ["default", "satellite", "muted"],
"defaultPrefix": "default"

Development

Testing

To run the tests, use:

composer test

Or run PHPUnit directly:

vendor/bin/phpunit

The basemap.de integration test is skipped by default because it depends on the live upstream service. Run it explicitly with:

RUN_BASEMAPDE_INTEGRATION_TESTS=1 vendor/bin/phpunit tests/BasemapDeIntegrationTest.php

License

This project is MIT.

For licensing or trademark questions, contact contact@open-mapsight.org.

Community

Participation in Mapsight community spaces is governed by our Code of Conduct (Contributor Covenant 3.0).

About

Mapsight Tile Proxy

Topics

Resources

Code of conduct

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages