This guide explains how to serve raster tiles from VRT/COG files referenced in PostgreSQL, without storing actual pixel data in the database.
Why Out-of-Database?
| Approach | Storage | Performance | Use Case |
|---|---|---|---|
| In-DB Raster | Pixels in PostgreSQL | Heavy DB load | Analysis, small datasets |
| Out-DB Raster | Metadata + file paths | Fast, GDAL renders | Tile serving, large datasets |
Out-of-database rasters store only metadata and file paths in PostgreSQL. The actual pixel data stays in COG/VRT files on disk or cloud storage. This approach:
- Reduces database load - No pixel processing in PostgreSQL
- Leverages GDAL - Efficient tile rendering with reprojection
- Enables dynamic filtering - Query parameters passed to PostgreSQL function
- Supports mosaics - Multiple rasters composited per tile
Prerequisites
- PostgreSQL 11+ with PostGIS 3.0+
- Out-of-database rasters enabled:
SET postgis.enable_outdb_rasters = true; - COG or VRT files accessible from the server
- tileserver-rs built with
postgresandrasterfeatures
Configuration
[postgres]
connection_string = "postgresql://user:pass@localhost:5432/gis"
[[postgres.outdb_rasters]]
id = "imagery" # Also used as function name if 'function' is omitted
schema = "public"
# function = "get_raster_paths" # Optional: defaults to 'id' value
name = "Satellite Imagery"
minzoom = 0
maxzoom = 22
bounds = [-180.0, -85.0, 180.0, 85.0]
Configuration Options
| Option | Required | Description |
|---|---|---|
id | Yes | Unique source identifier (also used as function name if function omitted) |
schema | No | PostgreSQL schema (default: public) |
function | No | Function name that returns file paths (default: id value) |
name | No | Display name |
minzoom | No | Minimum zoom level (default: 0) |
maxzoom | No | Maximum zoom level (default: 22) |
bounds | No | Bounding box west, south, east, north |
resampling | No | Resampling method used to warp each tile. Default: nearest with a discrete colormap, else bilinear |
colormap | No | Colormap for single-band rasters |
dataset_cache_size | No | Max open raster files kept by this source (default: 100, 0 turns the cache off) |
dataset_cache_ttl_secs | No | Seconds an open raster file stays cached (default: 600) |
Resampling
resampling sets the GDAL warp algorithm for every tile: nearest,
bilinear, cubic, cubicspline, lanczos, average or mode.
For categorical rasters (land cover, crop classes) use nearest or mode.
bilinear mixes the values of two classes at their border and creates class
values that are not in the source. With a discrete colormap and no
resampling set, the source uses nearest.
In v2.39.0 and earlier the setting had no effect: every tile was warped with
bilinear. Categorical rasters with a discrete colormap now render without
the in-between colours at class borders.
Rebuilt raster files
The source keeps recently used raster files open. On each tile it checks the file's modified time and size, and opens the file again when either changed. A VRT or GeoTIFF rebuilt at the same path is served on the next tile, with no restart.
For a VRT, only the VRT file itself is checked. If you change a source file
that the VRT references but not the VRT, the change shows up after
dataset_cache_ttl_secs. Remote paths (/vsicurl/, /vsis3/) also use the
TTL only.
PostgreSQL Function
Your function receives tile coordinates, a bounding box geometry, and query parameters as JSONB:
CREATE OR REPLACE FUNCTION get_raster_paths(
z INT,
x INT,
y INT,
tile_bbox GEOMETRY,
params JSONB
) RETURNS TABLE(filepath TEXT) AS $$
BEGIN
RETURN QUERY
SELECT r.file_path
FROM raster_catalog r
WHERE ST_Intersects(r.bounds, tile_bbox)
AND (params->>'satellite' IS NULL
OR r.satellite = params->>'satellite')
AND (params->>'date_from' IS NULL
OR r.acquired >= (params->>'date_from')::date)
AND (params->>'date_to' IS NULL
OR r.acquired <= (params->>'date_to')::date)
AND (params->>'cloud_cover' IS NULL
OR r.cloud_cover <= (params->>'cloud_cover')::float)
ORDER BY r.priority DESC, r.acquired DESC
LIMIT COALESCE((params->>'limit')::int, 5);
END;
$$ LANGUAGE plpgsql STABLE;
Function Signature
| Parameter | Type | Description |
|---|---|---|
z | INT | Zoom level |
x | INT | Tile X coordinate |
y | INT | Tile Y coordinate |
tile_bbox | GEOMETRY | Web Mercator (EPSG:3857) tile bounding box |
params | JSONB | All HTTP query parameters |
Return Value
The function must return a table with a filepath column containing paths to raster files.
API Usage
Basic Request
curl http://localhost:8080/data/imagery/10/512/256.png -o tile.png
With Filters
All query parameters are passed to your PostgreSQL function as JSONB:
curl "http://localhost:8080/data/imagery/10/512/256.png?satellite=sentinel-2&cloud_cover=10"
curl "http://localhost:8080/data/imagery/10/512/256.png?date_from=2024-01-01&date_to=2024-06-01"
curl "http://localhost:8080/data/imagery/10/512/256.png?satellite=landsat-8&latest=true"
VRT Support
VRT (Virtual Raster) files work seamlessly. Your function can return paths to .vrt files:
RETURN QUERY
SELECT '/data/mosaics/2024-q1.vrt'
WHERE ST_Intersects(mosaic_bounds, tile_bbox);
VRTs are powerful for:
- Mosaics - Combine multiple files into one virtual raster
- Band selection - Reference specific bands from multi-file datasets
- Overviews - Reference pre-built overview pyramids
Setting Up Out-DB Rasters
1. Enable Out-DB Rasters in PostGIS
ALTER SYSTEM SET postgis.enable_outdb_rasters = true;
SELECT pg_reload_conf();
Or in your session:
SET postgis.enable_outdb_rasters = true;
SET postgis.gdal_enabled_drivers = 'ENABLE_ALL';
2. Create a Raster Catalog Table
CREATE TABLE raster_catalog (
id SERIAL PRIMARY KEY,
file_path TEXT NOT NULL,
bounds GEOMETRY(Polygon, 4326),
satellite TEXT,
acquired DATE,
cloud_cover FLOAT,
priority INT DEFAULT 0
);
CREATE INDEX ON raster_catalog USING GIST(bounds);
CREATE INDEX ON raster_catalog(satellite);
CREATE INDEX ON raster_catalog(acquired);
3. Register Your Rasters
INSERT INTO raster_catalog (file_path, bounds, satellite, acquired)
SELECT
'/data/cogs/sentinel_' || id || '.tif',
ST_MakeEnvelope(west, south, east, north, 4326),
'sentinel-2',
acquisition_date
FROM raw_metadata;
Performance Tips
1. Spatial Indexing
Always create a GIST index on your geometry column:
CREATE INDEX ON raster_catalog USING GIST(bounds);
2. Dataset Caching
tileserver-rs caches opened GDAL datasets. Frequently accessed rasters stay in memory.
3. Use COGs
Cloud Optimized GeoTIFFs are faster than regular TIFFs:
gdal_translate input.tif output.cog.tif \
-of COG \
-co COMPRESS=ZSTD \
-co OVERVIEW_RESAMPLING=BILINEAR
4. Pre-build Overviews for VRTs
gdaladdo -r average mosaic.vrt 2 4 8 16
5. Limit Results
Always use LIMIT in your function to avoid returning too many files per tile.
Colormaps
For single-band rasters (DEMs, indices), configure a colormap:
[[postgres.outdb_rasters]]
id = "elevation"
function = "get_dem_paths"
[postgres.outdb_rasters.colormap]
map_type = "continuous"
nodata_color = "#00000000"
entries = [
{ value = 0, color = "#0000FF" },
{ value = 1000, color = "#00FF00" },
{ value = 3000, color = "#FF0000" },
{ value = 5000, color = "#FFFFFF" }
]
Colormap Options
| Option | Values | Description |
|---|---|---|
map_type | discrete, continuous | Discrete for exact matching, continuous for interpolation |
rescale_mode | static, dynamic, none | How to transform pixel values before colormap lookup |
nodata_color | Hex color | Color for nodata pixels (e.g., #00000000 for transparent) |
entries | Array | Value-to-color mappings |
Rescale Modes
| Mode | Description | Use Case |
|---|---|---|
static | Use raw pixel values directly (default) | Pre-normalized data (0-1 range) |
dynamic | Normalize using rescale_min/rescale_max from function | Variable-range data per tile |
none | Skip all normalization, use raw values | Categorical/classified rasters |
Categorical Rasters with rescale_mode = "none"
For classified rasters where pixel values represent discrete categories (land cover, crop types, soil maps), use rescale_mode = "none" with map_type = "discrete":
[[postgres.outdb_rasters]]
id = "landcover"
function = "get_landcover_paths"
[postgres.outdb_rasters.colormap]
map_type = "discrete"
rescale_mode = "none"
nodata_color = "#00000000"
entries = [
{ value = 0, color = "#00000000" }, # nodata
{ value = 1, color = "#FD080C" }, # cotton
{ value = 2, color = "#1D90FF" }, # maize
{ value = 3, color = "#22FDD5" }, # wheat
{ value = 4, color = "#FAD200" }, # sugarcane
{ value = 5, color = "#8B4513" }, # fallow
]
This approach:
- Uses raw pixel values directly (1, 2, 3...) instead of normalized values (0.125, 0.25...)
- Matches values within ±0.5 tolerance for discrete lookup
- Falls back to
nodata_colorwhen no match is found
Without rescale_mode = "none", you would need to calculate normalized values (e.g., 1/8=0.125 for an 8-class raster) and create step gradients in your colormap entries.
References
Next Steps
- Raster Sources - COG/VRT source configuration
- Vector Tiles - Serve vector tiles from PMTiles/MBTiles
- Configuration Reference - All config options