tilegroxy Docs
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