Skip to content

Commit 68a3273

Browse files
tile-server-lite: on-the-fly MVT tile server with MapLibre preview and Docker deploy
0 parents  commit 68a3273

15 files changed

Lines changed: 1386 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12"]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
25+
- name: Install package with dev extras
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install -e ".[dev]"
29+
30+
- name: Lint with ruff
31+
run: ruff check .
32+
33+
- name: Run tests
34+
run: pytest -q

.gitignore

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Byte-compiled / optimized / cache
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.egg-info/
6+
.eggs/
7+
build/
8+
dist/
9+
10+
# Virtual environments
11+
.venv/
12+
venv/
13+
env/
14+
15+
# Test / tooling caches
16+
.pytest_cache/
17+
.ruff_cache/
18+
.mypy_cache/
19+
.coverage
20+
htmlcov/
21+
22+
# Local data mounted into the server
23+
/data/
24+
*.gpkg
25+
*.geoparquet
26+
*.parquet
27+
28+
# Editor / OS
29+
.idea/
30+
.vscode/
31+
.DS_Store

Dockerfile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# tile-server-lite — single-file container.
2+
# Build: docker build -t tile-server-lite .
3+
# Run: docker run --rm -v $(pwd)/data:/data -p 8080:8080 tile-server-lite \
4+
# serve /data/parcels.gpkg --host 0.0.0.0 --port 8080
5+
FROM python:3.12-slim
6+
7+
# GeoPandas/pyogrio wheels bundle GDAL, but a couple of runtime libs help.
8+
RUN apt-get update \
9+
&& apt-get install -y --no-install-recommends libexpat1 \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
ENV PYTHONUNBUFFERED=1 \
13+
PIP_NO_CACHE_DIR=1
14+
15+
WORKDIR /app
16+
COPY pyproject.toml README.md ./
17+
COPY src ./src
18+
RUN pip install --upgrade pip && pip install .
19+
20+
# Mount your vector files here: -v $(pwd)/data:/data
21+
VOLUME ["/data"]
22+
WORKDIR /data
23+
EXPOSE 8080
24+
25+
ENTRYPOINT ["tile-server-lite"]
26+
CMD ["--help"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 python-geospatial.com
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# tile-server-lite
2+
3+
> From a local vector file to a live slippy map in one command — on-the-fly Mapbox Vector Tiles with a built-in MapLibre preview.
4+
5+
[![CI](https://github.com/python-geospatial/tile-server-lite/actions/workflows/ci.yml/badge.svg)](https://github.com/python-geospatial/tile-server-lite/actions/workflows/ci.yml)
6+
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
7+
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
8+
9+
`tile-server-lite` is a minimal [FastAPI](https://fastapi.tiangolo.com/) server that reads any
10+
[geopandas](https://geopandas.org/)-readable vector file — GeoPackage, GeoParquet, GeoJSON,
11+
Shapefile — and serves it as XYZ **Mapbox Vector Tiles (MVT)**, cut on the fly. Point it at a file,
12+
open your browser, and you get a working [MapLibre GL JS](https://maplibre.org/) map fit to your
13+
data's bounds.
14+
15+
Maintained by [python-geospatial.com](https://python-geospatial.com), a knowledge base for the
16+
modern Python geospatial stack.
17+
18+
## What & why
19+
20+
Static vector tile pipelines (Tippecanoe, PMTiles) are the right call for large, published datasets.
21+
But while you are *exploring* — checking a fresh export, sharing a quick preview, iterating on a
22+
layer — pre-baking tiles is friction. `tile-server-lite` cuts tiles per request straight from your
23+
file, so there is no build step between "I have a file" and "I have a map".
24+
25+
It complements, rather than replaces, the static [PMTiles](https://python-geospatial.com/web-mapping-interactive-visualization/vector-tile-pipelines-with-pmtiles/)
26+
workflow covered on the site: use this server for local development and previews, then bake to
27+
PMTiles for production hosting (see [How it works](#how-it-works)).
28+
29+
## Install
30+
31+
Not published to PyPI — install straight from the repository:
32+
33+
```bash
34+
pip install "git+https://github.com/python-geospatial/tile-server-lite.git"
35+
```
36+
37+
Or clone and install in editable mode:
38+
39+
```bash
40+
git clone https://github.com/python-geospatial/tile-server-lite.git
41+
cd tile-server-lite
42+
pip install -e ".[dev]"
43+
```
44+
45+
## Usage
46+
47+
Serve a single GeoPackage:
48+
49+
```bash
50+
tile-server-lite serve parcels.gpkg
51+
```
52+
53+
Serve several files at once (each layer is named after its file stem), on a chosen host/port and
54+
zoom window:
55+
56+
```bash
57+
tile-server-lite serve parcels.gpkg sensors.geoparquet \
58+
--host 0.0.0.0 --port 8080 --min-zoom 6 --max-zoom 18
59+
```
60+
61+
Example startup output:
62+
63+
```
64+
tile-server-lite
65+
┏━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
66+
┃ Layer ┃ Geometry ┃ Features ┃ TileJSON ┃
67+
┡━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
68+
│ parcels │ Polygon │ 1 240 │ http://127.0.0.1:8080/tilejson/parcels… │
69+
│ sensors │ Point │ 317 │ http://127.0.0.1:8080/tilejson/sensors… │
70+
└─────────┴──────────┴──────────┴──────────────────────────────────────────┘
71+
Preview map: http://127.0.0.1:8080/ · Served by python-geospatial.com
72+
```
73+
74+
Then:
75+
76+
- open [http://127.0.0.1:8080/](http://127.0.0.1:8080/) for the MapLibre preview,
77+
- fetch a tile at `GET /tiles/parcels/16/35208/21489.mvt`,
78+
- fetch the TileJSON at `GET /tilejson/parcels.json`.
79+
80+
Example TileJSON response (abridged):
81+
82+
```json
83+
{
84+
"tilejson": "3.0.0",
85+
"name": "parcels",
86+
"scheme": "xyz",
87+
"tiles": ["http://127.0.0.1:8080/tiles/parcels/{z}/{x}/{y}.mvt"],
88+
"minzoom": 6,
89+
"maxzoom": 18,
90+
"bounds": [13.4028, 52.5185, 13.4098, 52.5232],
91+
"vector_layers": [
92+
{"id": "parcels", "geometry_type": "Polygon",
93+
"fields": {"parcel_id": "Integer", "zoning": "String"}}
94+
]
95+
}
96+
```
97+
98+
## Deploy with Docker
99+
100+
A single-file `Dockerfile` is included. Build the image, then mount a directory of vector files at
101+
`/data`:
102+
103+
```bash
104+
docker build -t tile-server-lite .
105+
106+
docker run --rm \
107+
-v $(pwd)/data:/data \
108+
-p 8080:8080 \
109+
tile-server-lite serve /data/parcels.gpkg --host 0.0.0.0 --port 8080
110+
```
111+
112+
The map is then live at [http://localhost:8080/](http://localhost:8080/). Binding to `0.0.0.0`
113+
inside the container lets the published port reach the server; keep it on `127.0.0.1` when running
114+
directly on your machine.
115+
116+
## Features
117+
118+
- **Any geopandas-readable input** — GeoPackage, GeoParquet, GeoJSON, Shapefile, and more.
119+
- **True MVT** — geometries clipped to each tile (with an edge buffer) and encoded with
120+
`mapbox-vector-tile` in the standard 0–4096 grid.
121+
- **Spatial-index-backed selection** — only features intersecting a tile are considered.
122+
- **Valid TileJSON 3.0.0** — with 4326 bounds, zoom window, and `vector_layers` metadata.
123+
- **Built-in MapLibre preview** — served at `/`, auto-styled per geometry type, fit to bounds.
124+
- **Correct CRS handling** — reprojects once to Web Mercator for tiling; refuses files with a
125+
missing CRS instead of guessing silently.
126+
- **One-file Docker deploy** and a friendly `click` + `rich` CLI.
127+
128+
## How it works
129+
130+
For each requested `z/x/y` tile the server:
131+
132+
1. computes the tile's EPSG:3857 bounding box with [`mercantile`](https://github.com/mapbox/mercantile),
133+
2. queries the layer's spatial index for intersecting features,
134+
3. clips them to the (buffered) tile box with Shapely,
135+
4. affine-transforms the clipped coordinates into the MVT 0–4096 grid (origin top-left, y down),
136+
5. encodes the result with `mapbox-vector-tile`.
137+
138+
The source file is reprojected to **EPSG:3857 exactly once**, at load time, because web map tiles
139+
are *defined* in Web Mercator. That is correct here — but do not reuse EPSG:3857 for metric analysis
140+
(areas, distances, buffers), where it distorts badly; use a projected/UTM CRS for measurement
141+
instead. For production, bake your explored layers into static PMTiles and serve those; this server
142+
is for the fast local loop before that step.
143+
144+
## Learn more
145+
146+
Deep dives on the concepts behind this tool, from [python-geospatial.com](https://python-geospatial.com):
147+
148+
- [Vector Tile Pipelines with PMTiles](https://python-geospatial.com/web-mapping-interactive-visualization/vector-tile-pipelines-with-pmtiles/)
149+
- [Generating PMTiles from GeoParquet](https://python-geospatial.com/web-mapping-interactive-visualization/vector-tile-pipelines-with-pmtiles/generating-pmtiles-from-geoparquet/)
150+
- [Serving GeoJSON to MapLibre GL JS](https://python-geospatial.com/web-mapping-interactive-visualization/maplibre-gl-vector-web-maps/serving-geojson-to-maplibre-gl-js/)
151+
- [MapLibre GL Vector Web Maps](https://python-geospatial.com/web-mapping-interactive-visualization/maplibre-gl-vector-web-maps/)
152+
- [Cloud-Native Geospatial Formats](https://python-geospatial.com/geospatial-data-ingestion-processing-workflows/cloud-native-geospatial-formats/)
153+
154+
## Development
155+
156+
```bash
157+
git clone https://github.com/python-geospatial/tile-server-lite.git
158+
cd tile-server-lite
159+
pip install -e ".[dev]"
160+
161+
ruff check .
162+
pytest -q
163+
```
164+
165+
Tests generate tiny in-memory geometries in `tmp_path` and run entirely offline — no network access
166+
and no large fixtures required.
167+
168+
## License
169+
170+
[MIT](LICENSE) · Copyright (c) 2026 python-geospatial.com

pyproject.toml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "tile-server-lite"
7+
version = "0.1.0"
8+
description = "Minimal FastAPI vector tile server: serve any geopandas-readable vector file as XYZ Mapbox Vector Tiles with a built-in MapLibre preview."
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = { text = "MIT" }
12+
authors = [{ name = "python-geospatial.com", email = "info@python-geospatial.com" }]
13+
keywords = [
14+
"geospatial",
15+
"vector-tiles",
16+
"mvt",
17+
"maplibre",
18+
"fastapi",
19+
"geopandas",
20+
"web-mapping",
21+
]
22+
classifiers = [
23+
"Development Status :: 4 - Beta",
24+
"Framework :: FastAPI",
25+
"Intended Audience :: Science/Research",
26+
"License :: OSI Approved :: MIT License",
27+
"Programming Language :: Python :: 3.10",
28+
"Programming Language :: Python :: 3.11",
29+
"Programming Language :: Python :: 3.12",
30+
"Topic :: Scientific/Engineering :: GIS",
31+
]
32+
dependencies = [
33+
"fastapi>=0.110",
34+
"uvicorn[standard]>=0.29",
35+
"geopandas>=0.14",
36+
"shapely>=2.0",
37+
"mercantile>=1.2",
38+
"mapbox-vector-tile>=2.0",
39+
"click>=8.1",
40+
"rich>=13",
41+
]
42+
43+
[project.optional-dependencies]
44+
dev = [
45+
"pytest>=7.4",
46+
"ruff>=0.4",
47+
"httpx>=0.27",
48+
]
49+
50+
[project.urls]
51+
Homepage = "https://python-geospatial.com"
52+
Repository = "https://github.com/python-geospatial/tile-server-lite"
53+
54+
[project.scripts]
55+
tile-server-lite = "tile_server_lite.cli:main"
56+
57+
[tool.hatch.build.targets.wheel]
58+
packages = ["src/tile_server_lite"]
59+
60+
[tool.ruff]
61+
line-length = 100
62+
target-version = "py310"
63+
64+
[tool.ruff.lint]
65+
select = ["E", "F", "I", "UP", "B"]
66+
67+
[tool.pytest.ini_options]
68+
testpaths = ["tests"]

src/tile_server_lite/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""tile-server-lite: serve local vector files as XYZ Mapbox Vector Tiles.
2+
3+
Maintained by python-geospatial.com, a knowledge base for the modern Python
4+
geospatial stack.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
__version__ = "0.1.0"
10+
11+
from tile_server_lite.data import LayerSource
12+
13+
__all__ = ["LayerSource", "__version__"]

src/tile_server_lite/__main__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Enable ``python -m tile_server_lite``."""
2+
3+
from __future__ import annotations
4+
5+
from tile_server_lite.cli import main
6+
7+
if __name__ == "__main__": # pragma: no cover
8+
main()

0 commit comments

Comments
 (0)