# 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
```mermaid
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-tiler` as 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`:
```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:
```yaml
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
```javascript
// 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
```javascript
// 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:
```json
{
"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:
```javascript
const ndviColormap = {
0: [68, 1, 84], // Low - purple
100: [253, 231, 37], // High - yellow
};
```
---
## 6. Frontend Integration
### 6.1 React Leaflet Integration
```javascript
// Using react-leaflet
import { TileLayer } from 'react-leaflet';
// Main result layer
// DW baseline comparison
```
### 6.2 Layer Switching
Implement layer switcher in React:
```javascript
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:
```yaml
# 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
```python
# 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
```json
{
"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:
```python
# 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:
1. Create TiTiler Kubernetes manifests
2. Configure ingress and TLS
3. Set up DNS
4. Deploy and test
5. Integrate with frontend layer switcher