Example
Multi-Tenant Imagery
This configuration serves a SaaS application where each customer organization has its own survey imagery, layered over a basemap that every organization shares. The tenant comes from the user's token and decides which imagery is fetched, where it's cached, which area is visible, and how usage is attributed.
| Read Serve Multiple Tenants and User Dynamic Layers for the concepts behind it. |
Configuration
-
YAML
-
JSON
server:
production: true
cors:
enabled: true
origins:
- https://app.example.com
cachecontrol: (1)
enabled: true
visibility: private
maxage: 3600
datastores: (2)
- name: redis
id: tile-store
host: redis.internal
- name: postgresql
id: warehouse
host: postgres.internal
user: tilegroxy
password: env.POSTGRES_PASSWORD
database: analytics
authentication:
name: jwt
key: env.JWT_PUBLIC_KEY (3)
algorithms:
- RS256
expectedissuer: https://auth.example.com
tenantid: org_id (4)
layerscope: true (5)
scopeprefix: tiles/
cache:
- id: shared (6)
name: redis
datastore: tile-store
keyprefix: shared
ttl: 604800
- id: per-tenant
name: tenant (7)
cache:
name: redis
datastore: tile-store
keyprefix: tenant
ttl: 86400
analytics:
name: postgresql
datastore: warehouse
table: tile_events
fields: (8)
- tenantid
- cached
- duration
layers:
- id: basemap (9)
provider:
name: proxy
url: https://tiles.example.com/basemap/{z}/{x}/{y}.png
- id: survey
cache: per-tenant
provider:
name: crop
boundsfromauth: true (10)
primary:
name: proxy
url: https://imagery.example.com/{ctx.tenant}/survey/{z}/{x}/{y}.png (11)
- id: survey_on_basemap
cache: per-tenant (12)
allowcoalesce: false (13)
provider:
name: blend
mode: normal
providers:
- name: ref
layer: survey
- name: ref
layer: basemap
| 1 | Every layer needs a token, so no tile may be stored by a CDN or other shared cache. private limits reuse to the requester’s own browser. See Cache Control. |
| 2 | One Redis connection pool serves both caches and one PostgreSQL pool serves analytics. See Datastores. |
| 3 | The public key of the identity provider, read from an environment variable. The private key never needs to reach tilegroxy. |
| 4 | The token claim holding the organization. Everything tenant-aware below reads the value of this claim. |
| 5 | A token only grants the layers listed in its scope claim with the tiles/ prefix. A scope of tiles/basemap tiles/survey allows those two layers and nothing else. See JWT. |
| 6 | The first cache in the list is the default, so layers that don’t name a cache share this one. A basemap tile fetched for one organization is served from cache to the next. |
| 7 | Adds the tenant to every cache key, so each organization’s tiles are stored separately. The keyprefix keeps both caches apart within the same Redis database. See Tenant. |
| 8 | Records the organization on each event alongside the user ID, so usage can be billed per tenant. The tile_events table must be created ahead of time as described in PostgreSQL. |
| 9 | Uses the default shared cache since it’s identical for everyone. |
| 10 | If the token carries a geohash claim, pixels outside that area are made transparent. This lets an organization issue tokens limited to one project site. Tiles entirely outside the geohash are rejected by the JWT check before reaching the provider. Tokens without the claim see the full tile. See Crop. |
| 11 | {ctx.tenant} is replaced with the organization from the token, percent-encoded. Never take the tenant from the layer name or a header, as a user can change those. |
| 12 | The blended tile contains tenant imagery, so it must also be stored per tenant. A layer only uses the cache it names, regardless of what its referenced layers use. |
| 13 | Tilegroxy already turns coalescing off for a layer with a tenant cache, but the detection can’t catch every case. Stating it explicitly on every layer that varies by tenant guarantees two organizations requesting the same tile at once are never handed each other’s result. |
{
"server": {
"production": true,
"cors": {
"enabled": true,
"origins": [
"https://app.example.com"
]
},
"cachecontrol": { (1)
"enabled": true,
"visibility": "private",
"maxage": 3600
}
},
"datastores": [ (2)
{
"name": "redis",
"id": "tile-store",
"host": "redis.internal"
},
{
"name": "postgresql",
"id": "warehouse",
"host": "postgres.internal",
"user": "tilegroxy",
"password": "env.POSTGRES_PASSWORD",
"database": "analytics"
}
],
"authentication": {
"name": "jwt",
"key": "env.JWT_PUBLIC_KEY", (3)
"algorithms": [
"RS256"
],
"expectedissuer": "https://auth.example.com",
"tenantid": "org_id", (4)
"layerscope": true, (5)
"scopeprefix": "tiles/"
},
"cache": [
{
"id": "shared", (6)
"name": "redis",
"datastore": "tile-store",
"keyprefix": "shared",
"ttl": 604800
},
{
"id": "per-tenant",
"name": "tenant", (7)
"cache": {
"name": "redis",
"datastore": "tile-store",
"keyprefix": "tenant",
"ttl": 86400
}
}
],
"analytics": {
"name": "postgresql",
"datastore": "warehouse",
"table": "tile_events",
"fields": [ (8)
"tenantid",
"cached",
"duration"
]
},
"layers": [
{
"id": "basemap", (9)
"provider": {
"name": "proxy",
"url": "https://tiles.example.com/basemap/{z}/{x}/{y}.png"
}
},
{
"id": "survey",
"cache": "per-tenant", (12)
"provider": {
"name": "crop",
"boundsfromauth": true, (10)
"primary": {
"name": "proxy",
"url": "https://imagery.example.com/{ctx.tenant}/survey/{z}/{x}/{y}.png" (11)
}
}
},
{
"id": "survey_on_basemap",
"cache": "per-tenant",
"allowcoalesce": false, (13)
"provider": {
"name": "blend",
"mode": "normal",
"providers": [
{
"name": "ref",
"layer": "survey"
},
{
"name": "ref",
"layer": "basemap"
}
]
}
}
]
}
| 1 | Every layer needs a token, so no tile may be stored by a CDN or other shared cache. private limits reuse to the requester’s own browser. See Cache Control. |
| 2 | One Redis connection pool serves both caches and one PostgreSQL pool serves analytics. See Datastores. |
| 3 | The public key of the identity provider, read from an environment variable. The private key never needs to reach tilegroxy. |
| 4 | The token claim holding the organization. Everything tenant-aware below reads the value of this claim. |
| 5 | A token only grants the layers listed in its scope claim with the tiles/ prefix. A scope of tiles/basemap tiles/survey allows those two layers and nothing else. See JWT. |
| 6 | The first cache in the list is the default, so layers that don’t name a cache share this one. A basemap tile fetched for one organization is served from cache to the next. |
| 7 | Adds the tenant to every cache key, so each organization’s tiles are stored separately. The keyprefix keeps both caches apart within the same Redis database. See Tenant. |
| 8 | Records the organization on each event alongside the user ID, so usage can be billed per tenant. The tile_events table must be created ahead of time as described in PostgreSQL. |
| 9 | Uses the default shared cache since it’s identical for everyone. |
| 10 | If the token carries a geohash claim, pixels outside that area are made transparent. This lets an organization issue tokens limited to one project site. Tiles entirely outside the geohash are rejected by the JWT check before reaching the provider. Tokens without the claim see the full tile. See Crop. |
| 11 | {ctx.tenant} is replaced with the organization from the token, percent-encoded. Never take the tenant from the layer name or a header, as a user can change those. |
| 12 | The blended tile contains tenant imagery, so it must also be stored per tenant. A layer only uses the cache it names, regardless of what its referenced layers use. |
| 13 | Tilegroxy already turns coalescing off for a layer with a tenant cache, but the detection can’t catch every case. Stating it explicitly on every layer that varies by tenant guarantees two organizations requesting the same tile at once are never handed each other’s result. |
Try it
Tokens are issued by your identity provider. A token for a user of the acme organization limited to one site might carry these claims:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"org_id": "acme",
"scope": "tiles/basemap tiles/survey_on_basemap",
"geohash": "9xj6",
"exp": 1791700000
}
The seed command has no token, so pass the tenant explicitly to warm one organization’s cache:
tilegroxy seed -c multi-tenant.yml -l survey -z 10 -z 11 -z 12 --tenant acme