10 KiB
Plan 02: Dynamic Tiler Service (TiTiler)
Status: Pending Implementation
Date: 2026-02-27
Objective
Deploy a dynamic tiling service to serve Cloud Optimized GeoTIFFs (COGs) from MinIO as XYZ map tiles for the React frontend. This enables efficient map rendering without downloading entire raster files.
1. Architecture Overview
graph TD
A[React Frontend] -->|Tile Request XYZ/zoom/x/y| B[Ingress]
B --> C[TiTiler Service]
C -->|Read COG tiles| D[MinIO]
C -->|Return PNG/Tiles| A
E[Worker] -->|Upload COG| D
F[API] -->|Generate URLs| C
2. Technology Choice
2.1 TiTiler vs Rio-Tiler
| Feature | TiTiler | Rio-Tiler |
|---|---|---|
| Deployment | Docker/Cloud Native | Python Library |
| API REST | ✅ Built-in | ❌ Manual |
| Cloud Optimized | ✅ Native | ✅ Native |
| Multi-source | ✅ Yes | ✅ Yes |
| Dynamic tiling | ✅ Yes | ✅ Yes |
| Recommendation | TiTiler | - |
Chosen: TiTiler (modern, API-first, Kubernetes-ready)
2.2 Alternative: Custom Tiler with Rio-Tiler
If TiTiler has issues, implement custom FastAPI endpoint:
- Use
rio-tileras library - Create
/tiles/{job_id}/{z}/{x}/{y}endpoint - Read from MinIO on-demand
3. Deployment Strategy
3.1 Kubernetes Deployment
Create k8s/25-tiler.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: geocrop-tiler
namespace: geocrop
labels:
app: geocrop-tiler
spec:
replicas: 2
selector:
matchLabels:
app: geocrop-tiler
template:
metadata:
labels:
app: geocrop-tiler
spec:
containers:
- name: tiler
image: ghcr.io/developmentseed/titiler:latest
ports:
- containerPort: 8000
env:
- name: MINIO_ENDPOINT
value: "minio.geocrop.svc.cluster.local:9000"
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: geocrop-secrets
key: minio-access-key
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: geocrop-secrets
key: minio-secret-key
- name: AWS_S3_ENDPOINT_URL
value: "http://minio.geocrop.svc.cluster.local:9000"
- name: TILED_READER
value: "cog"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: geocrop-tiler
namespace: geocrop
spec:
selector:
app: geocrop-tiler
ports:
- port: 8000
targetPort: 8000
type: ClusterIP
3.2 Ingress Configuration
Add to existing ingress or create new:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: geocrop-tiler
namespace: geocrop
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- tiles.portfolio.techarvest.co.zw
secretName: geocrop-tiler-tls
rules:
- host: tiles.portfolio.techarvest.co.zw
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: geocrop-tiler
port:
number: 8000
3.3 DNS Configuration
Add A record:
tiles.portfolio.techarvest.co.zw→167.86.68.48(ingress IP)
4. TiTiler API Usage
4.1 Available Endpoints
| Endpoint | Description |
|---|---|
GET /cog/tiles/{z}/{x}/{y}.png |
Get tile as PNG |
GET /cog/tiles/{z}/{x}/{y}.webp |
Get tile as WebP |
GET /cog/point/{lon},{lat} |
Get pixel value at point |
GET /cog/bounds |
Get raster bounds |
GET /cog/info |
Get raster metadata |
GET /cog/stats |
Get raster statistics |
4.2 Tile URL Format
// For a COG in MinIO:
const tileUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif`;
// Or with custom colormap:
const tileUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif&colormap=${colormapId}`;
4.3 Multiple Layers
// True color (Sentinel-2)
const trueColorUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/truecolor.tif`;
// NDVI
const ndviUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/ndvi_peak.tif&colormap=ndvi`;
// DW Baseline
const dwUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-baselines/DW_Zim_HighestConf_${year}/${year+1}.tif`;
5. Color Mapping
5.1 Crop Classification Colors
Define colormap for LULC classes:
{
"colormap": {
"0": [27, 158, 119], // cropland - green
"1": [229, 245, 224], // forest - dark green
"2": [247, 252, 245], // grass - light green
"3": [224, 236, 244], // shrubland - teal
"4": [158, 188, 218], // water - blue
"5": [240, 240, 240], // builtup - gray
"6": [150, 150, 150], // bare - brown/gray
}
}
5.2 NDVI Color Scale
Use built-in viridis or custom:
const ndviColormap = {
0: [68, 1, 84], // Low - purple
100: [253, 231, 37], // High - yellow
};
6. Frontend Integration
6.1 React Leaflet Integration
// Using react-leaflet
import { TileLayer } from 'react-leaflet';
// Main result layer
<TileLayer
url={`https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif`}
attribution='© GeoCrop'
/>
// DW baseline comparison
<TileLayer
url={`https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-baselines/DW_Zim_HighestConf_${year}/${year+1}.tif`}
attribution='Dynamic World'
/>
6.2 Layer Switching
Implement layer switcher in React:
const layerOptions = [
{ id: 'refined', label: 'Refined Crop Map', urlTemplate: '...' },
{ id: 'dw', label: 'Dynamic World Baseline', urlTemplate: '...' },
{ id: 'truecolor', label: 'True Color', urlTemplate: '...' },
{ id: 'ndvi', label: 'Peak NDVI', urlTemplate: '...' },
];
7. Performance Optimization
7.1 Caching Strategy
TiTiler automatically handles tile caching, but add:
# Kubernetes annotations for caching
annotations:
nginx.ingress.kubernetes.io/enable-access-log: "false"
nginx.ingress.kubernetes.io/proxy-cache-valid: "200 1h"
7.2 MinIO Performance
- Ensure COGs have internal tiling (256x256)
- Use DEFLATE compression
- Set appropriate overview levels
7.3 TiTiler Configuration
# titiler/settings.py
READER = "cog"
CACHE_CONTROL = "public, max-age=3600"
TILES_CACHE_MAX_AGE = 3600 # seconds
# Environment variables for S3/MinIO
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin12
AWS_REGION=dummy
AWS_S3_ENDPOINT=http://minio.geocrop.svc.cluster.local:9000
AWS_HTTPS=NO
8. Security
8.1 MinIO Access
TiTiler needs read access to MinIO:
- Use IAM-like policies via MinIO
- Restrict to specific buckets
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["arn:aws:iam::system:user/tiler"]},
"Action": ["s3:GetObject"],
"Resource": [
"arn:aws:s3:::geocrop-results/*",
"arn:aws:s3:::geocrop-baselines/*"
]
}
]
}
8.2 Ingress Security
- Keep TLS enabled
- Consider rate limiting on tile endpoints
8.3 Security Model (Portfolio-Safe)
Two patterns:
Pattern A (Recommended): API Generates Signed Tile URLs
- Frontend requests "tile access token" per job layer
- API issues short-lived signed URL(s)
- Frontend uses those URLs as tile template
Pattern B: Tiler Behind Auth Proxy
- API acts as proxy adding Authorization header
- More complex
Start with Pattern A if TiTiler can read signed URLs; otherwise Pattern B.
9. Implementation Checklist
- Create Kubernetes deployment manifest for TiTiler
- Create Service
- Create Ingress with TLS
- Add DNS A record for tiles subdomain
- Configure MinIO bucket policies for TiTiler access
- Deploy to cluster
- Test tile endpoint with sample COG
- Verify performance (< 1s per tile)
- Integrate with frontend
10. Alternative: Custom Tiler Service
If TiTiler has compatibility issues, implement custom:
# apps/tiler/main.py
from fastapi import FastAPI, HTTPException
from rio_tiler.io import COGReader
import boto3
app = FastAPI()
s3 = boto3.client('s3',
endpoint_url='http://minio.geocrop.svc.cluster.local:9000',
aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'),
)
@app.get("/tiles/{job_id}/{z}/{x}/{y}.png")
async def get_tile(job_id: str, z: int, x: int, y: int):
s3_key = f"jobs/{job_id}/refined.tif"
# Generate presigned URL (short expiry)
presigned_url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'geocrop-results', 'Key': s3_key},
ExpiresIn=300
)
# Read tile with rio-tiler
with COGReader(presigned_url) as cog:
tile = cog.tile(x, y, z)
return Response(tile, media_type="image/png")
11. Technical Notes
11.1 COG Requirements
For efficient tiling, COGs must have:
- Internal tiling (256x256)
- Overviews at multiple zoom levels
- Appropriate compression
11.2 Coordinate Reference System
Zimbabwe uses:
- EPSG:32736 (UTM Zone 36S) for local
- EPSG:4326 (WGS84) for web tiles
TiTiler handles reprojection automatically.
11.3 Tile URL Expiry
For signed URLs:
- Generate with long expiry (24h) for job results
- Or use bucket policies for public read
- Pass URL as query param to TiTiler
12. Next Steps
After implementation approval:
- Create TiTiler Kubernetes manifests
- Configure ingress and TLS
- Set up DNS
- Deploy and test
- Integrate with frontend layer switcher